From 345fef5256f86e06686a7335ed4bca1c54499f7c Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 16 Sep 2026 09:01:27 +0800 Subject: [PATCH 01/71] docs(native-list): add the shared style spec and drop dead web CSS Add docs/STYLE_SPEC.md as the style vocabulary for rows, section headers, fixed footers, and empty states. The three renderers cannot constrain each other, so the spec is authoritative by review plus the one boundary they share: the serialized snapshot. It records the design tokens aliased to the application's own token names, the per-template style surface keyed by model field rather than by view (metricCard renders its value through the title label, so view names mis-target), list chrome, the template isolation rules, and a review checklist. Cross-platform divergences are registered, not changed: the three row-height tables disagree (rail 40/28/40, sectionHeader summary 68/80/68), Android draws sticky headers through a second renderer with its own typography, section headers carrying a value or a checkbox silently lose stickiness on Android, and source scale is computed list-wide. Remove dead duplicated rules from WEB_LIST_CSS: four blocks were emitted twice and a prefers-reduced-motion block three times, and every property of the earlier copies was redeclared by the later ones, so removing them changes no rendering. The two oddities that look like duplication are load-bearing and are documented instead - the !important on the account action row overrides an inline radius, and the market change chip's literal colors do not equal their nearest tokens. Also apply prettier to NativeList.web.test.ts, which was left unformatted by d91262e98 and failed lint before this change. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 6 + .../react-native-native-list/README.md | 5 +- .../docs/STYLE_SPEC.md | 436 ++++++++++++++++++ .../src/web/NativeListWebEngine.ts | 9 +- 4 files changed, 448 insertions(+), 8 deletions(-) create mode 100644 native-views/react-native-native-list/docs/STYLE_SPEC.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a3e7a8b7..2e0cdab01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Documentation +- **native-list**: Add `docs/STYLE_SPEC.md`, the shared style vocabulary for rows, section headers, fixed footers, and empty states. It records the design tokens (aliased to the application's own token names), the per-template style surface keyed by model field, list chrome, the template isolation rules, and a review checklist. Cross-platform divergences — row-height tables, typography, the Android sticky-header renderer, list-wide source scale — are registered rather than changed. + +### Chores +- **native-list (Web)**: Remove dead duplicated rules from `WEB_LIST_CSS`. Four blocks (`.ok-native-list-footer`/`-sticky`/`-index-rail`/`-index-button`, `-refresh`, `-warning`, `-subtitle-segments`) were emitted twice and a `prefers-reduced-motion` block three times; every property of the earlier copies was redeclared by the later ones, so removing them changes no rendering. + ## [3.0.156] - 2026-09-21 ### Features diff --git a/native-views/react-native-native-list/README.md b/native-views/react-native-native-list/README.md index 40eb165a2..3c9da2ebe 100644 --- a/native-views/react-native-native-list/README.md +++ b/native-views/react-native-native-list/README.md @@ -401,4 +401,7 @@ use Instruments or Android Studio Profiler for native frame and memory data. the React component. It mirrors the mobile data and imperative APIs, but its performance must still be measured independently from iOS and Android. -See [docs/DESIGN.md](docs/DESIGN.md) for the architecture and ownership model. +See [docs/DESIGN.md](docs/DESIGN.md) for the architecture and ownership model, and +[docs/STYLE_SPEC.md](docs/STYLE_SPEC.md) for the shared style vocabulary: design +tokens, the per-template style surface with each platform's current values, and the +registered cross-platform divergences. diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md new file mode 100644 index 000000000..935012e03 --- /dev/null +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -0,0 +1,436 @@ +# NativeList Style Spec + +Status: **draft**. Line numbers are as of `d91262e98`; symbol names are the stable +anchor and should be preferred when they disagree. + +This document is the shared design vocabulary for `@onekeyfe/react-native-native-list`. +iOS, Android, and Web each own a separate renderer, and nothing in the build forces +them to agree. This spec is therefore **authoritative by review, not by codegen**. +It records what a value is called, what it should be, and what each platform +currently does — so a divergence is visible in review instead of on a device. + +See [DESIGN.md](DESIGN.md) for the architecture and ownership model. + +## 1. Why this exists + +The list serves 13 row templates from one shared view pool per platform. Every +template hard-codes its own typography and geometry in three places, and the three +vocabularies have drifted: + +- The package's theme keys and the application's design tokens are **two different + names for the same colors**. Every consumer writes a mapping table by hand. +- The application has a named type scale (`$bodyMd`, `$headingSm`, …). The list has + ten-plus bare font sizes and references the scale nowhere. "One step smaller" has + no shared meaning. +- The same concept resolves to different numbers per platform (see §6). + +Only `market` rows accept a `style` object today. Every other template is tuned by +adding a `presentation` variant plus a branch in each renderer. + +## 2. How this spec is enforced + +The three renderers cannot constrain each other, but they all read the same +`snapshotJson`. That boundary is the single enforcement point. + +| Layer | Enforced by | Covers | +| --- | --- | --- | +| Token names and values | `src/validation.ts` resolves tokens to numbers before serialization | §3 | +| Which keys a template accepts | `validateSnapshot` rejects keys not declared for that row type | §4 | +| Numeric bounds | `validateSnapshot` throws on out-of-range values | §3, §4 | +| Per-platform default values | **This document + PR review** | §4, §5 | +| Known divergences | **This document + PR review** | §6 | + +A style token is resolved on the JavaScript side. `{ token: '$bodyLg' }` becomes +`{ fontSize: 16, lineHeight: 24, fontWeight: 'medium' }` before it crosses the +bridge. Native renderers never learn the token vocabulary and therefore cannot +drift from it. Raw numeric overrides stay available for pixel-parity work. + +## 3. T1 — Design tokens + +### 3.1 Color tokens + +`NativeListTheme` keys and the application's tokens are aliases. New code should +use the application name; the theme key is retained for compatibility. + +| Application token | `NativeListTheme` key | Role | +| --- | --- | --- | +| `text` | `primaryText` | Primary label | +| `textSubdued` | `secondaryText` | Secondary label | +| `textDisabled` | `disabledText` | Disabled / timestamp | +| `textInverse` | `inverseText` | Text on inverted ground | +| `textSuccess` | `positive` | Positive amount | +| `textCritical` | `negative` | Negative amount | +| `textCaution` | `caution` | Warning text | +| `textInfo` | `info` | Informational text, search highlight | +| `bgApp` | `background` | List ground | +| `bg` | `rowBackground` | Row ground | +| `bgActive` | `rowSelectedBackground`, `rowPressedBackground` | Selected / pressed row | +| `bgSubdued` | `subduedBackground` | Card and group ground | +| `bgStrong` | `strongBackground` | Chip / badge ground | +| `bgInverse` | `inverseBackground` | Inverted ground | +| `bgCriticalSubdued` | `criticalBackground` | Failed-state chip | +| `bgCautionSubdued` | `cautionBackground` | Caution chip | +| `borderSubdued` | `separator` | Separator, group border | +| `icon` | `icon` | Icon default | +| `iconSubdued` | `iconSubdued` | Secondary icon | +| `iconActive` | `accent` | Accent — **name mismatch, see §6** | + +### 3.2 Typography scale + +Sizes below are the application's scale. The "used by" column records which list +slots already sit on that step; slots that do not are listed in §4. + +| Token | Size / line height | Weight | Used by | +| --- | --- | --- | --- | +| `$headingXl` | 24 / 32 | semibold | `metricCard.value` (size `large`) | +| `$headingLg` | 20 / 28 | semibold | — | +| `$headingMd` | 18 / 24 | semibold | `sectionHeader` gallery, `metricCard.value` | +| `$headingSm` | 16 / 24 | medium | `identity.title`, `market.title`, `market.price` | +| `$headingXs` | 14 / 20 | semibold | `message.title`, `sectionHeader` default | +| `$bodyLg` | 16 / 24 | regular | `action.title`, `sectionHeader` summary | +| `$bodyMd` | 14 / 20 | regular | `identity.subtitle`, `message.body`, `market.change` | +| `$bodySm` | 12 / 16 | regular | `rail.title`, `market.subtitle`, `identity.badge` | +| `$bodyXs` | 11 / 16 | regular | `metricCard` labels, `market` badges | + +Weight names map to the bundled Roobert faces: `regular`, `medium`, `semibold`, +`bold`. No other family is available; `fontFamily` is not part of the style surface. + +### 3.3 Spacing, radius, bounds + +| Kind | Allowed values | Bound | +| --- | --- | --- | +| Padding / gap | any integer | `0…64` | +| `lineGap` (title to subtitle) | any integer | `0…16` | +| Font size | any integer | `8…48` | +| Line height | any integer | `8…64` | +| Corner radius | any integer | `0…80` | +| Image edge | any integer | `1…160` | + +Radius steps in use: `4` (chip), `8` (rail, small visual), `10` (visual `rounded`), +`12` (row, card), `16` (media tile), `20` (wallet sidebar row on iOS only — §6). + +## 4. T2 — Template style surface + +`style.X` modifies the rendering of the **model field named `X`**, regardless of +which physical view carries it. This matters because the view pool is shared: on +every platform `metricCard` renders its *value* through the title label and its +*title* through the subtitle label, and the status label carries `rail.status`, +`activity.status`, `message.time`, and `metricCard.trend`. Naming style keys after +views would therefore mis-target. `market` already follows this rule with +`style.price` / `style.change`. + +Legend: **=** all three platforms agree; **≠** registered divergence, see §6. + +### identity + +| Key | Default | iOS | Android | Web | +| --- | --- | --- | --- | --- | +| `title` | `$headingSm` | 16 medium / 24 | sp(16) medium / dp(24) | 16px / 20px / 600 **≠** | +| `subtitle` | `$bodyMd` | 14 regular / 20 | sp(14) regular / dp(20) | 14px / 20px **=** | +| `tertiary` | `$bodyMd` | 14 regular / 20 | sp(14) / dp(20) | 14px / 20px **=** | +| `badge` | `$bodySm` | 12 medium / 16, pad 8/2, r4 | sp(12) / dp(16), pad 8/2, r4 | 11px / 18px, pad 0 5, r5 **≠** | +| `value` | `$headingSm` | 16 medium | sp(16) medium / dp(24) | 14px / 20px / 500 **≠** | +| `valueSecondary` | `$bodyMd` | 14 regular | sp(14) regular / dp(20) | 14px **=** | +| box | pad 12/8, gap 12 | 12 / -12 / 8 / -8, spacing 12 | dp(12), dp(8) | `padding:8px 12px`, `gap:12px` **=** | +| `image` | 40 | 40×40 (32 for `network`) | 40 (32 for `network`) | 40px (32px for account) **=** | + +### identity · presentation `walletSidebar` + +| Key | Default | iOS | Android | Web | +| --- | --- | --- | --- | --- | +| `title` | `$bodySm` | 12 regular / 16, centered | sp(12) / dp(16), centered | 12px / 16px / 400 **=** | +| `badge` | `$bodyXs` | 11 / 14, pad 6/2, r4 | sp(11) / dp(14), pad 6/2, r4 | 11px / 14px, pad 2 6, r4 **=** | +| `image` | 40 | 40×40, fallback 28 | 40, fallback sp(28) | 40px **=** | +| box | pad 4 | 4 / -4 / 4 / -4 | dp(4) × 4 | `padding:4px 8px` **≠** | +| row radius | 12 | 20 | 12 | 12 **≠** | + +### identity · presentation `accountSelector` + +| Key | Default | iOS | Android | Web | +| --- | --- | --- | --- | --- | +| `title` | `$bodyLg` | 16 regular | sp(16) regular | 16px / 20px / 400 **=** | +| `subtitle` | `$bodyMd` | 14 regular / 20 | sp(14) / dp(20) | 14px / 20px / 400 **=** | +| `image` | 32 | 32×32 | 32 | 32px **=** | +| trailing icon | 38 | 38, margin −7 | dp(38), margin −dp(7) | 38px, margin −7px **=** | + +### identity · presentation `networkSelector` + +| Key | Default | iOS | Android | Web | +| --- | --- | --- | --- | --- | +| `title` | `$headingSm` | 16 medium / 24 | sp(16) / dp(24) | 16px / 24px / 500 **=** | +| `image` | 32 | 32×32, fallback 19/27 semibold | 32, fallback sp(19)/dp(27) | 32px, fallback 19/27/600 **=** | +| checkbox | 20, r4, border 2 | 20×20, r4, border 2 | dp(20), r4f, stroke dp(2) | 20px, r4, border 2px **=** | + +### walletGroup + +| Key | Default | iOS | Android | Web | +| --- | --- | --- | --- | --- | +| member gap | 12 | stack spacing 12 | topMargin dp(12) | `gap:12px` **=** | +| container radius | 12 | 20 | dp(12) | 12px **≠** | +| container border | 1, `borderSubdued` | 1 | dp(1) | 1px **=** | + +### rail + +| Key | Default | iOS | Android | Web | +| --- | --- | --- | --- | --- | +| `title` | `$bodySm` | 12 medium / 16 | sp(12) / dp(16) | 12px / 500 **=** | +| `badge` | `$bodySm` | 12 medium / 16 | sp(12) medium / dp(16) | — | +| `status` | `$bodySm` | 12 regular / 16 | sp(12) | — | +| box | pad 4, gap 6 | 4 / -4, spacing 6 | dp(4), spacing 6 | `padding:4px;gap:6px` **=** | +| `image` | 20 | 20×20 | 20 | 20px **=** | +| row radius | 8 | 8 | 8 | 8px **=** | + +### activity + +| Key | Default | iOS | Android | Web | +| --- | --- | --- | --- | --- | +| `title` | `$headingSm` | lineHeight 24 | dp(24) | 16px / 20px **≠** | +| `description` | `$bodyMd` | lineHeight 20, 2 lines | dp(20), 2 lines | 14px / 20px **=** | +| `primaryAmount` | `$headingSm` | 16 medium / 24, tabular | sp(16) medium / dp(24) | 16px / 20px **≠** | +| `secondaryAmount` | `$bodyMd` | 14 regular / 20, tabular | sp(14) / dp(20) | 14px **=** | +| footer action | `$bodyMd` | 12 medium, r8, pad 4/8 | sp(14) semibold, r8f, pad 8/4 | 12px / 16px / 600 **≠** | + +### message + +| Key | Default | iOS | Android | Web | +| --- | --- | --- | --- | --- | +| `title` | `$headingXs` | 14 semibold / 20, 2 lines | sp(14) semibold / dp(20) | 15px / 20px / 600 **≠** | +| `body` | `$bodyMd` | 14 regular / 20 | sp(14) / dp(20) | 13px / 18px **≠** | +| `time` | `$bodySm` | 12 / 16, `textDisabled` | sp(12) / dp(16), `disabledText` | 11px / 16px **≠** | +| `image` | 28 | 28×28 | 28 | 40px **≠** | +| thumbnail | 64, r6 | 64×64, r6 | dp(64), r6f | 64px, r10 **≠** | +| box | pad 12/16 | 16 / -16 | dp(12), dp(16) | `padding:8px 12px` **≠** | + +### dataRow + +| Key | Default | iOS | Android | Web | +| --- | --- | --- | --- | --- | +| `columns` (linear) | `$bodyMd` | 14 medium | sp(16) medium | 16px / 500 **≠** | +| `columns` (table) | `$bodyMd` | 14 medium / 20 | sp(14) medium / dp(20) | 16px / 500 **≠** | +| column secondary | `$bodySm` | 12 regular / 16 | sp(12) / dp(16) | — | +| `index` | `$bodySm` | — | dp(32) slot | 13px, 28px slot **≠** | +| favorite icon | 20 | 20×20 | dp(20) | 24px / 22px glyph **≠** | +| box | pad 12/6 | table 20/10 | table dp(20)/dp(10) | `padding:6px 12px` **=** | + +### market + +Already fully data-driven through `MarketRowStyle`. The values below are the +defaults applied when `style` is absent. + +| Key | Default | All platforms | +| --- | --- | --- | +| `title` | `$headingSm` | 16 / 24 / medium | +| `subtitle` | `$bodyMd` | 14 / 20 / regular | +| `price` | `$headingSm` | 16 / 24 / medium | +| `change` | `$bodyMd` | 14 / 20 / medium, block 80×32 r8 | +| box | pad 20/12, gap 14 | perp 16, gap 8 | +| `image` | 32 (stock 40) | circle | + +### mediaTile + +| Key | Default | iOS | Android | Web | +| --- | --- | --- | --- | --- | +| `title` | `$headingSm` | 16 medium / 24 | sp(16) / dp(24) | 16px / 500 **=** | +| `subtitle` | `$bodySm` | 12 regular / 16 | sp(12) / dp(16) | 12px **=** | +| `badge` | `$bodyMd` | 14 medium, h24, r10 | sp(14), h dp(24), r10f | — | +| image radius | 10 | 10 | dp(10) | 10px **=** | +| tile radius | 16 | 16 | 16 | 16px **=** | +| network badge | 14 | 14×14, r7 | dp(14), circle | 14px, r50% **=** | + +### metricCard + +`title` is the small label; `value` is the large number. They are rendered through +the subtitle and title views respectively — do not follow the view names. + +| Key | Default | iOS | Android | Web | +| --- | --- | --- | --- | --- | +| `title` | `$bodyXs` | 11 regular, `textDisabled` | sp(11), `disabledText` | 14px **≠** | +| `value` | `$headingMd` | 18 semibold (24 when `size: large`) | sp(18) / sp(24) semibold | 22px / 28px / 700 **≠** | +| `trend` | `$bodySm` | 12, tone colored | sp(12), tone colored | — | +| metric label | `$bodyXs` | 11 regular / 14 | sp(11) / dp(14) | — | +| metric value | `$bodyMd` | 14 medium / 20 | sp(14) medium / dp(20) | 18px / 600 **≠** | +| card padding | 14 | 14 | dp(14) | 12px **≠** | +| card radius | 12 | 12 | 12 | 12px **=** | +| progress bar | 4, r2 | 4, r2 | dp(4), r dp(2) | 4px, r2 **=** | + +### sectionHeader + +| Variant | Default | iOS | Android | Web | +| --- | --- | --- | --- | --- | +| default | `$headingXs` | 14 semibold / 20 | sp(14) semibold / dp(20) | 13px / 18px / 700 **≠** | +| `summary` | `$bodyLg` | 16 medium / 24 | sp(16) medium / dp(24) | 16px / 500 **=** | +| `gallery` | `$headingMd` | 18 semibold / 24 | sp(18) semibold / dp(24) | 16px **≠** | +| `history` | `$bodySm` | 12 semibold / 16, tracking 0.8 | sp(12) semibold / dp(16), tracking 0.8 | 12px / 16px **=** | +| table layout | `$bodyXs` | 11 regular / 14 | sp(11) regular / dp(14) | — | +| `value` | `$bodyLg` | 16 / 24 | sp(16) medium / dp(24) | 14px / 20px / 500 **≠** | + +### action + +| Key | Default | iOS | Android | Web | +| --- | --- | --- | --- | --- | +| `title` | `$bodyLg` | 16 / 24 | sp(16) / dp(24) | 15px / 600 **≠** | +| `title` (accountSelector) | `$bodyLg` | 16 medium / regular | medium / regular | 16px / 24px / 400 **=** | +| icon slot | 40 (32 accountSelector) | 40 / 32, glyph 24 | 40 / 32, glyph dp(24) | 40px / 32px **=** | +| box | pad 16 | — | — | `padding:0 16px` **=** | + +### system + +| Variant | Default | iOS | Android | Web | +| --- | --- | --- | --- | --- | +| `loading` skeleton | — | marks 32 / 80×16 / 60×12 / 80×18 | same | same **=** | +| `loading` spinner | 20 | 20, market 32 | dp(20), market 32 | 20px, market 32px **=** | +| `retry` | `$bodyMd` | 14 medium / 20, r14 | sp(14) / dp(20), r16f | 12px **≠** | +| `noMatch` / `end` | `$bodyMd` | 14 regular / 20 | sp(14) / dp(20) | 14px **=** | +| `warning` | `$bodyMd` | 14 medium + regular / 20 | sp(14) / dp(20) | 14px / 20px **=** | +| `spacer` | — | `height` only | `height` only | `height` only **=** | + +### Carrier coverage + +`sectionHeader`, `fixedFooter`, and `emptyState` are ordinary rows on all three +platforms — iOS binds the footer through `NativeListCell`, Android through +`NativeListRowView.bind`, Web through `renderElement`, and an empty snapshot's +`emptyState` is spliced into `rows` during parsing. One `style` surface therefore +covers all four carriers. The only exception is the Android sticky header (§6). + +## 5. T3 — List chrome + +Chrome is not part of a row and needs its own surface (`snapshot.listStyle`, +not yet implemented). + +| Item | Data-driven today | iOS | Android | Web | +| --- | --- | --- | --- | --- | +| Content padding | yes (`layout.contentPadding*`) | `contentInset` | `setPadding` | `paddingValues` | +| Item spacing | yes (`layout.itemSpacing`) | flow layout spacing | `ItemSpacingDecoration` | layout gap | +| Separator | partial (`row.separator`) | 1/scale, inset 60/12 | 1px, inset dp(60)/dp(12) | `1px`, class rule | +| Group card radius | no | 20 / 12 | dp(12) | 12px | +| Section index rail | partial (`capabilities.sectionIndex`) | static constants, label 10 | `SECTION_INDEX_*_DP`, raw density | `SECTION_INDEX_*` + CSS | +| Section index preview | no | 48×48 r14, 22 semibold | preview w/h/margin constants | 48px r14, 22px | +| Pull to refresh | partial (`capabilities.pullToRefresh`) | `UIRefreshControl` | `SwipeRefreshLayout` | custom pill, 12px | +| Reorder preview | no | — | — | r12, shadow `0 4px 24px` | +| Reorder count badge | no | 24 h, r12, 12 semibold | dp(24), r dp(12), sp(12) semibold | 24px, r12, 12/22/600 | + +The section index rail is three separate constant sets for one control; Android's +`sectionIndexDp()` deliberately never follows the source-scale switch. + +## 6. T4 — Known divergences + +Registered, **not** fixed by this document. Changing any of these requires a PR +that cites this table and carries device verification. + +### 6.1 Row heights + +Row height is computed from data by three independent implementations — +`RNCNativeListView.rowHeight()`, `NativeListRowView.applySize()`, and +`estimateWebRowHeight()`. They disagree: + +| Template | iOS | Android | Web | +| --- | --- | --- | --- | +| `rail` | 40 | 28 | 40 | +| `activity` + footer actions | 100 | 104 | 100 | +| `dataRow` + secondary text | 60 | 64 | 60 | +| `sectionHeader` `summary` | 68 | 80 | 68 | +| `sectionHeader` value + checkbox | 56 | 40 | 56 | +| `message`, `mediaTile`, composite `metricCard` | computed / 244 / 160+ | 0 (wrap content) | computed / 244 / 161 | + +Within iOS, `NativeListCell.bindMessage()` sizes the leading slot at 28 while +`rowHeight()` estimates text width against 40; `bindSystem()` uses 52/120 for a +market retry row where `rowHeight()` uses 44, and 88 for `noMatch` where +`rowHeight()` uses 44. + +These values may have been tuned per platform on real devices. They are recorded +here and left alone. + +### 6.2 Typography and geometry + +| Concept | iOS | Android | Web | +| --- | --- | --- | --- | +| `identity.title` | 16 medium / 24 | sp(16) medium / dp(24) | 16px / **20px** / **600** | +| `identity.badge` | 12 / 16, pad 8/2, r4 | sp(12) / dp(16), pad 8/2, r4 | **11 / 18, pad 0 5, r5** | +| `message.body` | 14 / 20 | sp(14) / dp(20) | **13 / 18** | +| `message` leading | 28 | 28 | **40** | +| `sectionHeader` default | 14 semibold / 20 | sp(14) semibold / dp(20) | **13 / 18 / 700** | +| `action.title` | 16 / 24 | sp(16) / dp(24) | **15 / 600** | +| `metricCard.value` | 18 semibold | sp(18) semibold | **22 / 28 / 700** | +| visual `rounded` radius | `min(10, h/4)` | `min(10, size/4)`; 8 for accountSelector | **10px**; 8 `!important` for account action; 8 for market | +| `walletSidebar` row radius | **20** | 12 | 12 | + +### 6.3 Behavioral + +- **Android sticky headers are a second renderer.** + `StickySectionHeaderDecoration` draws the pinned header with `canvas.drawText`, + carrying its own `Paint`, font size (14 / 12), hard-coded semibold typeface, + height (36 / 16), horizontal inset (20 / 8), manual letter spacing (× 0.8), and + baseline math. iOS pins the real cell through layout attributes and Web reuses + `renderElement`, so both inherit row styling for free; Android will need the + style applied in two places. +- **Android silently drops stickiness for complex headers.** + `isSimpleStickySectionHeader()` requires no `value`, no `checkbox`, and + `variant !== 'summary'`. A section header carrying a total or a select-all box + does not stick on Android, but does on iOS and Web. Needs a product decision. +- **Source scale is list-wide on Android.** + `usesSelectorSourceScale` is computed with `items.any { … }`, so one selector row + switches the metric system (the sub-400dp 0.9 factor) for the entire list. + `NativeListTableColumnView`, the market skeleton, and the section index never + follow it. +- **`accent` is not an accent.** The consumer maps the application's `iconActive` + onto the theme key `accent`. The name should be retired in favour of an alias. + +### 6.4 Load-bearing oddities (do not "clean up") + +- `.ok-native-list-account-action-row .ok-native-list-visual{border-radius:8px!important}` + overrides an **inline** radius written by `createVisual`. Removing `!important` + regresses the account action row to 10px. +- `.ok-native-list-market-change{color:#fff;background:#8d8d8d}` are literals, not + tokens, because `--nl-inverse-text` defaults to `#fcfcfc` and `--nl-secondary` to + `#6b7280`. Swapping them changes untinted rendering. + +## 7. Isolation rules + +The view pool is shared across templates, so a styled row must not be able to +affect any other row. Four rules: + +1. **Style types are per template.** `IdentityRowStyle` carries only identity's + fields; `MetricCardRowStyle` only metricCard's. A key that the row type does not + declare fails `validateSnapshot` instead of silently hitting a shared view. +2. **Every property gets an explicit default.** Android's `resetViews()` restores + visibility, gravity, maxLines, layout params, background, and padding — but + **not** `textSize`, `typeface`, or `lineHeight`. A style pass that writes those + only when a style is present will leak a font size into the next row that reuses + the view. The pass must write the template default when the style omits a value. +3. **No list-wide side effects.** Any style-dependent decision is made per row. + `usesSelectorSourceScale` (§6.3) is the counter-example to avoid. +4. **Bounded and fail-soft.** Bounds are validated in JavaScript; native readers use + `opt*(key, default)` and fall back to the template default. Bad data degrades to + "this row is unstyled", never to a crash or to a neighbouring row changing. + +## 8. Where style is applied + +Each renderer already runs a pass after the per-template binder. The style pass +belongs there, so none of the 13 binders change. + +| Platform | Anchor | Note | +| --- | --- | --- | +| iOS | `NativeListCell.bind()`, after the `switch item.type` | Also covers box metrics: the four root constraints, stack spacings, leading size | +| Android | `NativeListRowView.bind()`, **after** `applySize(item)` | `applySize` re-dispatches font size and typeface by row type and would otherwise overwrite the style | +| Web | `renderElement()`, after `createRowBody()` | The selector inline overrides already live here | + +The existing market helpers generalize rather than being rewritten: +`applyMarketTextStyle` / `applyMarketButtonStyle` / `marketAttributedText` (iOS), +`applyMarketTextStyle` / `applyMarketTextMetrics` (Android), +`applyMarketTextStyle` / `resolveWebMarketLayoutStyle` (Web). + +## 9. Review checklist + +For any PR that touches list typography or geometry: + +- [ ] Does the change introduce a new bare font size or radius? If so, which token + in §3 should it be, or does §3 need a new step? +- [ ] Is the value applied identically on all three platforms? If not, is the + divergence recorded in §6? +- [ ] Does a new style key modify a **model field** name (§4), not a view name? +- [ ] Does the style pass write an explicit default for every property it can set + (§7 rule 2)? +- [ ] Is any decision derived from the whole row list rather than one row + (§7 rule 3)? +- [ ] For a section header: does the Android sticky decoration need the same change + (§6.3)? diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index dd0a0940e..f164a17e2 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -1007,18 +1007,13 @@ export const WEB_LIST_CSS = ` .ok-native-list-data{padding:6px 12px}.ok-native-list-index{flex:0 0 28px;color:var(--nl-secondary);font-size:13px}.ok-native-list-favorite{flex:0 0 24px;color:var(--nl-icon-subdued);font-size:22px}.ok-native-list-favorite[data-active="true"]{color:var(--nl-accent)}.ok-native-list-data-cell{display:flex;flex-direction:column;min-width:0}.ok-native-list-data-cell[data-align="center"]{align-items:center}.ok-native-list-data-cell[data-align="end"]{align-items:flex-end}.ok-native-list-data-primary{display:flex;align-items:center;gap:5px;max-width:100%;font-size:16px;font-weight:500;white-space:nowrap}.ok-native-list-unread{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--nl-accent)}.ok-native-list-thumbnail{width:64px;height:64px;border-radius:10px;object-fit:cover} .ok-native-list-market{padding:12px 20px;gap:14px}.ok-native-list-market>.ok-native-list-visual{width:32px;height:32px;flex-basis:32px}.ok-native-list-market[data-variant="stock"]>.ok-native-list-visual{width:40px;height:40px;flex-basis:40px}.ok-native-list-market>.ok-native-list-visual>.ok-native-list-visual-main{width:100%;height:100%}.ok-native-list-market-main{display:flex;flex:1;min-width:0;flex-direction:column;justify-content:center}.ok-native-list-market-title-line{display:flex;align-items:center;min-width:0;gap:4px}.ok-native-list-market-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--nl-primary);font-size:16px;line-height:24px;font-weight:500}.ok-native-list-market-subtitle{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--nl-secondary);font-size:14px;line-height:20px;font-weight:400}.ok-native-list-market-badge{display:inline-flex;flex:0 0 auto;align-items:center;justify-content:center;box-sizing:border-box;max-width:72px;height:18px;padding:0 5px;border:0;border-radius:4px;background:var(--nl-strong);color:var(--nl-secondary);font:500 11px/18px inherit;overflow:hidden;white-space:nowrap}.ok-native-list-market-badge img{width:14px;height:14px;object-fit:contain}.ok-native-list-market-trailing{display:flex;flex:0 0 auto;align-items:center;gap:8px}.ok-native-list-market-price{max-width:112px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right;color:var(--nl-primary);font-size:16px;line-height:24px;font-weight:500;font-variant-numeric:tabular-nums}.ok-native-list-market-change{display:flex;align-items:center;justify-content:center;box-sizing:border-box;width:80px;height:32px;border-radius:8px;color:#fff;background:#8d8d8d;font-size:14px;line-height:20px;font-weight:500;font-variant-numeric:tabular-nums;white-space:nowrap}.ok-native-list-market-change[data-tone="positive"]{background:var(--nl-positive)}.ok-native-list-market-change[data-tone="negative"]{background:var(--nl-negative)} .ok-native-list-market-badge>svg,.ok-native-list-market-badge>.ok-native-list-market-badge-icon>svg{display:block;width:16px;height:16px;flex:0 0 16px}.ok-native-list-system[data-native-list-presentation="market"]{box-sizing:border-box;padding:12px 20px}.ok-native-list-system[data-native-list-presentation="market"]>.ok-native-list-spinner{width:32px;height:32px} -.ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:${SECTION_INDEX_RAIL_WIDTH}px;touch-action:none;cursor:pointer}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;position:absolute;left:6px;display:flex;width:20px;height:16px;align-items:center;justify-content:center;padding:0;transform:translateY(-50%);border:0;border-radius:8px;background:transparent;color:var(--nl-secondary);font:600 10px/1 inherit;cursor:pointer}.ok-native-list-index-button[data-active="true"]{background:var(--nl-accent);color:var(--nl-inverse-text)}.ok-native-list-index-button:focus-visible{outline:2px solid var(--nl-accent);outline-offset:1px}.ok-native-list-index-preview{position:absolute;z-index:8;right:40px;top:50%;display:flex;width:48px;height:48px;align-items:center;justify-content:center;transform:translateY(-50%) scale(.92);border-radius:14px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:22px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .15s ease,transform .15s ease}.ok-native-list-index-preview[data-visible="true"]{opacity:1;transform:translateY(-50%) scale(1)} -.ok-native-list-refresh{position:absolute;z-index:7;left:50%;top:8px;display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:12px;opacity:0;transform:translate(-50%,-16px);transition:opacity .15s ease,transform .15s ease;pointer-events:none}.ok-native-list-refresh[data-visible="true"]{opacity:1;transform:translate(-50%,0)} -.ok-native-list-warning{height:auto;display:flex;flex-direction:column;align-items:stretch;gap:4px;padding:14px 12px;border-top:1px solid;border-bottom:1px solid;box-sizing:border-box;cursor:default}.ok-native-list-warning-title,.ok-native-list-warning-message{font-size:14px;line-height:20px;white-space:normal;overflow-wrap:anywhere}.ok-native-list-warning-title{font-weight:500;color:var(--nl-primary)}.ok-native-list-warning-message{font-weight:400;color:var(--nl-secondary)} -.ok-native-list-subtitle-segments{display:flex;align-items:center;min-width:0;max-width:100%;height:20px}.ok-native-list-subtitle-segments>.ok-native-list-secondary{flex:0 1 auto;min-width:0}.ok-native-list-subtitle-dot{flex:0 0 4px;width:4px;height:4px;margin:0 6px;border-radius:50%;background:var(--nl-disabled)}.ok-native-list-wallet-row>.ok-native-list-flex{flex:0 1 auto;width:100%;align-items:center}.ok-native-list-wallet-badges{display:flex;gap:4px;justify-content:center;margin-top:4px;height:20px;max-width:100%}.ok-native-list-wallet-badges>.ok-native-list-badge{background:var(--nl-strong);color:var(--nl-secondary);font-size:12px;line-height:16px;height:20px;box-sizing:border-box;padding:2px 4px}.ok-native-list-visual-overlay{position:absolute;display:flex;align-items:center;justify-content:center;box-sizing:border-box;border-radius:50%;overflow:hidden;line-height:1;font-size:10px}.ok-native-list-visual-overlay img,.ok-native-list-visual-overlay svg{width:100%;height:100%;object-fit:contain} +.ok-native-list-index-preview{position:absolute;z-index:8;right:40px;top:50%;display:flex;width:48px;height:48px;align-items:center;justify-content:center;transform:translateY(-50%) scale(.92);border-radius:14px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:22px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .15s ease,transform .15s ease}.ok-native-list-index-preview[data-visible="true"]{opacity:1;transform:translateY(-50%) scale(1)} .ok-native-list-row.ok-native-list-market-skeleton{padding:12px 20px;gap:0}.ok-native-list-skeleton-left{display:flex;align-items:center;gap:12px;flex:1}.ok-native-list-skeleton-text{display:flex;flex-direction:column;gap:4px}.ok-native-list-skeleton-right{display:flex;align-items:center;gap:8px}.ok-native-list-skeleton-mark{display:block;flex-shrink:0;border-radius:8px;animation:ok-native-list-skeleton 1.5s linear infinite alternate}@keyframes ok-native-list-skeleton{from{background-color:var(--nl-skeleton-base)}to{background-color:var(--nl-skeleton-highlight)}}.ok-native-list-market-spinner{display:block;width:20px;height:20px;flex-shrink:0;color:var(--nl-icon);animation:ok-native-list-spin .75s linear infinite} @media (prefers-reduced-motion:reduce){.ok-native-list-index-preview,.ok-native-list-refresh{transition:none}.ok-native-list-spinner,.ok-native-list-market-spinner{animation:none}.ok-native-list-skeleton-mark{animation:none;background:var(--nl-skeleton-base)}} .ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-viewport-frame:has(>.ok-native-list-index-rail:not([hidden]))>.ok-native-list-viewport,.ok-native-list-viewport-frame[data-section-index-visible="true"]>.ok-native-list-viewport{scrollbar-width:none}.ok-native-list-viewport-frame:has(>.ok-native-list-index-rail:not([hidden]))>.ok-native-list-viewport::-webkit-scrollbar,.ok-native-list-viewport-frame[data-section-index-visible="true"]>.ok-native-list-viewport::-webkit-scrollbar{display:none}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:${SECTION_INDEX_RAIL_WIDTH}px;touch-action:none;cursor:pointer;font-family:Roobert,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;position:absolute;left:15px;display:flex;width:14px;height:14px;align-items:center;justify-content:center;padding:0;transform:translateY(-50%);border:0;border-radius:7px;background:transparent;color:var(--nl-disabled);font-family:inherit;font-size:10px;font-weight:400;line-height:1;cursor:pointer;opacity:.5;transition:opacity .15s ease}.ok-native-list-index-rail:hover .ok-native-list-index-button,.ok-native-list-index-rail:focus-within .ok-native-list-index-button,.ok-native-list-index-button[data-active="true"]{opacity:1}.ok-native-list-index-button[data-active="true"]{background:var(--nl-positive);color:var(--nl-inverse-text);font-weight:500}.ok-native-list-index-button:focus-visible{outline:2px solid var(--nl-positive);outline-offset:1px} .ok-native-list-refresh{position:absolute;z-index:7;left:50%;top:8px;display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:12px;opacity:0;transform:translate(-50%,-16px);transition:opacity .15s ease,transform .15s ease;pointer-events:none}.ok-native-list-refresh[data-visible="true"]{opacity:1;transform:translate(-50%,0)} .ok-native-list-warning{height:auto;display:flex;flex-direction:column;align-items:stretch;gap:4px;padding:14px 12px;border-top:1px solid;border-bottom:1px solid;box-sizing:border-box;cursor:default}.ok-native-list-warning-title,.ok-native-list-warning-message{font-size:14px;line-height:20px;white-space:normal;overflow-wrap:anywhere}.ok-native-list-warning-title{font-weight:500;color:var(--nl-primary)}.ok-native-list-warning-message{font-weight:400;color:var(--nl-secondary)} -.ok-native-list-subtitle-segments{display:flex;align-items:center;min-width:0;max-width:100%;height:20px}.ok-native-list-subtitle-segments>.ok-native-list-secondary{flex:0 1 auto;min-width:0}.ok-native-list-subtitle-dot{flex:0 0 4px;width:4px;height:4px;margin:0 6px;border-radius:50%;background:var(--nl-disabled)}.ok-native-list-wallet-row>.ok-native-list-flex{flex:0 1 auto;width:100%;align-items:center}.ok-native-list-wallet-badges{display:flex;gap:4px;justify-content:center;margin-top:4px;height:20px;max-width:100%}.ok-native-list-wallet-badges>.ok-native-list-badge{background:var(--nl-strong);color:var(--nl-secondary);font-size:12px;line-height:16px;height:20px;box-sizing:border-box;padding:2px 4px}.ok-native-list-visual-overlay{position:absolute;display:flex;align-items:center;justify-content:center;box-sizing:border-box;border-radius:50%;overflow:hidden;line-height:1;font-size:10px}.ok-native-list-visual-overlay img,.ok-native-list-visual-overlay svg{width:100%;height:100%;object-fit:contain} -@media (prefers-reduced-motion:reduce){.ok-native-list-refresh{transition:none}.ok-native-list-spinner{animation:none}} -/* OneKey patch: selector controls follow their original semantic colors and geometry. */ +.ok-native-list-subtitle-segments{display:flex;align-items:center;min-width:0;max-width:100%;height:20px}.ok-native-list-subtitle-segments>.ok-native-list-secondary{flex:0 1 auto;min-width:0}.ok-native-list-subtitle-dot{flex:0 0 4px;width:4px;height:4px;margin:0 6px;border-radius:50%;background:var(--nl-disabled)}.ok-native-list-wallet-row>.ok-native-list-flex{flex:0 1 auto;width:100%;align-items:center}.ok-native-list-wallet-badges{display:flex;gap:4px;justify-content:center;margin-top:4px;height:20px;max-width:100%}.ok-native-list-wallet-badges>.ok-native-list-badge{background:var(--nl-strong);color:var(--nl-secondary);font-size:12px;line-height:16px;height:20px;box-sizing:border-box;padding:2px 4px}.ok-native-list-visual-overlay{position:absolute;display:flex;align-items:center;justify-content:center;box-sizing:border-box;border-radius:50%;overflow:hidden;line-height:1;font-size:10px}.ok-native-list-visual-overlay img,.ok-native-list-visual-overlay svg{width:100%;height:100%;object-fit:contain}/* OneKey patch: selector controls follow their original semantic colors and geometry. */ .ok-native-list-checkbox[data-selector="networkSelector"]{padding:0;border-radius:4px;border-color:var(--nl-checkbox-border,var(--nl-separator));background:var(--nl-checkbox-icon,var(--nl-inverse-text))} .ok-native-list-checkbox[data-selector="networkSelector"]::after{display:none} .ok-native-list-checkbox[data-selector="networkSelector"]>svg{display:none;width:16px;height:16px;color:var(--nl-checkbox-icon,var(--nl-inverse-text));flex-shrink:0} From fc23d4a430131e84308b29d6146e8ab7761d6e1e Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 16 Sep 2026 09:23:37 +0800 Subject: [PATCH 02/71] feat(native-list): add the per-template row style and apply it on Web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every template except market hard-codes its typography in three renderers, so tuning one meant adding a presentation variant plus a branch per platform. Give each template a bounded style instead. A style key names the model field it modifies, never the view that carries it. The view pool is shared and the mapping is not one to one: metricCard renders its value through the label identity uses for its title, and one status view carries rail.status, activity.status, message.time and metricCard.trend. So metricCard.style.value reaches the large number, and identity.style.title the title, through the same physical view. Typography can be a named step from the application's scale ({ token: '$bodyLg' }). Tokens resolve to numbers in validation.ts before the snapshot is serialized, so no native renderer learns the vocabulary and none can drift from it; an explicit fontSize alongside a token still wins. Resolution is idempotent and a snapshot with nothing to resolve is returned unchanged, so the common path keeps object identity. Isolation follows docs/STYLE_SPEC.md §7: style types are per template, and validateSnapshot and validatePatches reject a key the row type does not declare, so a style written for one template cannot reach a shared view through another. Styles carried by a patch are validated and resolved the same way, because patches reach the native side without passing through a snapshot. Web applies it in renderElement after createRowBody. The element rendering a field carries data-nl-slot, because .ok-native-list-secondary alone is the identity subtitle, the rail status, a metric label, a data column's secondary text and a system message. Text slots work for every template, along with horizontalPadding, verticalPadding and lineGap. leadingGap, trailingGap, titleBadgeGap and image stay validated but applied only on market: the others need a per-template default gap that cannot be read back from the DOM. Row heights are untouched. A styled row that grows still needs an explicit height, as recorded in docs/STYLE_SPEC.md §6.1. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 + .../docs/STYLE_SPEC.md | 27 +- .../src/__tests__/NativeList.web.test.ts | 83 ++++ .../src/__tests__/validation.test.ts | 90 ++++ .../react-native-native-list/src/models.ts | 160 ++++++- .../src/validation.ts | 322 ++++++++++++-- .../src/web/NativeListWebEngine.ts | 407 ++++++++++++------ 7 files changed, 914 insertions(+), 178 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e0cdab01..2cf0f332d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Features +- **native-list**: Add a bounded per-template row style. `style` keys name the model field they modify rather than the view that carries it, so `metricCard.style.value` reaches the large number even though it shares a view with `identity.style.title`. Typography can be given as a named step (`{ token: '$bodyLg' }`) which is resolved to numbers in JavaScript before serialization, so no native renderer learns the vocabulary; explicit values still override. Keys a template does not declare are rejected by `validateSnapshot` and `validatePatches`, and styles carried by a patch are resolved the same way. Implemented on Web: text slots for every template plus `horizontalPadding`, `verticalPadding`, and `lineGap`. Row heights are unchanged — a styled row that grows still needs an explicit `height`. + ### Documentation - **native-list**: Add `docs/STYLE_SPEC.md`, the shared style vocabulary for rows, section headers, fixed footers, and empty states. It records the design tokens (aliased to the application's own token names), the per-template style surface keyed by model field, list chrome, the template isolation rules, and a review checklist. Cross-platform divergences — row-height tables, typography, the Android sticky-header renderer, list-wide source scale — are registered rather than changed. diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index 935012e03..5da23b0f1 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -41,9 +41,22 @@ The three renderers cannot constrain each other, but they all read the same | Known divergences | **This document + PR review** | §6 | A style token is resolved on the JavaScript side. `{ token: '$bodyLg' }` becomes -`{ fontSize: 16, lineHeight: 24, fontWeight: 'medium' }` before it crosses the -bridge. Native renderers never learn the token vocabulary and therefore cannot -drift from it. Raw numeric overrides stay available for pixel-parity work. +`{ fontSize: 16, lineHeight: 24, fontWeight: 'regular' }` before it crosses the +bridge — and an explicit `fontSize` alongside the token wins. Native renderers +never learn the token vocabulary and therefore cannot drift from it. Raw numeric +overrides stay available for pixel-parity work. Resolution is idempotent, and a +snapshot with nothing to resolve is returned unchanged, so the common path keeps +object identity. + +### Status + +| Layer | State | +| --- | --- | +| Contract, validation, token resolution, patch support | Implemented | +| Web — text slots for every template, `horizontalPadding`, `verticalPadding`, `lineGap` | Implemented | +| Web — `leadingGap`, `trailingGap`, `titleBadgeGap`, `image` | Validated but applied only on `market`; the others need a per-template default gap that cannot be read back from the DOM | +| iOS, Android | Not started | +| Row heights | Unchanged. A styled row that grows still needs an explicit `row.height` (§6.1) | ## 3. T1 — Design tokens @@ -119,6 +132,12 @@ every platform `metricCard` renders its *value* through the title label and its views would therefore mis-target. `market` already follows this rule with `style.price` / `style.change`. +On Web the element rendering a field carries `data-nl-slot=""`, so the +style pass resolves a slot by name rather than by CSS class — on its own, +`.ok-native-list-secondary` is the identity subtitle, the rail status, a metric +label, a data column's secondary text, and a system message. iOS and Android will +need the same field-to-view mapping expressed in native code. + Legend: **=** all three platforms agree; **≠** registered divergence, see §6. ### identity @@ -412,7 +431,7 @@ belongs there, so none of the 13 binders change. | --- | --- | --- | | iOS | `NativeListCell.bind()`, after the `switch item.type` | Also covers box metrics: the four root constraints, stack spacings, leading size | | Android | `NativeListRowView.bind()`, **after** `applySize(item)` | `applySize` re-dispatches font size and typeface by row type and would otherwise overwrite the style | -| Web | `renderElement()`, after `createRowBody()` | The selector inline overrides already live here | +| Web | `renderElement()`, after `createRowBody()` — **implemented** as `applyRowStyle()` | The selector inline overrides already live here | The existing market helpers generalize rather than being rewritten: `applyMarketTextStyle` / `applyMarketButtonStyle` / `marketAttributedText` (iOS), diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index cab49c03c..df0bc0199 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -2,9 +2,11 @@ import type { IdentityRow, NativeListSnapshot, RowModel } from '../models'; import { WEB_LIST_CSS, WEB_REORDER_ANIMATION, + applyRowStyle, canStartWebWalletGroupReorder, cancelWebReorderRows, computeWebListLayout, + createRowBody, estimateWebRowHeight, hasExceededWebReorderMouseThreshold, isWebRowReorderable, @@ -561,3 +563,84 @@ describe('NativeList pure DOM web layout', () => { } }); }); + +describe('web row style', () => { + // The workspace ships jsdom without its type package; type the one entry used. + const { JSDOM } = require('jsdom') as { + JSDOM: new (html: string) => { window: { document: Document } }; + }; + + const render = (styledRow: RowModel): HTMLElement => { + const { document } = new JSDOM('').window; + const body = createRowBody( + { + document, + snapshot: { + schemaVersion: 1, + generation: 1, + layout: { kind: 'linear' }, + rows: [styledRow], + }, + selectedKeys: new Set(), + itemIndex: 0, + }, + styledRow + ); + applyRowStyle(body, styledRow); + return body; + }; + + it('tags each slot with the model field it renders', () => { + const body = render({ + type: 'message', + key: 'notification', + title: 'Title', + body: 'Body', + time: '1m', + }); + expect(body.querySelector('[data-nl-slot="title"]')?.textContent).toBe( + 'Title' + ); + expect(body.querySelector('[data-nl-slot="body"]')?.textContent).toBe( + 'Body' + ); + expect(body.querySelector('[data-nl-slot="time"]')?.textContent).toBe('1m'); + }); + + it('styles the named model field, not the view that carries it', () => { + // metricCard renders `value` through the view identity uses for `title`. + const body = render({ + type: 'metricCard', + key: 'kpi', + title: 'Volume', + value: '42', + style: { + title: { fontSize: 11 }, + value: { fontSize: 22, fontWeight: 'bold' }, + }, + }); + const label = body.querySelector('[data-nl-slot="title"]'); + const value = body.querySelector('[data-nl-slot="value"]'); + expect(label?.textContent).toBe('Volume'); + expect(label?.style.fontSize).toBe('11px'); + expect(value?.textContent).toBe('42'); + expect(value?.style.fontSize).toBe('22px'); + expect(value?.style.fontWeight).toBe('700'); + }); + + it('applies box padding only when the row asks for it', () => { + const base: RowModel = { + type: 'identity', + key: 'btc', + leading: { kind: 'icon', name: 'coin' }, + title: 'Bitcoin', + }; + expect(render(base).style.paddingInline).toBe(''); + const styled = render({ + ...base, + style: { horizontalPadding: 16, verticalPadding: 10 }, + } as RowModel); + expect(styled.style.paddingInline).toBe('16px'); + expect(styled.style.paddingBlock).toBe('10px'); + }); +}); diff --git a/native-views/react-native-native-list/src/__tests__/validation.test.ts b/native-views/react-native-native-list/src/__tests__/validation.test.ts index 90d7b2297..8aa3974ac 100644 --- a/native-views/react-native-native-list/src/__tests__/validation.test.ts +++ b/native-views/react-native-native-list/src/__tests__/validation.test.ts @@ -1229,3 +1229,93 @@ describe('NativeList patches', () => { ).toThrow('bodyLines'); }); }); + +describe('NativeList style contract', () => { + const styled = (style: unknown): NativeListSnapshot => + snapshot([{ ...row('btc'), style } as RowModel]); + + it('resolves a typography token to numbers before serialization', () => { + const [first] = validateSnapshot( + styled({ title: { token: '$bodyLg' }, horizontalPadding: 16 }) + ).rows; + expect((first as IdentityRow).style).toEqual({ + title: { fontSize: 16, lineHeight: 24, fontWeight: 'regular' }, + horizontalPadding: 16, + }); + expect( + serializeSnapshot(styled({ title: { token: '$bodyLg' } })) + ).not.toContain('$bodyLg'); + }); + + it('lets an explicit value override the token it resolves', () => { + const [first] = validateSnapshot( + styled({ title: { token: '$bodyLg', fontSize: 15 } }) + ).rows; + expect((first as IdentityRow).style?.title).toEqual({ + fontSize: 15, + lineHeight: 24, + fontWeight: 'regular', + }); + }); + + it('keeps snapshot identity when nothing needs resolving', () => { + const input = snapshot(); + expect(validateSnapshot(input)).toBe(input); + }); + + it('rejects a style key the template does not declare', () => { + expect(() => validateSnapshot(styled({ price: { fontSize: 12 } }))).toThrow( + 'is not a style key of the "identity" template' + ); + }); + + it('rejects an unknown token and out-of-range metrics', () => { + expect(() => + validateSnapshot(styled({ title: { token: '$displayXl' } })) + ).toThrow('token'); + expect(() => validateSnapshot(styled({ title: { fontSize: 72 } }))).toThrow( + 'fontSize' + ); + expect(() => validateSnapshot(styled({ lineGap: 17 }))).toThrow('lineGap'); + }); + + it('validates and resolves a style carried by a patch', () => { + const patches = validatePatches([ + { + type: 'identity', + key: 'btc', + changes: { style: { subtitle: { token: '$bodySm' } } }, + } as unknown as RowPatch, + ]); + expect( + (patches[0] as unknown as { changes: { style: { subtitle: unknown } } }) + .changes.style.subtitle + ).toEqual({ fontSize: 12, lineHeight: 16, fontWeight: 'regular' }); + expect(() => + validatePatches([ + { + type: 'identity', + key: 'btc', + changes: { style: { change: {} } }, + } as unknown as RowPatch, + ]) + ).toThrow('is not a style key of the "identity" template'); + }); + + it('keeps the Market style surface intact', () => { + const [first] = validateSnapshot( + snapshot([ + { + ...marketRow(), + style: { title: { token: '$headingSm' }, changeWidth: 80 }, + } as MarketRow, + ]) + ).rows; + expect((first as MarketRow).style?.title).toEqual({ + fontSize: 16, + lineHeight: 24, + fontWeight: 'medium', + }); + expect((first as MarketRow).style?.changeWidth).toBe(80); + }); +}); diff --git a/native-views/react-native-native-list/src/models.ts b/native-views/react-native-native-list/src/models.ts index a095682ed..bfa396db2 100644 --- a/native-views/react-native-native-list/src/models.ts +++ b/native-views/react-native-native-list/src/models.ts @@ -34,7 +34,25 @@ export type BadgeModel = Readonly<{ tone?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; }>; -export type MarketTextStyle = Readonly<{ +/** + * Named typography steps, shared with the application's design scale. Resolved + * to fontSize/lineHeight/fontWeight in JavaScript before the snapshot is + * serialized, so no native renderer needs to know the vocabulary. + * See docs/STYLE_SPEC.md §3.2. + */ +export type NativeListTypographyToken = + | '$headingXl' + | '$headingLg' + | '$headingMd' + | '$headingSm' + | '$headingXs' + | '$bodyLg' + | '$bodyMd' + | '$bodySm' + | '$bodyXs'; + +export type NativeListTextStyle = Readonly<{ + token?: NativeListTypographyToken; fontSize?: number; fontWeight?: 'regular' | 'medium' | 'semibold' | 'bold'; color?: string; @@ -43,7 +61,10 @@ export type MarketTextStyle = Readonly<{ alignment?: 'start' | 'center' | 'end'; }>; -export type MarketImageStyle = Readonly<{ +/** Retained alias: Market shipped this name before the style surface was shared. */ +export type MarketTextStyle = NativeListTextStyle; + +export type NativeListImageStyle = Readonly<{ width?: number; height?: number; shape?: 'circle' | 'rounded' | 'square'; @@ -51,29 +72,122 @@ export type MarketImageStyle = Readonly<{ contentFit?: ImageContentFit; }>; -export type MarketRowStyle = Readonly<{ +export type MarketImageStyle = NativeListImageStyle; + +/** Box metrics every template shares. Bounds live in docs/STYLE_SPEC.md §3.3. */ +export type RowBoxStyle = Readonly<{ horizontalPadding?: number; verticalPadding?: number; leadingGap?: number; /** Space between title and subtitle; 0 by default, bounded to 0..16. */ lineGap?: number; titleBadgeGap?: number; - /** OneKey patch: keep badges next to the intrinsic title width. */ - titleBadgeLayout?: 'inline'; trailingGap?: number; - /** OneKey patch: preserve separate Market content and subtitle insets. */ - contentTrailingGap?: number; - subtitleTrailingPadding?: number; - image?: MarketImageStyle; - title?: MarketTextStyle; - subtitle?: MarketTextStyle; - price?: MarketTextStyle; - change?: MarketTextStyle; - changeWidth?: number; - changeHeight?: number; - changeCornerRadius?: number; + image?: NativeListImageStyle; }>; +/** + * A style key names the model field it modifies, never the view that carries + * it. The view pool is shared and the mapping is not one to one: metricCard + * renders `value` through the same label identity uses for `title`, and the + * status view carries rail.status, activity.status, message.time and + * metricCard.trend. See docs/STYLE_SPEC.md §4. + */ +export type IdentityRowStyle = RowBoxStyle & + Readonly<{ + title?: NativeListTextStyle; + subtitle?: NativeListTextStyle; + tertiary?: NativeListTextStyle; + badge?: NativeListTextStyle; + value?: NativeListTextStyle; + valueSecondary?: NativeListTextStyle; + }>; + +export type WalletGroupRowStyle = RowBoxStyle; + +export type RailRowStyle = RowBoxStyle & + Readonly<{ + title?: NativeListTextStyle; + badge?: NativeListTextStyle; + status?: NativeListTextStyle; + }>; + +export type ActivityRowStyle = RowBoxStyle & + Readonly<{ + title?: NativeListTextStyle; + description?: NativeListTextStyle; + status?: NativeListTextStyle; + primaryAmount?: NativeListTextStyle; + secondaryAmount?: NativeListTextStyle; + }>; + +export type MessageRowStyle = RowBoxStyle & + Readonly<{ + title?: NativeListTextStyle; + body?: NativeListTextStyle; + time?: NativeListTextStyle; + }>; + +export type DataRowStyle = RowBoxStyle & + Readonly<{ + columns?: NativeListTextStyle; + columnSecondary?: NativeListTextStyle; + index?: NativeListTextStyle; + }>; + +export type MarketRowStyle = RowBoxStyle & + Readonly<{ + /** OneKey patch: keep badges next to the intrinsic title width. */ + titleBadgeLayout?: 'inline'; + /** OneKey patch: preserve separate Market content and subtitle insets. */ + contentTrailingGap?: number; + subtitleTrailingPadding?: number; + title?: NativeListTextStyle; + subtitle?: NativeListTextStyle; + price?: NativeListTextStyle; + change?: NativeListTextStyle; + changeWidth?: number; + changeHeight?: number; + changeCornerRadius?: number; + }>; + +export type MediaTileRowStyle = RowBoxStyle & + Readonly<{ + title?: NativeListTextStyle; + subtitle?: NativeListTextStyle; + badge?: NativeListTextStyle; + }>; + +export type MetricCardRowStyle = RowBoxStyle & + Readonly<{ + /** The small label. Carried by the subtitle view on every platform. */ + title?: NativeListTextStyle; + /** The large number. Carried by the title view on every platform. */ + value?: NativeListTextStyle; + subtitle?: NativeListTextStyle; + trend?: NativeListTextStyle; + }>; + +export type SectionHeaderRowStyle = RowBoxStyle & + Readonly<{ + title?: NativeListTextStyle; + subtitle?: NativeListTextStyle; + value?: NativeListTextStyle; + }>; + +export type ActionRowStyle = RowBoxStyle & + Readonly<{ + title?: NativeListTextStyle; + value?: NativeListTextStyle; + }>; + +export type SystemRowStyle = RowBoxStyle & + Readonly<{ + title?: NativeListTextStyle; + message?: NativeListTextStyle; + actionText?: NativeListTextStyle; + }>; + export type MarketBadgeModel = Readonly<{ key: string; text?: string; @@ -273,6 +387,7 @@ export type IdentityRow = RowBase & badges?: readonly BadgeModel[]; trailing?: readonly TrailingAccessory[]; draggable?: boolean; + style?: IdentityRowStyle; }>; export type WalletGroupRow = RowBase & @@ -281,6 +396,7 @@ export type WalletGroupRow = RowBase & parent: IdentityRow; children: readonly IdentityRow[]; draggable?: boolean; + style?: WalletGroupRowStyle; }>; export type RailRow = RowBase & @@ -291,6 +407,7 @@ export type RailRow = RowBase & status?: 'none' | 'online' | 'warning' | 'error'; badge?: BadgeModel; draggable?: boolean; + style?: RailRowStyle; }>; export type ActivityRow = RowBase & @@ -304,6 +421,7 @@ export type ActivityRow = RowBase & primaryAmount?: string; secondaryAmount?: string; footerActions?: readonly FooterAction[]; + style?: ActivityRowStyle; }>; export type MessageRow = RowBase & @@ -316,6 +434,7 @@ export type MessageRow = RowBase & bodyLines?: 1 | 2 | 3; time: string; thumbnail?: ImageSource; + style?: MessageRowStyle; }>; export type DataColumn = Readonly<{ @@ -340,6 +459,7 @@ export type DataRow = RowBase & badges?: readonly BadgeModel[]; favorite?: boolean; favoriteActive?: boolean; + style?: DataRowStyle; }>; /** Native Market quote row. All values are already localized/formatted strings. */ @@ -381,6 +501,7 @@ export type MediaTileRow = RowBase & subtitle?: string; badge?: BadgeModel; closeActionKey?: string; + style?: MediaTileRowStyle; }>; /** Compact KPI card for dashboards and grids. */ @@ -405,6 +526,7 @@ export type MetricCardRow = RowBase & badge?: BadgeModel; metrics?: readonly MetricValue[]; progress?: number; + style?: MetricCardRowStyle; }>; export type SectionHeaderRow = RowBase & @@ -427,6 +549,7 @@ export type SectionHeaderRow = RowBase & titleIcon?: Extract; valueIcon?: Extract; checkbox?: Extract; + style?: SectionHeaderRowStyle; }>; export type ActionRow = RowBase & @@ -439,9 +562,11 @@ export type ActionRow = RowBase & icon?: Extract; checkbox?: Extract; trailing?: readonly TrailingAccessory[]; + style?: ActionRowStyle; }>; export type SystemRow = RowBase & + Readonly<{ style?: SystemRowStyle }> & ( | Readonly<{ type: 'system'; @@ -575,7 +700,8 @@ type CommonPatchFields = | 'heightRounding' | 'opacity' | 'pressDisabled' - | 'accessibilityLabel'; + | 'accessibilityLabel' + | 'style'; export type RowPatch = | Readonly<{ diff --git a/native-views/react-native-native-list/src/validation.ts b/native-views/react-native-native-list/src/validation.ts index bd988bd37..214ae76c1 100644 --- a/native-views/react-native-native-list/src/validation.ts +++ b/native-views/react-native-native-list/src/validation.ts @@ -5,8 +5,10 @@ import type { LeadingVisual, MarketRow, MarketRowStyle, - MarketTextStyle, NativeListSnapshot, + NativeListTextStyle, + NativeListTypographyToken, + RowBoxStyle, RowModel, RowPatch, WalletGroupRow, @@ -127,11 +129,100 @@ function assertBoundedStyleNumber( } } -function assertMarketTextStyle( - style: MarketTextStyle | undefined, +/** + * The named typography scale from docs/STYLE_SPEC.md §3.2. Tokens are resolved + * to numbers here, before serialization, so the iOS, Android, and Web renderers + * never learn the vocabulary and cannot drift from it. + */ +const TYPOGRAPHY_TOKENS: Readonly< + Record< + NativeListTypographyToken, + Readonly<{ + fontSize: number; + lineHeight: number; + fontWeight: NonNullable; + }> + > +> = { + $headingXl: { fontSize: 24, lineHeight: 32, fontWeight: 'semibold' }, + $headingLg: { fontSize: 20, lineHeight: 28, fontWeight: 'semibold' }, + $headingMd: { fontSize: 18, lineHeight: 24, fontWeight: 'semibold' }, + $headingSm: { fontSize: 16, lineHeight: 24, fontWeight: 'medium' }, + $headingXs: { fontSize: 14, lineHeight: 20, fontWeight: 'semibold' }, + $bodyLg: { fontSize: 16, lineHeight: 24, fontWeight: 'regular' }, + $bodyMd: { fontSize: 14, lineHeight: 20, fontWeight: 'regular' }, + $bodySm: { fontSize: 12, lineHeight: 16, fontWeight: 'regular' }, + $bodyXs: { fontSize: 11, lineHeight: 16, fontWeight: 'regular' }, +}; + +const BOX_STYLE_KEYS: readonly string[] = [ + 'horizontalPadding', + 'verticalPadding', + 'leadingGap', + 'lineGap', + 'titleBadgeGap', + 'trailingGap', + 'image', +]; + +/** Style keys name model fields, never views. See docs/STYLE_SPEC.md §4. */ +const TEXT_STYLE_KEYS_BY_ROW_TYPE: Readonly< + Record +> = { + identity: [ + 'title', + 'subtitle', + 'tertiary', + 'badge', + 'value', + 'valueSecondary', + ], + walletGroup: [], + rail: ['title', 'badge', 'status'], + activity: [ + 'title', + 'description', + 'status', + 'primaryAmount', + 'secondaryAmount', + ], + message: ['title', 'body', 'time'], + dataRow: ['columns', 'columnSecondary', 'index'], + market: ['title', 'subtitle', 'price', 'change'], + mediaTile: ['title', 'subtitle', 'badge'], + metricCard: ['title', 'value', 'subtitle', 'trend'], + sectionHeader: ['title', 'subtitle', 'value'], + action: ['title', 'value'], + system: ['title', 'message', 'actionText'], +}; + +const EXTRA_STYLE_KEYS_BY_ROW_TYPE: Readonly< + Partial> +> = { + market: [ + 'titleBadgeLayout', + 'contentTrailingGap', + 'subtitleTrailingPadding', + 'changeWidth', + 'changeHeight', + 'changeCornerRadius', + ], +}; + +function assertTextStyle( + style: NativeListTextStyle | undefined, path: string ): void { if (!style) return; + if ( + style.token !== undefined && + !Object.prototype.hasOwnProperty.call(TYPOGRAPHY_TOKENS, style.token) + ) { + fail( + `${path}.token`, + `must be one of ${Object.keys(TYPOGRAPHY_TOKENS).join(', ')}` + ); + } assertBoundedStyleNumber(style.fontSize, `${path}.fontSize`, 8, 48); assertBoundedStyleNumber(style.lineHeight, `${path}.lineHeight`, 8, 64); if ( @@ -151,38 +242,17 @@ function assertMarketTextStyle( } } -function assertMarketStyle( - style: MarketRowStyle | undefined, - path: string -): void { - if (!style) return; +function assertBoxStyle(style: RowBoxStyle, path: string): void { for (const field of [ 'horizontalPadding', 'verticalPadding', 'leadingGap', 'titleBadgeGap', 'trailingGap', - 'contentTrailingGap', - 'subtitleTrailingPadding', ] as const) { assertBoundedStyleNumber(style[field], `${path}.${field}`, 0, 64); } - // OneKey patch: validate the opt-in Market badge layout. - if ( - style.titleBadgeLayout !== undefined && - style.titleBadgeLayout !== 'inline' - ) { - fail(`${path}.titleBadgeLayout`, 'must be inline when provided'); - } assertBoundedStyleNumber(style.lineGap, `${path}.lineGap`, 0, 16); - assertBoundedStyleNumber(style.changeWidth, `${path}.changeWidth`, 1, 160); - assertBoundedStyleNumber(style.changeHeight, `${path}.changeHeight`, 1, 160); - assertBoundedStyleNumber( - style.changeCornerRadius, - `${path}.changeCornerRadius`, - 0, - 80 - ); if (style.image) { assertBoundedStyleNumber(style.image.width, `${path}.image.width`, 1, 160); assertBoundedStyleNumber( @@ -208,10 +278,140 @@ function assertMarketStyle( ); } } - assertMarketTextStyle(style.title, `${path}.title`); - assertMarketTextStyle(style.subtitle, `${path}.subtitle`); - assertMarketTextStyle(style.price, `${path}.price`); - assertMarketTextStyle(style.change, `${path}.change`); +} + +function assertMarketStyle( + style: MarketRowStyle | undefined, + path: string +): void { + if (!style) return; + assertBoxStyle(style, path); + for (const field of [ + 'contentTrailingGap', + 'subtitleTrailingPadding', + ] as const) { + assertBoundedStyleNumber(style[field], `${path}.${field}`, 0, 64); + } + // OneKey patch: validate the opt-in Market badge layout. + if ( + style.titleBadgeLayout !== undefined && + style.titleBadgeLayout !== 'inline' + ) { + fail(`${path}.titleBadgeLayout`, 'must be inline when provided'); + } + assertBoundedStyleNumber(style.changeWidth, `${path}.changeWidth`, 1, 160); + assertBoundedStyleNumber(style.changeHeight, `${path}.changeHeight`, 1, 160); + assertBoundedStyleNumber( + style.changeCornerRadius, + `${path}.changeCornerRadius`, + 0, + 80 + ); + assertTextStyle(style.title, `${path}.title`); + assertTextStyle(style.subtitle, `${path}.subtitle`); + assertTextStyle(style.price, `${path}.price`); + assertTextStyle(style.change, `${path}.change`); +} + +/** + * Rejects keys the template does not declare, so a style written for one + * template cannot reach a shared view through another. See docs/STYLE_SPEC.md + * §7 rule 1. + */ +function assertRowStyle(row: RowModel, path: string): void { + const style = (row as { style?: unknown }).style; + if (style === undefined) return; + if (typeof style !== 'object' || style === null || Array.isArray(style)) { + fail(`${path}.style`, 'must be an object'); + } + const textKeys = TEXT_STYLE_KEYS_BY_ROW_TYPE[row.type] ?? []; + const allowed = new Set([ + ...BOX_STYLE_KEYS, + ...textKeys, + ...(EXTRA_STYLE_KEYS_BY_ROW_TYPE[row.type] ?? []), + ]); + Object.keys(style as Record).forEach((key) => { + if (!allowed.has(key)) { + fail( + `${path}.style.${key}`, + `is not a style key of the "${row.type}" template` + ); + } + }); + // Market keeps its own richer assertions, driven from assertMarketRow. + if (row.type === 'market') return; + assertBoxStyle(style as RowBoxStyle, `${path}.style`); + const slots = style as Record; + textKeys.forEach((key) => + assertTextStyle(slots[key], `${path}.style.${key}`) + ); +} + +/** Token resolution is idempotent: a resolved style carries no token. */ +function resolveTextStyle( + style: NativeListTextStyle | undefined +): NativeListTextStyle | undefined { + if (!style?.token) return style; + const { token, ...overrides } = style; + return { ...TYPOGRAPHY_TOKENS[token], ...overrides }; +} + +function resolveStyleTokens( + style: Record, + rowType: RowModel['type'] +): Record { + const textKeys = TEXT_STYLE_KEYS_BY_ROW_TYPE[rowType] ?? []; + let changed = false; + const next: Record = { ...style }; + textKeys.forEach((key) => { + const slot = style[key] as NativeListTextStyle | undefined; + const resolved = resolveTextStyle(slot); + if (resolved !== slot) { + next[key] = resolved; + changed = true; + } + }); + return changed ? next : style; +} + +function normalizeRowStyles(row: T): T { + // Narrowing a generic does not reach the walletGroup members; go through the + // union type instead. + const model: RowModel = row; + const style = (row as { style?: Record }).style; + const nextStyle = style ? resolveStyleTokens(style, row.type) : style; + const prefix = model.type === 'market' ? model.subtitlePrefix : undefined; + const nextPrefixStyle = resolveTextStyle(prefix?.style); + let nextParent: IdentityRow | undefined; + let nextChildren: readonly IdentityRow[] | undefined; + if (model.type === 'walletGroup') { + const parent = normalizeRowStyles(model.parent); + const children = model.children.map(normalizeRowStyles); + if ( + parent !== model.parent || + children.some((child, index) => child !== model.children[index]) + ) { + nextParent = parent; + nextChildren = children; + } + } + if ( + nextStyle === style && + nextPrefixStyle === prefix?.style && + nextParent === undefined + ) { + return row; + } + const next: Record = { ...row }; + if (nextStyle !== style) next.style = nextStyle; + if (prefix && nextPrefixStyle !== prefix.style) { + next.subtitlePrefix = { ...prefix, style: nextPrefixStyle }; + } + if (nextParent !== undefined) { + next.parent = nextParent; + next.children = nextChildren; + } + return next as T; } function assertMarketRow(row: MarketRow, path: string): void { @@ -235,10 +435,7 @@ function assertMarketRow(row: MarketRow, path: string): void { 1, 320 ); - assertMarketTextStyle( - row.subtitlePrefix.style, - `${path}.subtitlePrefix.style` - ); + assertTextStyle(row.subtitlePrefix.style, `${path}.subtitlePrefix.style`); } assertText(row.price, `${path}.price`); assertText(row.change.text, `${path}.change.text`); @@ -286,7 +483,7 @@ function assertMarketRow(row: MarketRow, path: string): void { badgeKeys.add(badge.key); assertText(badge.text, `${badgePath}.text`); // OneKey patch: share typography bounds with Market text styles. - assertMarketTextStyle(badge.style, `${badgePath}.style`); + assertTextStyle(badge.style, `${badgePath}.style`); assertBoundedStyleNumber( badge.style?.height, `${badgePath}.style.height`, @@ -597,6 +794,7 @@ function assertRow( if (!row.groupId && row.groupPosition) { fail(`${path}.groupId`, 'is required when groupPosition is present'); } + assertRowStyle(row, path); assertVisual(row, path); switch (row.type) { @@ -1016,11 +1214,44 @@ export function validateSnapshot( 'supports at most one key in single mode' ); } - return snapshot; + return normalizeSnapshotStyles(snapshot); +} + +/** + * Resolves typography tokens to numbers. Returns the original snapshot when + * nothing needs resolving, so the common no-style path keeps object identity. + */ +function normalizeSnapshotStyles( + snapshot: NativeListSnapshot +): NativeListSnapshot { + let changed = false; + const rows = snapshot.rows.map((row) => { + const next = normalizeRowStyles(row); + if (next !== row) changed = true; + return next; + }); + const emptyState = snapshot.emptyState + ? normalizeRowStyles(snapshot.emptyState) + : snapshot.emptyState; + const fixedFooter = snapshot.fixedFooter + ? normalizeRowStyles(snapshot.fixedFooter) + : snapshot.fixedFooter; + if ( + !changed && + emptyState === snapshot.emptyState && + fixedFooter === snapshot.fixedFooter + ) { + return snapshot; + } + return { ...snapshot, rows, emptyState, fixedFooter }; } function assertPatchChanges(patch: RowPatch, index: number): void { const path = `patches[${index}].changes`; + const patchedStyle = (patch.changes as { style?: unknown }).style; + if (patchedStyle !== undefined) { + assertRowStyle({ type: patch.type, style: patchedStyle } as RowModel, path); + } // OneKey patch: partial balance updates retain a valid, current accessibility label. if ('accessibilityLabel' in patch.changes) { assertText(patch.changes.accessibilityLabel, `${path}.accessibilityLabel`); @@ -1236,7 +1467,26 @@ export function validatePatches( } assertPatchChanges(patch, index); }); - return patches; + return normalizePatchStyles(patches); +} + +/** Patches reach the native side without passing through a snapshot. */ +function normalizePatchStyles( + patches: readonly RowPatch[] +): readonly RowPatch[] { + let changed = false; + const next = patches.map((patch) => { + const style = (patch.changes as { style?: Record }).style; + if (!style) return patch; + const resolved = resolveStyleTokens(style, patch.type); + if (resolved === style) return patch; + changed = true; + return { + ...patch, + changes: { ...patch.changes, style: resolved }, + } as RowPatch; + }); + return changed ? next : patches; } export function applyRowPatches( diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index f164a17e2..9b9c94b05 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -9,8 +9,10 @@ import type { NativeListActionAnchor, NativeListActionSource, NativeListTheme, + NativeListTextStyle, ReorderEvent, RowActionEvent, + RowBoxStyle, RowModel, RowPatch, SelectionDeltaEvent, @@ -1827,11 +1829,9 @@ function createBadge( context: RenderContext, badge: Readonly<{ text: string; tone?: string }> ): HTMLElement { - const element = createElement( - context.document, - 'span', - 'ok-native-list-badge', - badge.text + const element = tagSlot( + createElement(context.document, 'span', 'ok-native-list-badge', badge.text), + 'badge' ); setData(element, 'tone', badge.tone); return element; @@ -2143,26 +2143,42 @@ function appendAccessories( ) { setData(container, 'nativeListAccountControl', 'createAddress'); } - accessories.forEach((accessory, slot) => - container.appendChild(createAccessory(context, rowKey, accessory, slot)) - ); + accessories.forEach((accessory, slot) => { + const element = createAccessory(context, rowKey, accessory, slot); + if (accessory.kind === 'value' || accessory.kind === 'valuePair') { + tagSlot(element, slot === 0 ? 'value' : 'valueSecondary'); + } + container.appendChild(element); + }); parent.appendChild(container); } +/** + * Marks the element that renders one model field. Style keys name model fields, + * not views, and the view pool is shared. See docs/STYLE_SPEC.md §4. + */ +function tagSlot(element: T, slot: string): T { + element.dataset.nlSlot = slot; + return element; +} + function createTextColumn( context: RenderContext, title: string, subtitle?: string, tertiary?: string, tertiaryTone?: 'secondary' | 'info', - badges?: readonly Readonly<{ text: string; tone?: string }>[] + badges?: readonly Readonly<{ text: string; tone?: string }>[], + slots: Readonly<{ title: string; subtitle: string; tertiary: string }> = { + title: 'title', + subtitle: 'subtitle', + tertiary: 'tertiary', + } ): HTMLElement { const column = createElement(context.document, 'span', 'ok-native-list-flex'); - const titleLine = createElement( - context.document, - 'span', - 'ok-native-list-title', - title + const titleLine = tagSlot( + createElement(context.document, 'span', 'ok-native-list-title', title), + slots.title ); if (badges?.length) { const badgeLine = createElement( @@ -2178,21 +2194,27 @@ function createTextColumn( column.appendChild(titleLine); if (subtitle) column.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - subtitle + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-secondary', + subtitle + ), + slots.subtitle ) ); if (tertiary) { - const element = createElement( - context.document, - 'span', - tertiaryTone === 'info' - ? 'ok-native-list-secondary ok-native-list-info' - : 'ok-native-list-secondary ok-native-list-tertiary', - tertiary + const element = tagSlot( + createElement( + context.document, + 'span', + tertiaryTone === 'info' + ? 'ok-native-list-secondary ok-native-list-info' + : 'ok-native-list-secondary ok-native-list-tertiary', + tertiary + ), + slots.tertiary ); column.appendChild(element); } @@ -2303,13 +2325,16 @@ function createSectionHeader( } body.appendChild(column); if (row.value) { - const value = createElement( - context.document, - row.valueActionKey ? 'button' : 'span', - row.valueActionKey - ? 'ok-native-list-action-button ok-native-list-section-value' - : 'ok-native-list-value ok-native-list-section-value', - row.value + const value = tagSlot( + createElement( + context.document, + row.valueActionKey ? 'button' : 'span', + row.valueActionKey + ? 'ok-native-list-action-button ok-native-list-section-value' + : 'ok-native-list-value ok-native-list-section-value', + row.value + ), + 'value' ); applyValueSegments(value, row.valueSegments); if (row.presentation === 'networkSelector' && row.height !== undefined) { @@ -2365,11 +2390,14 @@ function createActionRow( .join(' ') ); if (row.icon) body.appendChild(createVisual(context, row.icon)!); - const title = createElement( - context.document, - 'span', - 'ok-native-list-action-title', - row.title + const title = tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-action-title', + row.title + ), + 'title' ); setData(title, 'tone', row.tone); if (row.presentation === 'accountSelector' && row.icon) @@ -2462,7 +2490,10 @@ function createSystemRow( if (row.variant === 'noMatch') { body.style.justifyContent = 'center'; body.style.padding = '32px'; - const message = createElement(context.document, 'span', '', row.message); + const message = tagSlot( + createElement(context.document, 'span', '', row.message), + 'message' + ); message.style.fontSize = '16px'; message.style.lineHeight = '24px'; body.appendChild(message); @@ -2487,19 +2518,25 @@ function createSystemRow( body.classList.add('ok-native-list-warning'); body.style.borderColor = row.borderColor ?? 'var(--nl-separator)'; body.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-warning-title', - row.title + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-warning-title', + row.title + ), + 'title' ) ); body.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-warning-message', - row.message + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-warning-message', + row.message + ), + 'message' ) ); return body; @@ -2514,11 +2551,14 @@ function createSystemRow( : row.message ?? (row.variant === 'end' ? 'End' : ''); if (message) body.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - message + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-secondary', + message + ), + 'message' ) ); return body; @@ -2536,20 +2576,26 @@ function createRailRow( const visual = createVisual(context, row.visual); if (visual) body.appendChild(visual); body.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-rail-title', - row.title + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-rail-title', + row.title + ), + 'title' ) ); if (row.status && row.status !== 'none') body.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - row.status + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-secondary', + row.status + ), + 'status' ) ); if (row.badge) body.appendChild(createBadge(context, row.badge)); @@ -2589,11 +2635,14 @@ function createMediaRow( 'ok-native-list-media-subtitle-row' ); subtitleLine.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-media-subtitle', - row.subtitle || '-' + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-media-subtitle', + row.subtitle || '-' + ), + 'subtitle' ) ); if (row.networkImage) { @@ -2606,11 +2655,14 @@ function createMediaRow( } metadata.appendChild(subtitleLine); metadata.appendChild( - createElement( - context.document, - 'div', - 'ok-native-list-media-title', - row.title + tagSlot( + createElement( + context.document, + 'div', + 'ok-native-list-media-title', + row.title + ), + 'title' ) ); if (row.badge) metadata.appendChild(createBadge(context, row.badge)); @@ -2753,27 +2805,36 @@ function createMetricRow( if (visual) body.appendChild(visual); } body.appendChild( - createElement( - context.document, - 'div', - 'ok-native-list-secondary', - row.title + tagSlot( + createElement( + context.document, + 'div', + 'ok-native-list-secondary', + row.title + ), + 'title' ) ); body.appendChild( - createElement( - context.document, - 'div', - 'ok-native-list-metric-value', - row.value + tagSlot( + createElement( + context.document, + 'div', + 'ok-native-list-metric-value', + row.value + ), + 'value' ) ); if (row.trend) { - const trend = createElement( - context.document, - 'div', - 'ok-native-list-secondary', - row.trend + const trend = tagSlot( + createElement( + context.document, + 'div', + 'ok-native-list-secondary', + row.trend + ), + 'trend' ); trend.style.color = row.trendTone === 'positive' @@ -2785,11 +2846,14 @@ function createMetricRow( } if (row.subtitle) body.appendChild( - createElement( - context.document, - 'div', - 'ok-native-list-secondary', - row.subtitle + tagSlot( + createElement( + context.document, + 'div', + 'ok-native-list-secondary', + row.subtitle + ), + 'subtitle' ) ); if (row.badge) body.appendChild(createBadge(context, row.badge)); @@ -2809,11 +2873,14 @@ function createDataRow( body.appendChild(createCheckbox(context, row.key, row.checkbox)); if (row.index !== undefined) body.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-index', - String(row.index) + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-index', + String(row.index) + ), + 'index' ) ); if (row.favorite) { @@ -2838,10 +2905,9 @@ function createDataRow( ); cell.style.flex = String(column.weight ?? 1); setData(cell, 'align', column.alignment ?? 'start'); - const primary = createElement( - context.document, - 'span', - 'ok-native-list-data-primary' + const primary = tagSlot( + createElement(context.document, 'span', 'ok-native-list-data-primary'), + 'columns' ); primary.style.color = toneColor(column.tone, 'primary'); if (column.secondaryLeadingText) @@ -2863,11 +2929,14 @@ function createDataRow( } cell.appendChild(primary); if (column.secondaryText) { - const secondary = createElement( - context.document, - 'span', - 'ok-native-list-secondary', - column.secondaryText + const secondary = tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-secondary', + column.secondaryText + ), + 'columnSecondary' ); secondary.style.color = toneColor(column.secondaryTone, 'secondary'); cell.appendChild(secondary); @@ -2980,7 +3049,17 @@ function createIdentityActivityOrMessageRow( row.type === 'identity' ? row.tertiaryTone : undefined, row.type === 'identity' && presentation !== 'walletSidebar' ? row.badges - : undefined + : undefined, + { + title: 'title', + subtitle: + row.type === 'activity' + ? 'description' + : row.type === 'message' + ? 'body' + : 'subtitle', + tertiary: 'tertiary', + } ); // OneKey patch: match existing search, subtitle fragments, and sidebar badges. if (row.type === 'identity') { @@ -3094,26 +3173,40 @@ function createIdentityActivityOrMessageRow( ); if (row.primaryAmount) amounts.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-value', - row.primaryAmount + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-value', + row.primaryAmount + ), + 'primaryAmount' ) ); if (row.secondaryAmount) amounts.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - row.secondaryAmount + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-secondary', + row.secondaryAmount + ), + 'secondaryAmount' ) ); body.appendChild(amounts); } else if (row.type === 'message') { body.appendChild( - createElement(context.document, 'span', 'ok-native-list-time', row.time) + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-time', + row.time + ), + 'time' + ) ); if (row.thumbnail) { const thumbnail = createImage( @@ -3498,7 +3591,78 @@ function createMarketRow(context: RenderContext, row: MarketRow): HTMLElement { return body; } -function createRowBody(context: RenderContext, row: RowModel): HTMLElement { +const ROW_BOX_STYLE_KEYS: ReadonlySet = new Set([ + 'horizontalPadding', + 'verticalPadding', + 'leadingGap', + 'lineGap', + 'titleBadgeGap', + 'trailingGap', + 'image', +]); + +function applyTextStyleToSlot( + element: HTMLElement, + style: NativeListTextStyle +): void { + if (style.fontSize !== undefined) + element.style.fontSize = String(style.fontSize) + 'px'; + if (style.lineHeight !== undefined) + element.style.lineHeight = String(style.lineHeight) + 'px'; + if (style.fontWeight !== undefined) + element.style.fontWeight = String(marketFontWeight(style.fontWeight, 400)); + if (style.color !== undefined) element.style.color = style.color; + if (style.alignment !== undefined) element.style.textAlign = style.alignment; + if (style.lines !== undefined) { + element.style.removeProperty('-webkit-line-clamp'); + element.style.removeProperty('-webkit-box-orient'); + element.style.display = ''; + element.style.whiteSpace = style.lines === 2 ? 'normal' : 'nowrap'; + if (style.lines === 2) { + element.style.display = '-webkit-box'; + element.style.setProperty('-webkit-line-clamp', '2'); + element.style.setProperty('-webkit-box-orient', 'vertical'); + } + } +} + +/** + * Applies a row style once the template has been built. A style key names the + * model field it modifies and the element rendering that field carries + * `data-nl-slot`, because the view pool is shared across templates. Market + * keeps its own richer path. See docs/STYLE_SPEC.md §4 and §8. + */ +export function applyRowStyle(body: HTMLElement, row: RowModel): void { + if (row.type === 'market') return; + const style = (row as { style?: Record }).style; + if (!style) return; + const box = style as RowBoxStyle; + if (box.horizontalPadding !== undefined) + body.style.paddingInline = String(box.horizontalPadding) + 'px'; + if (box.verticalPadding !== undefined) + body.style.paddingBlock = String(box.verticalPadding) + 'px'; + if (box.lineGap !== undefined) { + const lineGap = String(box.lineGap) + 'px'; + body + .querySelectorAll('.ok-native-list-flex') + .forEach((column) => { + column.style.rowGap = lineGap; + }); + } + Object.keys(style).forEach((key) => { + if (ROW_BOX_STYLE_KEYS.has(key)) return; + const slotStyle = style[key] as NativeListTextStyle | undefined; + if (!slotStyle) return; + body + .querySelectorAll('[data-nl-slot="' + key + '"]') + .forEach((element) => applyTextStyleToSlot(element, slotStyle)); + }); +} + +export function createRowBody( + context: RenderContext, + row: RowModel +): HTMLElement { switch (row.type) { case 'walletGroup': return createWalletGroupRow(context, row); @@ -4303,6 +4467,7 @@ export class NativeListWebEngine { }; const body = createRowBody(context, row); applySelectorTabularNumbers(body, row); + applyRowStyle(body, row); // OneKey patch: explicit selector fields preserve original page geometry. element.style.contain = row.backgroundFullWidth ? 'layout style' : ''; if (row.backgroundColor) body.style.backgroundColor = row.backgroundColor; From 94f2a3f1bcdda45ee54f3343e144ecf9abebe523 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 16 Sep 2026 09:33:30 +0800 Subject: [PATCH 03/71] feat(native-list): apply the row style on iOS and Android Both platforms gain applyRowStyle, running after the per-template binder so it is the last writer. Android must run after applySize, which re-dispatches font size and typeface by row type and would otherwise overwrite the style. A style key names a model field, and the view pool is shared, so the same field lands in a different view per template: metricCard draws its value through the view identity uses for its title, and one status view carries rail.status, activity.status, message.time and metricCard.trend. The mapping is nativeListStyleSlot in NativeListModels.kt and styleSlot in NativeListCell.swift, both following docs/STYLE_SPEC.md section 4. The Kotlin copy is unit-tested, including a check that no template maps two style keys onto one view - a collision would make one of them silently win. Both platforms also gain resetRowStyle, running before the binder, because neither reset path is complete (STYLE_SPEC section 7 rule 2). Android's resetViews restores visibility, gravity, maxLines, layout params, background and padding but not textSize, typeface or lineHeight. iOS's reset restores the fonts of the shared labels but not those of the data, metric and media labels, nor any text alignment. Without the reset a style would leak into the next row that reuses the view. Both resets are guarded by a dirty flag, so an unstyled list pays nothing. On iOS the pass rebuilds the attributed string rather than assigning font and textColor: the binders install an attributed string through setLineHeight, so a plain font assignment would not take effect. The rebuilt line box centers font metrics inside an explicit lineHeight, matching React Native and the Web engine. Deliberately unchanged, and recorded in the spec's status table: - Row heights. A styled row that grows still needs an explicit height. - The list-wide source scale on Android. Making it per-row would move every selector list's metrics and needs device verification. - dataRow columns in the table layout, whose text lives inside NativeListTableColumnView; the linear layout is covered. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../com/onekey/nativelist/NativeListModels.kt | 71 ++++++ .../onekey/nativelist/NativeListRowView.kt | 102 ++++++++ .../nativelist/NativeListStyleSlotTest.kt | 80 ++++++ .../docs/STYLE_SPEC.md | 24 +- .../ios/NativeListCell.swift | 230 ++++++++++++++++++ 6 files changed, 503 insertions(+), 5 deletions(-) create mode 100644 native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cf0f332d..6ea62d184 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Features +- **native-list (iOS and Android)**: Apply the row style natively. A style key is mapped to the view that renders that model field by `nativeListStyleSlot` (Kotlin, unit-tested) and `styleSlot` (Swift), because the view pool is shared and the mapping is not one to one. Both platforms gained a `resetRowStyle` that runs before the binder: Android's `resetViews` restores neither `textSize`, `typeface` nor `lineHeight`, and iOS's `reset` misses the data, metric and media label fonts, so without it a style would leak into the next row reusing the view. On iOS the pass rebuilds the attributed string, because the binders install one through `setLineHeight` and a plain `font` assignment would not take effect. - **native-list**: Add a bounded per-template row style. `style` keys name the model field they modify rather than the view that carries it, so `metricCard.style.value` reaches the large number even though it shares a view with `identity.style.title`. Typography can be given as a named step (`{ token: '$bodyLg' }`) which is resolved to numbers in JavaScript before serialization, so no native renderer learns the vocabulary; explicit values still override. Keys a template does not declare are rejected by `validateSnapshot` and `validatePatches`, and styles carried by a patch are resolved the same way. Implemented on Web: text slots for every template plus `horizontalPadding`, `verticalPadding`, and `lineGap`. Row heights are unchanged — a styled row that grows still needs an explicit `height`. ### Documentation diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index 36bc73b9c..52ae588fe 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -25,6 +25,77 @@ internal fun isNativeListWholeRowInteractive( pressDisabled = pressDisabled, ) +/** + * Maps a style key - which names a model field - to the view slot that renders it. + * The view pool is shared across templates and the mapping is not one to one: + * metricCard renders `value` through the title view and its own `title` through + * the subtitle view, and one status view carries rail.status, activity.status, + * message.time and metricCard.trend. See docs/STYLE_SPEC.md section 4. + * + * Returns null when the template does not render that field, so an unmapped key + * is ignored rather than reaching an unrelated view. + */ +internal fun nativeListStyleSlot(type: String, variant: String, field: String): String? = + when (type) { + "identity" -> when (field) { + "title", "subtitle", "tertiary", "badge", "value", "valueSecondary" -> field + else -> null + } + "rail" -> when (field) { + "title", "badge", "status" -> field + else -> null + } + "activity" -> when (field) { + "title", "status" -> field + "description" -> "subtitle" + "primaryAmount" -> "value" + "secondaryAmount" -> "valueSecondary" + else -> null + } + "message" -> when (field) { + "title" -> "title" + "body" -> "subtitle" + "time" -> "status" + else -> null + } + "dataRow" -> when (field) { + "columns" -> "dataPrimary" + "index" -> "value" + else -> null + } + "mediaTile" -> when (field) { + "title", "subtitle" -> field + "badge" -> "mediaBadge" + else -> null + } + // The large number and the small label sit in swapped views. + "metricCard" -> when (field) { + "value" -> "title" + "title" -> "subtitle" + "subtitle" -> "metricSubtitle" + "trend" -> "status" + else -> null + } + "sectionHeader" -> when (field) { + "title", "subtitle", "value" -> field + else -> null + } + "action" -> when (field) { + "title", "value" -> field + else -> null + } + "system" -> when (field) { + "title" -> "title" + // Only the warning variant renders a separate title; every other variant + // puts its message in the title view. + "message" -> if (variant == "warning") "subtitle" else "title" + "actionText" -> "value" + else -> null + } + // Market owns its own richer style path. + else -> null + } + // The image fallback-state cache is process-wide, so it is keyed by a digest of the request // identity instead of the raw headers, which can carry credentials such as Authorization. internal fun nativeListSourceFallbackStateKey(uri: String, headers: Map): String? { diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index f3669e328..388d619f8 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -523,6 +523,8 @@ internal class NativeListRowView( private var marketTouchX = 0f private var marketTouchY = 0f private var marketLongPressFired = false + // OneKey patch: a row style wrote view properties that resetViews does not restore. + private var styledViewsDirty = false private var reorderActive = false private var checkboxCheckedColor = Color.rgb(32, 32, 32) private var checkboxUncheckedColor = Color.rgb(252, 252, 252) @@ -1023,10 +1025,84 @@ internal class NativeListRowView( TextViewCompat.setLineHeight(subtitle, dp(20)) minimumHeight = 0 } + applyRowStyle(item) applyListOrientation(item, listOrientation) applySelectorTypography(item) } + /** + * docs/STYLE_SPEC.md section 8. Must run after applySize(), which re-dispatches + * font size and typeface by row type and would otherwise overwrite the style. + * A style key names a model field; nativeListStyleSlot maps it to the view that + * renders that field, because the view pool is shared across templates. + */ + private fun applyRowStyle(item: NativeListItem) { + // Market owns its own richer style path, applied inside bindMarket. + if (item.type == "market") return + val style = item.json.optJSONObject("style") ?: return + styledViewsDirty = true + if (style.has("horizontalPadding")) { + val inset = dp(style.optDouble("horizontalPadding").roundToInt()) + setPadding(inset, paddingTop, inset, paddingBottom) + } + if (style.has("verticalPadding")) { + val inset = dp(style.optDouble("verticalPadding").roundToInt()) + setPadding(paddingLeft, inset, paddingRight, inset) + } + if (style.has("lineGap")) { + (subtitle.layoutParams as? MarginLayoutParams)?.topMargin = + dp(style.optDouble("lineGap").roundToInt()) + } + val variant = item.json.optString("variant") + val fields = style.keys() + while (fields.hasNext()) { + val field = fields.next() + val slotStyle = style.optJSONObject(field) ?: continue + when (val slot = nativeListStyleSlot(item.type, variant, field)) { + null -> continue + "dataPrimary" -> dataColumns.forEach { applyStyledText(it, slotStyle) } + else -> styledSlotView(slot)?.let { applyStyledText(it, slotStyle) } + } + } + } + + private fun styledSlotView(slot: String): TextView? = when (slot) { + "title" -> title + "subtitle" -> subtitle + "tertiary" -> tertiary + "status" -> status + "metricSubtitle" -> metricSubtitle + "badge" -> badgeLine + "mediaBadge" -> mediaBadge + "value" -> trailingViews[0] + "valueSecondary" -> trailingViews[1] + else -> null + } + + private fun applyStyledText(view: TextView, style: JSONObject) { + if (style.has("fontSize")) view.textSize = sp(style.optDouble("fontSize").toFloat()) + style.optString("fontWeight").takeIf(String::isNotEmpty)?.let { + view.typeface = marketTypeface(it, "regular") + } + style.optString("color").takeIf(String::isNotEmpty)?.let { + view.setTextColor(safeColor(it, view.currentTextColor)) + } + if (style.has("lineHeight")) { + TextViewCompat.setLineHeight(view, dp(style.optDouble("lineHeight").roundToInt())) + } + if (style.has("lines")) { + view.maxLines = style.optInt("lines").coerceIn(1, 2) + view.ellipsize = TextUtils.TruncateAt.END + } + style.optString("alignment").takeIf(String::isNotEmpty)?.let { + view.gravity = (view.gravity and Gravity.VERTICAL_GRAVITY_MASK) or when (it) { + "center" -> Gravity.CENTER_HORIZONTAL + "end" -> Gravity.END + else -> Gravity.START + } + } + } + // OneKey patch: keep a small idle member pool after reuse or a direct rebind. // The compact proxy/expansion keeps every member until it leaves that state. private fun trimWalletGroupRows(required: Int) { @@ -1209,7 +1285,33 @@ internal class NativeListRowView( bindingEpoch += 1 } + /** + * docs/STYLE_SPEC.md section 7 rule 2: resetViews() restores visibility, gravity, + * maxLines, layout params, background and padding - but not textSize, typeface or + * lineHeight, which each binding path re-establishes for itself. A style that wrote + * one of those would therefore leak into the next row reusing the view, so put them + * back to the constructor baseline before the binder runs. + */ + private fun resetRowStyle() { + if (!styledViewsDirty) return + styledViewsDirty = false + listOf(title, subtitle, tertiary, status, metricSubtitle).forEach { view -> + view.typeface = NativeListFonts.regular(context) + view.setLineSpacing(0f, 1f) + } + badgeLine.typeface = NativeListFonts.medium(context) + badgeLine.setLineSpacing(0f, 1f) + mediaBadge.typeface = NativeListFonts.regular(context) + mediaBadge.setLineSpacing(0f, 1f) + trailingViews.forEach { it.setLineSpacing(0f, 1f) } + dataColumns.forEach { + it.typeface = NativeListFonts.medium(context) + it.setLineSpacing(0f, 1f) + } + } + private fun resetViews() { + resetRowStyle() clipChildren = true clipToPadding = true selectorOriginalFontFeatures.forEach { (view, original) -> view.fontFeatureSettings = original } diff --git a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt new file mode 100644 index 000000000..d1c9ee386 --- /dev/null +++ b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt @@ -0,0 +1,80 @@ +package com.margelo.nitro.nativelist + +import org.junit.Assert.assertEquals +import org.junit.Test + +class NativeListStyleSlotTest { + private data class Case( + val type: String, + val field: String, + val variant: String = "", + val expected: String?, + ) + + @Test + fun styleKeyResolvesToTheViewThatRendersThatModelField() { + val cases = listOf( + // metricCard swaps the two views: the large number is drawn by the title + // view and the small label by the subtitle view. + Case(type = "metricCard", field = "value", expected = "title"), + Case(type = "metricCard", field = "title", expected = "subtitle"), + Case(type = "metricCard", field = "subtitle", expected = "metricSubtitle"), + Case(type = "metricCard", field = "trend", expected = "status"), + // identity keeps its own names. + Case(type = "identity", field = "title", expected = "title"), + Case(type = "identity", field = "valueSecondary", expected = "valueSecondary"), + // One status view carries four different model fields. + Case(type = "rail", field = "status", expected = "status"), + Case(type = "activity", field = "status", expected = "status"), + Case(type = "message", field = "time", expected = "status"), + // Amounts, indices and values share the two trailing views. + Case(type = "activity", field = "primaryAmount", expected = "value"), + Case(type = "activity", field = "secondaryAmount", expected = "valueSecondary"), + Case(type = "dataRow", field = "index", expected = "value"), + Case(type = "sectionHeader", field = "value", expected = "value"), + // Only the warning variant renders a separate title. + Case(type = "system", field = "message", variant = "warning", expected = "subtitle"), + Case(type = "system", field = "message", variant = "noMatch", expected = "title"), + // A field the template does not render is ignored, never remapped onto + // whichever view happens to be free. + Case(type = "identity", field = "price", expected = null), + Case(type = "rail", field = "subtitle", expected = null), + Case(type = "market", field = "title", expected = null), + Case(type = "walletGroup", field = "title", expected = null), + ) + + assertEquals( + cases.map(Case::expected), + cases.map { nativeListStyleSlot(it.type, it.variant, it.field) }, + ) + } + + @Test + fun noTemplateMapsTwoStyleKeysOntoOneView() { + // A collision would make one of the two keys silently win. + val templates = listOf( + Triple("identity", "", listOf("title", "subtitle", "tertiary", "badge", "value", "valueSecondary")), + Triple("rail", "", listOf("title", "badge", "status")), + Triple("activity", "", listOf("title", "description", "status", "primaryAmount", "secondaryAmount")), + Triple("message", "", listOf("title", "body", "time")), + Triple("dataRow", "", listOf("columns", "index")), + Triple("mediaTile", "", listOf("title", "subtitle", "badge")), + Triple("metricCard", "", listOf("title", "value", "subtitle", "trend")), + Triple("sectionHeader", "", listOf("title", "subtitle", "value")), + Triple("action", "", listOf("title", "value")), + // Only `warning` carries both a title and a message; the other variants + // have no title field at all, so their message owning the title view is + // not a collision. + Triple("system", "warning", listOf("title", "message", "actionText")), + ) + + templates.forEach { (type, variant, fields) -> + val slots = fields.mapNotNull { nativeListStyleSlot(type, variant, it) } + assertEquals( + "$type maps two style keys onto one view: $slots", + slots.size, + slots.toSet().size, + ) + } + } +} diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index 5da23b0f1..4fb99c112 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -53,10 +53,16 @@ object identity. | Layer | State | | --- | --- | | Contract, validation, token resolution, patch support | Implemented | -| Web — text slots for every template, `horizontalPadding`, `verticalPadding`, `lineGap` | Implemented | -| Web — `leadingGap`, `trailingGap`, `titleBadgeGap`, `image` | Validated but applied only on `market`; the others need a per-template default gap that cannot be read back from the DOM | -| iOS, Android | Not started | +| Text slots, `horizontalPadding`, `verticalPadding`, `lineGap` | Implemented on Web, iOS and Android | +| `leadingGap`, `trailingGap`, `titleBadgeGap`, `image` | Validated but applied only on `market`. The others need a per-template default gap, which is not readable from the DOM on Web and is spread across the binders on the native sides | +| `dataRow` columns in the `table` layout | Not applied. The text lives inside `NativeListTableColumnView` / `tableDataColumns`, which own their own labels; the `linear` layout is covered | | Row heights | Unchanged. A styled row that grows still needs an explicit `row.height` (§6.1) | +| Android list-wide source scale (§6.3) | Unchanged. Making it per-row would move every selector list's metrics and needs device verification | + +The field-to-view mapping exists twice, once per native language — `nativeListStyleSlot` +in `NativeListModels.kt` and `styleSlot` in `NativeListCell.swift`. §4 is the source of +truth for both; the Kotlin copy is unit-tested, including a check that no template maps +two style keys onto one view. ## 3. T1 — Design tokens @@ -427,11 +433,19 @@ affect any other row. Four rules: Each renderer already runs a pass after the per-template binder. The style pass belongs there, so none of the 13 binders change. +All three are implemented as `applyRowStyle`. + | Platform | Anchor | Note | | --- | --- | --- | -| iOS | `NativeListCell.bind()`, after the `switch item.type` | Also covers box metrics: the four root constraints, stack spacings, leading size | +| iOS | `NativeListCell.bind()`, after the `switch item.type` | The binders install an attributed string through `setLineHeight`, so assigning `font` or `textColor` alone would not take effect — the style pass rebuilds the line box | | Android | `NativeListRowView.bind()`, **after** `applySize(item)` | `applySize` re-dispatches font size and typeface by row type and would otherwise overwrite the style | -| Web | `renderElement()`, after `createRowBody()` — **implemented** as `applyRowStyle()` | The selector inline overrides already live here | +| Web | `renderElement()`, after `createRowBody()` | The selector inline overrides already live here | + +Each platform also gained a `resetRowStyle` that runs before the binder, per §7 rule 2. +On Android `resetViews()` restores neither `textSize`, `typeface` nor `lineHeight`; on iOS +`reset()` restores the shared label fonts but not the data, metric and media label fonts, +nor any text alignment. Without the reset, a style would leak into the next row that +reuses the view. The existing market helpers generalize rather than being rewritten: `applyMarketTextStyle` / `applyMarketButtonStyle` / `marketAttributedText` (iOS), diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index 4908595e6..7789e68c0 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -548,6 +548,8 @@ final class NativeListCell: UICollectionViewCell { private var checkboxBorderColor = UIColor(nativeListHex: "#CECECE", fallback: .lightGray) private var visualBackdropColor = UIColor.white private var currentLayout = "linear" + // OneKey patch: a row style wrote view properties that reset() does not restore. + private var styledViewsDirty = false private var currentTheme: [String: Any]? private var currentItemIndex: Int? // OneKey patch: delayed image retries belong to the current reusable cell binding. @@ -1009,6 +1011,7 @@ final class NativeListCell: UICollectionViewCell { case "system": bindSystem(item, theme: theme) default: break } + applyRowStyle(item) applySelectorTypography(item) if shouldRestoreHighlight && isUserInteractionEnabled { isHighlighted = true @@ -1191,7 +1194,29 @@ final class NativeListCell: UICollectionViewCell { return color } + /** + docs/STYLE_SPEC.md section 7 rule 2: reset() restores the fonts of the labels + every template uses, but not those of the data, metric and media labels, nor any + text alignment. A style that wrote one of those would leak into the next row that + reuses the cell, so put them back before the binder runs. + */ + private func resetRowStyle() { + guard styledViewsDirty else { return } + styledViewsDirty = false + metricSubtitleLabel.font = nativeListFont(ofSize: 12) + mediaBadgeLabel.font = nativeListFont(ofSize: 14, weight: .medium) + dataLabels.forEach { $0.font = nativeListFont(ofSize: 12) } + let aligned: [UILabel] = [ + subtitleLabel, tertiaryLabel, statusLabel, badgeLabel, metricSubtitleLabel, + ] + aligned.forEach { + $0.textAlignment = .natural + $0.lineBreakMode = .byTruncatingTail + } + } + private func reset() { + resetRowStyle() titleLabel.transform = .identity restoreSelectorTypography() // OneKey patch: remove selector decorations before rebinding recycled cells. @@ -2267,6 +2292,211 @@ final class NativeListCell: UICollectionViewCell { } } + /** + docs/STYLE_SPEC.md section 4. A style key names a model field, and the view pool + is shared, so the same field lands in a different view per template. This table + must stay in step with `nativeListStyleSlot` on Android. + */ + private func styleSlot(type: String, variant: String, field: String) -> String? { + switch type { + case "identity": + return ["title", "subtitle", "tertiary", "badge", "value", "valueSecondary"] + .contains(field) ? field : nil + case "rail": + return ["title", "badge", "status"].contains(field) ? field : nil + case "activity": + switch field { + case "title", "status": return field + case "description": return "subtitle" + case "primaryAmount": return "value" + case "secondaryAmount": return "valueSecondary" + default: return nil + } + case "message": + switch field { + case "title": return "title" + case "body": return "subtitle" + case "time": return "status" + default: return nil + } + case "dataRow": + switch field { + case "columns": return "dataPrimary" + case "index": return "value" + default: return nil + } + case "mediaTile": + switch field { + case "title", "subtitle": return field + case "badge": return "mediaBadge" + default: return nil + } + // The large number and the small label sit in swapped views. + case "metricCard": + switch field { + case "value": return "title" + case "title": return "subtitle" + case "subtitle": return "metricSubtitle" + case "trend": return "status" + default: return nil + } + case "sectionHeader": + return ["title", "subtitle", "value"].contains(field) ? field : nil + case "action": + return ["title", "value"].contains(field) ? field : nil + case "system": + switch field { + case "title": return "title" + // Only the warning variant renders a separate title; every other variant + // puts its message in the title view. + case "message": return variant == "warning" ? "subtitle" : "title" + case "actionText": return "value" + default: return nil + } + default: + return nil + } + } + + /** + docs/STYLE_SPEC.md section 8. Runs after the per-template binder, so it is the + last writer. Market owns its own richer path inside bindMarket. + */ + private func applyRowStyle(_ item: NativeListItem) { + if item.type == "market" { return } + guard let style = item.data.dictionary("style") else { return } + styledViewsDirty = true + if style["horizontalPadding"] != nil { + let inset = CGFloat(style.double("horizontalPadding")) + rootLeadingConstraint.constant = inset + rootTrailingConstraint.constant = -inset + } + if style["verticalPadding"] != nil { + let inset = CGFloat(style.double("verticalPadding")) + rootTopConstraint.constant = inset + rootBottomConstraint.constant = -inset + } + if style["lineGap"] != nil { + mainStack.spacing = CGFloat(style.double("lineGap")) + } + let variant = item.data.string("variant") + for (field, value) in style { + guard let slotStyle = value as? [String: Any], + let slot = styleSlot(type: item.type, variant: variant, field: field) + else { continue } + switch slot { + case "title": applyStyledText(titleLabel, slotStyle) + case "subtitle": applyStyledText(subtitleLabel, slotStyle) + case "tertiary": applyStyledText(tertiaryLabel, slotStyle) + case "status": applyStyledText(statusLabel, slotStyle) + case "metricSubtitle": applyStyledText(metricSubtitleLabel, slotStyle) + case "badge": applyStyledText(badgeLabel, slotStyle) + case "mediaBadge": applyStyledText(mediaBadgeLabel, slotStyle) + case "dataPrimary": dataLabels.forEach { applyStyledText($0, slotStyle) } + case "value": applyStyledButton(accessoryButtons[0], slotStyle) + case "valueSecondary": applyStyledButton(accessoryButtons[1], slotStyle) + default: break + } + } + } + + /** + The binders install an attributed string through setLineHeight, so assigning + `font` or `textColor` alone would not take effect. Rebuild the line box. + */ + private func applyStyledText(_ label: UILabel, _ style: [String: Any]) { + let text = label.attributedText?.string ?? label.text ?? "" + guard !text.isEmpty else { return } + let baseFont = label.font ?? nativeListFont(ofSize: 14) + let size = CGFloat(style.double("fontSize", default: Double(baseFont.pointSize))) + let font: UIFont + if let weightName = style["fontWeight"] as? String { + font = nativeListFont( + ofSize: size, + weight: marketFontWeight(weightName, fallback: .regular) + ) + } else { + font = baseFont.withSize(size) + } + let color = (style["color"] as? String) + .map { UIColor(nativeListHex: $0, fallback: label.textColor ?? .black) } + ?? label.textColor ?? .black + if style["lines"] != nil { + label.numberOfLines = min(2, max(1, style.int("lines", default: 1))) + label.lineBreakMode = .byTruncatingTail + } + if let alignmentName = style["alignment"] as? String { + label.textAlignment = marketTextAlignment(alignmentName) + } + label.font = font + label.textColor = color + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = label.textAlignment + paragraph.lineBreakMode = label.lineBreakMode + var attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraph, + ] + if style["lineHeight"] != nil { + let box = CGFloat(style.double("lineHeight")) + paragraph.minimumLineHeight = box + paragraph.maximumLineHeight = box + // React Native centers font metrics inside an explicit line height. + attributes[.baselineOffset] = max(0, (box - font.lineHeight) / 2) + } + label.attributedText = NSAttributedString(string: text, attributes: attributes) + } + + private func applyStyledButton(_ button: UIButton, _ style: [String: Any]) { + let text = button.attributedTitle(for: .normal)?.string + ?? button.title(for: .normal) + ?? "" + guard !text.isEmpty else { return } + let baseFont = button.titleLabel?.font ?? nativeListFont(ofSize: 14) + let size = CGFloat(style.double("fontSize", default: Double(baseFont.pointSize))) + let font: UIFont + if let weightName = style["fontWeight"] as? String { + font = nativeListFont( + ofSize: size, + weight: marketFontWeight(weightName, fallback: .regular) + ) + } else { + font = baseFont.withSize(size) + } + let fallbackColor = button.titleColor(for: .normal) ?? .black + let color = (style["color"] as? String) + .map { UIColor(nativeListHex: $0, fallback: fallbackColor) } + ?? fallbackColor + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = button.titleLabel?.textAlignment ?? .natural + if let alignmentName = style["alignment"] as? String { + paragraph.alignment = marketTextAlignment(alignmentName) + button.contentHorizontalAlignment = alignmentName == "start" + ? .leading + : alignmentName == "end" ? .trailing : .center + } + var attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraph, + ] + if style["lineHeight"] != nil { + let box = CGFloat(style.double("lineHeight")) + paragraph.minimumLineHeight = box + paragraph.maximumLineHeight = box + attributes[.baselineOffset] = max(0, (box - font.lineHeight) / 2) + } + if style["lines"] != nil { + button.titleLabel?.numberOfLines = min(2, max(1, style.int("lines", default: 1))) + } + button.titleLabel?.font = font + button.setAttributedTitle( + NSAttributedString(string: text, attributes: attributes), + for: .normal + ) + } + private func applyMarketTextStyle( _ label: UILabel, data: [String: Any]?, From 5c6f59d9d8443854f6c62fd2d73e0c75222b67aa Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 16 Sep 2026 09:45:43 +0800 Subject: [PATCH 04/71] feat(example): add the Native List Row Style page The row style now exists on all three platforms but only the Web half has automated coverage, so the native halves need something to check against. This page is that artifact. Every template is rendered twice - plain, then styled - behind a toggle. With the toggle off the whole page must match the build from before the row style existed, which is the regression check that matters most: unstyled rows must not move by a pixel. The rows deliberately cover what the field-to-view mapping is most likely to get wrong. metricCard styles `value` and `title` separately, and the two must land on the large number and the small label respectively even though they share views with identity's title and subtitle. message styles `body` and `time`, which are carried by the subtitle and status views. One identity row grows its text and gives an explicit height, because heights are not derived from the style. Also link STYLE_SPEC.md from DESIGN.md, so the architecture document points at the style vocabulary. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../pages/NativeListRowStylePage.tsx | 247 ++++++++++++++++++ example/react-native/route.tsx | 14 + .../react-native-native-list/docs/DESIGN.md | 6 + .../docs/STYLE_SPEC.md | 4 + 5 files changed, 272 insertions(+) create mode 100644 example/react-native/pages/NativeListRowStylePage.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ea62d184..bb8fae6cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file. - **native-list**: Add `docs/STYLE_SPEC.md`, the shared style vocabulary for rows, section headers, fixed footers, and empty states. It records the design tokens (aliased to the application's own token names), the per-template style surface keyed by model field, list chrome, the template isolation rules, and a review checklist. Cross-platform divergences — row-height tables, typography, the Android sticky-header renderer, list-wide source scale — are registered rather than changed. ### Chores +- Add the "Native List Row Style" example page. Every template is rendered twice, plain and styled, behind a toggle; with the toggle off the page must match the build from before the row style existed, which makes it the cross-platform regression check. It also covers the cases the mapping is most likely to get wrong: `metricCard` styling `value` versus `title`, and `message` styling `body` and `time`. - **native-list (Web)**: Remove dead duplicated rules from `WEB_LIST_CSS`. Four blocks (`.ok-native-list-footer`/`-sticky`/`-index-rail`/`-index-button`, `-refresh`, `-warning`, `-subtitle-segments`) were emitted twice and a `prefers-reduced-motion` block three times; every property of the earlier copies was redeclared by the later ones, so removing them changes no rendering. ## [3.0.156] - 2026-09-21 diff --git a/example/react-native/pages/NativeListRowStylePage.tsx b/example/react-native/pages/NativeListRowStylePage.tsx new file mode 100644 index 000000000..a21966fac --- /dev/null +++ b/example/react-native/pages/NativeListRowStylePage.tsx @@ -0,0 +1,247 @@ +import { useMemo, useState } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { + NativeList, + type NativeListSnapshot, + type NativeListTheme, + type RowModel, +} from '@onekeyfe/react-native-native-list'; + +// docs/STYLE_SPEC.md section 3.1 names; the list keys are the legacy aliases. +const THEME: NativeListTheme = { + background: '#F5F5F5', + rowBackground: '#FFFFFF', + rowSelectedBackground: '#F0F0F0', + rowPressedBackground: '#E8E8E8', + subduedBackground: '#F9F9F9', + strongBackground: '#F0F0F0', + primaryText: '#202020', + secondaryText: '#646464', + disabledText: '#8D8D8D', + icon: '#646464', + iconSubdued: '#8D8D8D', + separator: '#E0E0E0', + accent: '#108303', + positive: '#218358', + negative: '#CE2C31', + inverseBackground: '#202020', + inverseText: '#FCFCFC', + info: '#0D74CE', +}; + +const header = (key: string, title: string, note: string): RowModel => ({ + type: 'sectionHeader', + key: `header-${key}`, + sectionKey: key, + title, + value: note, +}); + +/** + * Each template appears twice: once untouched, once styled. With the toggle off + * every row is untouched, so the whole page should be pixel-identical to the + * build before the row style existed - that is the regression check. + */ +function buildRows(styled: boolean): RowModel[] { + const rows: RowModel[] = []; + + rows.push(header('identity', 'identity', 'plain / styled')); + const identity = { + type: 'identity', + sectionKey: 'identity', + leading: { kind: 'icon', name: 'StarOutline' }, + title: 'Bitcoin', + subtitle: 'BTC · Bitcoin', + badges: [{ key: 'tag', text: 'Native' }], + trailing: [{ kind: 'value' as const, text: '$64,230' }], + } as const; + rows.push({ ...identity, key: 'identity-plain' }); + rows.push({ + ...identity, + key: 'identity-styled', + ...(styled + ? { + style: { + // A named step from the application's scale, resolved to numbers in + // JavaScript before the snapshot is serialized. + title: { token: '$bodyMd' as const }, + subtitle: { fontSize: 12, lineHeight: 16, color: '#8D8D8D' }, + badge: { fontSize: 10 }, + value: { token: '$bodySm' as const }, + horizontalPadding: 20, + lineGap: 2, + }, + } + : {}), + }); + + // The mapping is not one to one: metricCard draws `value` through the view + // identity uses for `title`. Styling `value` must hit the large number. + rows.push(header('metric', 'metricCard', 'value vs title')); + const metric = { + type: 'metricCard', + sectionKey: 'metric', + title: 'Volume', + value: '$1.24B', + subtitle: '24h', + trend: '+2.4%', + trendTone: 'positive', + } as const; + rows.push({ ...metric, key: 'metric-plain' }); + rows.push({ + ...metric, + key: 'metric-styled', + ...(styled + ? { + style: { + title: { fontSize: 10, color: '#8D8D8D' }, + value: { token: '$headingLg' as const, color: '#108303' }, + trend: { fontSize: 11 }, + }, + } + : {}), + }); + + rows.push(header('message', 'message', 'body / time')); + const message = { + type: 'message', + sectionKey: 'message', + title: 'Transfer confirmed', + body: 'Your transfer of 0.5 BTC has been confirmed on-chain.', + bodyLines: 2, + time: '2m', + } as const; + rows.push({ ...message, key: 'message-plain' }); + rows.push({ + ...message, + key: 'message-styled', + ...(styled + ? { + style: { + title: { token: '$bodyMd' as const }, + body: { fontSize: 12, lineHeight: 16 }, + time: { fontSize: 10, color: '#8D8D8D' }, + }, + } + : {}), + }); + + rows.push(header('rail', 'rail', 'title / status / badge')); + const rail = { + type: 'rail', + sectionKey: 'rail', + visual: { kind: 'icon', name: 'StarOutline' }, + title: 'ETH', + status: 'online', + badge: { key: 'change', text: '+1.2%', tone: 'success' }, + } as const; + rows.push({ ...rail, key: 'rail-plain' }); + rows.push({ + ...rail, + key: 'rail-styled', + ...(styled + ? { style: { title: { fontSize: 11 }, status: { fontSize: 10 } } } + : {}), + }); + + rows.push(header('action', 'action', 'title')); + const action = { + type: 'action', + sectionKey: 'action', + title: 'Add custom token', + actionKey: 'demo.add', + tone: 'primary', + } as const; + rows.push({ ...action, key: 'action-plain' }); + rows.push({ + ...action, + key: 'action-styled', + ...(styled + ? { style: { title: { token: '$bodyMd' as const, color: '#0D74CE' } } } + : {}), + }); + + // Growing text needs an explicit height: row heights are not derived from the + // style. See docs/STYLE_SPEC.md section 6.1. + rows.push(header('height', 'explicit height', 'larger text')); + rows.push({ + type: 'identity', + key: 'identity-grown', + sectionKey: 'height', + leading: { kind: 'icon', name: 'StarOutline' }, + title: 'Larger title', + subtitle: 'Row height is given, not inferred', + ...(styled + ? { + height: 76, + style: { + title: { token: '$headingMd' as const }, + subtitle: { token: '$bodyMd' as const }, + verticalPadding: 12, + }, + } + : {}), + }); + + return rows; +} + +export function NativeListRowStylePage() { + const [styled, setStyled] = useState(true); + const snapshot = useMemo( + () => ({ + schemaVersion: 1, + generation: styled ? 2 : 1, + layout: { kind: 'sectioned', stickyHeaders: true, contentPadding: 8 }, + theme: THEME, + rows: buildRows(styled), + }), + [styled], + ); + + return ( + + + + Every template appears twice: plain, then styled. Turn the style off + and the page should match the build before the row style existed. + + setStyled(current => !current)} + style={({ pressed }) => [styles.button, pressed && styles.pressed]} + > + + {styled ? 'Style: on' : 'Style: off'} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#F5F5F5' }, + bar: { + paddingHorizontal: 16, + paddingTop: 12, + paddingBottom: 10, + gap: 10, + backgroundColor: '#FFFFFF', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#E0E0E0', + }, + caption: { fontSize: 13, lineHeight: 18, color: '#646464' }, + button: { + alignSelf: 'flex-start', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + backgroundColor: '#202020', + }, + pressed: { opacity: 0.8 }, + buttonLabel: { color: '#FCFCFC', fontSize: 14, fontWeight: '600' }, + list: { flex: 1 }, +}); diff --git a/example/react-native/route.tsx b/example/react-native/route.tsx index 5f0be9206..be06d1dcf 100644 --- a/example/react-native/route.tsx +++ b/example/react-native/route.tsx @@ -30,6 +30,7 @@ import type { AccountSelectorInitialTargetInput } from './pages/nativeListAccoun import { NativeListNetworkSelectorPage } from './pages/NativeListNetworkSelectorPage'; import { NativeListTokenSelectorPage } from './pages/NativeListTokenSelectorPage'; import { NativeListWalletSidebarReorderPage } from './pages/NativeListWalletSidebarReorderPage'; +import { NativeListRowStylePage } from './pages/NativeListRowStylePage'; import type { MarketSearchParams } from './pages/MarketNativePagerExamplePage'; import { NativeListExamplePage, @@ -88,6 +89,7 @@ export type RootStackParamList = { NativeListNetworkSelector: undefined; NativeListTokenSelector: undefined; NativeListWalletSidebarReorder: undefined; + NativeListRowStyle: undefined; MarketNativePager: undefined; MarketSearch: MarketSearchParams; OtaPipeline: undefined; @@ -298,6 +300,13 @@ const modules: { 'OK-62492 recorded wallet sidebar for iOS wallet group drag reorder', icon: '🗂️', }, + { + screen: 'NativeListRowStyle', + name: 'Native List Row Style', + description: + 'Every template plain and styled, with a toggle for the unstyled regression check', + icon: '🎚️', + }, { screen: 'PagerView', name: 'Pager View', @@ -700,6 +709,11 @@ export function AppNavigator() { // The sidebar sits on the left edge; edge-swipe back would steal slow drags. options={{ headerShown: false, gestureEnabled: false }} /> + Date: Wed, 16 Sep 2026 10:13:25 +0800 Subject: [PATCH 05/71] feat(native-list): add listStyle for separator and group chrome snapshot.listStyle carries the list chrome that sits outside a row: separator.inset, separator.color and groupCornerRadius, applied on Web, iOS and Android. The surface is deliberately small. It carries only what all three platforms can honour, and validateSnapshot rejects any other key rather than accepting one that some platform would quietly ignore. Pull to refresh is a system control on both native platforms and only Web draws its own indicator; the section index rail belongs to capabilities.sectionIndex and is three independent constant sets today; the reorder preview and count badge are drawn with platform-specific primitives, including a Canvas badge inside Android's dispatchDraw; and content padding and item spacing already live on layout, so repeating them here would give one value two homes. docs/STYLE_SPEC.md section 5 records all of that. Every value is absent by default and each platform keeps its own number as the fallback, so an untouched list renders exactly as before. Web needed care for that: an inset separator keeps the transparent border-bottom so the row's height does not change, and paints the visible line with an inset-inline-start overlay so it also follows RTL, which the border could not. Chrome lives outside the row payload, so a changed listStyle has to reach rows whose own content is unchanged. Android routes it through the existing theme rebind flag and iOS folds it into the themeChanged comparison and into both snapshot fast-path guards. The example page gains a listStyle section: a row with an inset separator and a two-row grouped card, both driven by the snapshot rather than by the rows. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../pages/NativeListRowStylePage.tsx | 36 +++++++++++ .../onekey/nativelist/NativeListAdapter.kt | 8 +++ .../com/onekey/nativelist/NativeListModels.kt | 2 + .../onekey/nativelist/NativeListRowView.kt | 25 +++++++- .../com/onekey/nativelist/NativeListView.kt | 2 + .../docs/STYLE_SPEC.md | 30 +++++++++- .../ios/NativeListCell.swift | 20 ++++++- .../ios/NativeListModels.swift | 2 + .../ios/RNCNativeListView.swift | 6 ++ .../src/__tests__/NativeList.web.test.ts | 14 +++++ .../src/__tests__/validation.test.ts | 29 +++++++++ .../react-native-native-list/src/models.ts | 23 ++++++++ .../src/validation.ts | 59 +++++++++++++++++++ .../src/web/NativeListWebEngine.ts | 36 +++++++++-- 15 files changed, 281 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb8fae6cc..8d4115347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Features +- **native-list**: Add `snapshot.listStyle` for list chrome: `separator.inset`, `separator.color`, and `groupCornerRadius`, applied on all three platforms. The surface carries only what every platform can honour — pull to refresh is a system control on both native platforms, the section index belongs to `capabilities.sectionIndex`, and the reorder preview is drawn with platform-specific primitives — and `validateSnapshot` rejects any other key rather than accepting one that some platform would ignore. Every value is absent by default and each platform keeps its own number as the fallback, so an untouched list renders exactly as before; on Web the inset separator keeps a transparent border for layout and paints the line with a logical-inset overlay, so row heights do not move. - **native-list (iOS and Android)**: Apply the row style natively. A style key is mapped to the view that renders that model field by `nativeListStyleSlot` (Kotlin, unit-tested) and `styleSlot` (Swift), because the view pool is shared and the mapping is not one to one. Both platforms gained a `resetRowStyle` that runs before the binder: Android's `resetViews` restores neither `textSize`, `typeface` nor `lineHeight`, and iOS's `reset` misses the data, metric and media label fonts, so without it a style would leak into the next row reusing the view. On iOS the pass rebuilds the attributed string, because the binders install one through `setLineHeight` and a plain `font` assignment would not take effect. - **native-list**: Add a bounded per-template row style. `style` keys name the model field they modify rather than the view that carries it, so `metricCard.style.value` reaches the large number even though it shares a view with `identity.style.title`. Typography can be given as a named step (`{ token: '$bodyLg' }`) which is resolved to numbers in JavaScript before serialization, so no native renderer learns the vocabulary; explicit values still override. Keys a template does not declare are rejected by `validateSnapshot` and `validatePatches`, and styles carried by a patch are resolved the same way. Implemented on Web: text slots for every template plus `horizontalPadding`, `verticalPadding`, and `lineGap`. Row heights are unchanged — a styled row that grows still needs an explicit `height`. diff --git a/example/react-native/pages/NativeListRowStylePage.tsx b/example/react-native/pages/NativeListRowStylePage.tsx index a21966fac..fecf229be 100644 --- a/example/react-native/pages/NativeListRowStylePage.tsx +++ b/example/react-native/pages/NativeListRowStylePage.tsx @@ -161,6 +161,37 @@ function buildRows(styled: boolean): RowModel[] { : {}), }); + // listStyle is chrome, not a row: the separator inset and the group card + // radius below come from the snapshot, not from these rows. + rows.push(header('chrome', 'listStyle', 'separator / group')); + rows.push({ + type: 'identity', + key: 'chrome-separator', + sectionKey: 'chrome', + leading: { kind: 'icon', name: 'StarOutline' }, + title: 'Separator inset', + subtitle: 'Inset comes from listStyle.separator', + separator: true, + }); + rows.push({ + type: 'identity', + key: 'chrome-group-first', + sectionKey: 'chrome', + groupId: 'chrome-card', + groupPosition: 'first', + leading: { kind: 'icon', name: 'StarOutline' }, + title: 'Grouped card, first', + }); + rows.push({ + type: 'identity', + key: 'chrome-group-last', + sectionKey: 'chrome', + groupId: 'chrome-card', + groupPosition: 'last', + leading: { kind: 'icon', name: 'StarOutline' }, + title: 'Grouped card, last', + }); + // Growing text needs an explicit height: row heights are not derived from the // style. See docs/STYLE_SPEC.md section 6.1. rows.push(header('height', 'explicit height', 'larger text')); @@ -194,6 +225,11 @@ export function NativeListRowStylePage() { generation: styled ? 2 : 1, layout: { kind: 'sectioned', stickyHeaders: true, contentPadding: 8 }, theme: THEME, + // Absent by default, so the chrome falls back to each platform's own + // numbers - which are not the same everywhere, see STYLE_SPEC section 6.2. + listStyle: styled + ? { separator: { inset: 20 }, groupCornerRadius: 16 } + : undefined, rows: buildRows(styled), }), [styled], diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt index 41a5b5344..aa80dbbe8 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt @@ -52,6 +52,13 @@ internal class NativeListAdapter( if (field?.toString() != value?.toString()) needsThemeRebind = true field = value } + // Reuses the theme rebind path: chrome lives outside the row payload, so a + // changed list style would otherwise not reach rows DiffUtil considers equal. + var listStyle: JSONObject? = null + set(value) { + if (field?.toString() != value?.toString()) needsThemeRebind = true + field = value + } var layout: String = "linear" var orientation: String = "vertical" var selectedKeys: Set = emptySet() @@ -79,6 +86,7 @@ internal class NativeListAdapter( override fun onBindViewHolder(holder: NativeListViewHolder, position: Int) { val item = itemAt(position) ?: return + holder.rowView.listStyle = listStyle holder.rowView.bind( item, theme, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index 52ae588fe..99fe28997 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -212,6 +212,7 @@ internal data class NativeListConfig( val sectionIndexHapticsEnabled: Boolean, val sectionIndexCenteredInWindow: Boolean, val theme: JSONObject?, + val listStyle: JSONObject?, val fixedFooter: NativeListItem?, val items: List, ) { @@ -261,6 +262,7 @@ internal data class NativeListConfig( sectionIndexHapticsEnabled = sectionIndex?.optBoolean("hapticsEnabled", true) ?: true, sectionIndexCenteredInWindow = sectionIndex?.optBoolean("centeredInWindow", false) ?: false, theme = root.optJSONObject("theme"), + listStyle = root.optJSONObject("listStyle"), fixedFooter = fixedFooter, items = items, ) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index 388d619f8..2369823f7 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -542,6 +542,12 @@ internal class NativeListRowView( private val separatorPaint = Paint(Paint.ANTI_ALIAS_FLAG) private var showsSeparator = false + /** + * docs/STYLE_SPEC.md section 5. Set by the adapter before bind, so the binder and + * groupedBackground can read it without another parameter. + */ + var listStyle: JSONObject? = null + var onRowPress: ((NativeListItem, NativeListActionOrigin) -> Unit)? = null var onAction: ((NativeListItem, String, NativeSelectionTarget?, NativeListActionOrigin?) -> Unit)? = null var onBindingInvalidated: ((NativeListRowView, Long) -> Unit)? = null @@ -722,7 +728,14 @@ internal class NativeListRowView( override fun dispatchDraw(canvas: Canvas) { super.dispatchDraw(canvas) if (showsSeparator) { - val start = if ((tag as? NativeListItem)?.type == "identity") dp(60).toFloat() else dp(12).toFloat() + val separatorStyle = listStyle?.optJSONObject("separator") + val start = if (separatorStyle?.has("inset") == true) { + dp(separatorStyle.optDouble("inset").roundToInt()).toFloat() + } else if ((tag as? NativeListItem)?.type == "identity") { + dp(60).toFloat() + } else { + dp(12).toFloat() + } canvas.drawLine(start, height - 1f, width.toFloat(), height - 1f, separatorPaint) } if (reorderActive && (tag as? NativeListItem)?.type == "walletGroup" && walletGroupDragChildCount > 0) { @@ -979,7 +992,10 @@ internal class NativeListRowView( status.setTextColor(secondary) metricSubtitle.setTextColor(secondary) badgeLine.setTextColor(accent) - separatorPaint.color = color(theme, "separator", "#0000001F") + separatorPaint.color = safeColor( + listStyle?.optJSONObject("separator")?.optString("color"), + color(theme, "separator", "#0000001F"), + ) separatorPaint.strokeWidth = 1f showsSeparator = item.json.optBoolean("separator", false) && !item.key.startsWith("token-") && @@ -1725,6 +1741,7 @@ internal class NativeListRowView( memberRow.onBindingInvalidated = { row, epoch -> onBindingInvalidated?.invoke(row, epoch) } + memberRow.listStyle = listStyle memberRow.bind( member, theme, @@ -4440,7 +4457,9 @@ internal class NativeListRowView( private fun groupedBackground(position: String, color: Int) = GradientDrawable().apply { setColor(color) - val radius = scaledDp(12f) + val radius = listStyle?.takeIf { it.has("groupCornerRadius") } + ?.let { scaledDp(it.optDouble("groupCornerRadius").toFloat()) } + ?: scaledDp(12f) cornerRadii = when (position) { "first" -> floatArrayOf(radius, radius, radius, radius, 0f, 0f, 0f, 0f) "last" -> floatArrayOf(0f, 0f, 0f, 0f, radius, radius, radius, radius) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt index c4299d5c9..3e2f69547 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt @@ -500,6 +500,7 @@ class NativeListView( usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale } adapter.usesSelectorSourceScale = usesSelectorSourceScale adapter.theme = next.theme + adapter.listStyle = next.listStyle adapter.layout = next.layout adapter.orientation = next.orientation adapter.selectedKeys = next.selectedKeys @@ -1381,6 +1382,7 @@ class NativeListView( footerView.recycle() } else { footerView.visibility = VISIBLE + footerView.listStyle = next.listStyle footerView.bind( footer, next.theme, diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index 476f5d38c..b05d929cd 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -55,6 +55,7 @@ object identity. | Contract, validation, token resolution, patch support | Implemented | | Text slots, `horizontalPadding`, `verticalPadding`, `lineGap` | Implemented on Web, iOS and Android | | `leadingGap`, `trailingGap`, `titleBadgeGap`, `image` | Validated but applied only on `market`. The others need a per-template default gap, which is not readable from the DOM on Web and is spread across the binders on the native sides | +| `listStyle.separator` and `listStyle.groupCornerRadius` | Implemented on Web, iOS and Android | | `dataRow` columns in the `table` layout | Not applied. The text lives inside `NativeListTableColumnView` / `tableDataColumns`, which own their own labels; the `linear` layout is covered | | Row heights | Unchanged. A styled row that grows still needs an explicit `row.height` (§6.1) | | Android list-wide source scale (§6.3) | Unchanged. Making it per-row would move every selector list's metrics and needs device verification | @@ -330,8 +331,8 @@ not yet implemented). | --- | --- | --- | --- | --- | | Content padding | yes (`layout.contentPadding*`) | `contentInset` | `setPadding` | `paddingValues` | | Item spacing | yes (`layout.itemSpacing`) | flow layout spacing | `ItemSpacingDecoration` | layout gap | -| Separator | partial (`row.separator`) | 1/scale, inset 60/12 | 1px, inset dp(60)/dp(12) | `1px`, class rule | -| Group card radius | no | 20 / 12 | dp(12) | 12px | +| Separator | **yes** — `listStyle.separator.{inset,color}` | 1/scale, inset 60/12 | 1px, inset dp(60)/dp(12) | `1px`, class rule, **no inset** | +| Group card radius | **yes** — `listStyle.groupCornerRadius` | 20 / 12 | dp(12) | 12px | | Section index rail | partial (`capabilities.sectionIndex`) | static constants, label 10 | `SECTION_INDEX_*_DP`, raw density | `SECTION_INDEX_*` + CSS | | Section index preview | no | 48×48 r14, 22 semibold | preview w/h/margin constants | 48px r14, 22px | | Pull to refresh | partial (`capabilities.pullToRefresh`) | `UIRefreshControl` | `SwipeRefreshLayout` | custom pill, 12px | @@ -341,6 +342,25 @@ not yet implemented). The section index rail is three separate constant sets for one control; Android's `sectionIndexDp()` deliberately never follows the source-scale switch. +`listStyle` carries only what all three platforms can honour. The rest of this table +is deliberately excluded rather than declared and half-implemented: + +- **Pull to refresh** is a system control on both native platforms (`UIRefreshControl`, + `SwipeRefreshLayout`); only Web draws its own indicator. +- **Section index rail and preview** belong to `capabilities.sectionIndex`, which is + where their geometry should go if it is ever exposed, and are three independent + constant sets today. +- **Reorder preview and count badge** are drawn with platform-specific primitives — + Android paints the badge on `Canvas` inside `dispatchDraw`, Web uses a CSS overlay, + and iOS has neither. +- **Content padding and item spacing** are already `layout.contentPadding*` and + `layout.itemSpacing`; duplicating them here would give one value two homes. + +Every `listStyle` value is absent by default, and each platform keeps its own number +as the fallback, so an untouched list renders exactly as before. On Web that required +the inset separator to keep the transparent `border-bottom` for layout and paint the +visible line with a logical-inset overlay, rather than moving the row by a pixel. + ## 6. T4 — Known divergences Registered, **not** fixed by this document. Changing any of these requires a PR @@ -382,6 +402,12 @@ here and left alone. | `metricCard.value` | 18 semibold | sp(18) semibold | **22 / 28 / 700** | | visual `rounded` radius | `min(10, h/4)` | `min(10, size/4)`; 8 for accountSelector | **10px**; 8 `!important` for account action; 8 for market | | `walletSidebar` row radius | **20** | 12 | 12 | +| separator default inset | 60 identity / 12 | dp(60) identity / dp(12) | **0** | +| separator thickness | `1 / scale` (hairline) | `1f` raw px | `1px` | + +Separator inset and colour are now settable through `listStyle.separator` (§5); the +defaults above are what a list gets when it does not set them. Thickness is not +exposed — a hairline is correct on iOS and a whole pixel is correct elsewhere. ### 6.3 Behavioral diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index 7789e68c0..8741c471b 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -556,6 +556,10 @@ final class NativeListCell: UICollectionViewCell { private var selectorImageRetries: [ObjectIdentifier: DispatchWorkItem] = [:] private(set) var bindingEpoch = 0 + /// docs/STYLE_SPEC.md §5. Set by the list before `bind`, so the row binder and + /// `applyGroupPosition` can read it without another parameter. + var listStyle: [String: Any]? + var onAction: ((NativeListItem, String, NativeSelectionTarget?, NativeListActionOrigin?) -> Void)? var onBindingInvalidated: ((NativeListCell, Int) -> Void)? @@ -953,8 +957,14 @@ final class NativeListCell: UICollectionViewCell { mediaBadgeLabel.backgroundColor = nativeListColor(theme, "inverseBackground", "#202020") mediaBadgeLabel.textColor = nativeListColor(theme, "inverseText", "#FCFCFC") mediaBadgeLabel.layer.borderColor = nativeListColor(theme, "rowBackground", "#FFFFFF").cgColor - separatorView.backgroundColor = nativeListColor(theme, "separator", "#E0E0E0") - separatorLeadingConstraint.constant = item.type == "identity" ? 60 : 12 + let separatorStyle = listStyle?.dictionary("separator") + let separatorColor = nativeListColor(theme, "separator", "#E0E0E0") + separatorView.backgroundColor = (separatorStyle?["color"] as? String) + .map { UIColor(nativeListHex: $0, fallback: separatorColor) } + ?? separatorColor + separatorLeadingConstraint.constant = separatorStyle?["inset"] == nil + ? (item.type == "identity" ? 60 : 12) + : CGFloat(separatorStyle?.double("inset") ?? 0) separatorView.isHidden = !item.data.bool("separator") restingBackgroundColor = selectionBackgroundColor( item: item, @@ -1548,6 +1558,7 @@ final class NativeListCell: UICollectionViewCell { memberCell.onBindingInvalidated = { [weak self] cell, epoch in self?.onBindingInvalidated?(cell, epoch) } + memberCell.listStyle = listStyle memberCell.bind( item: member, theme: theme, @@ -1559,6 +1570,7 @@ final class NativeListCell: UICollectionViewCell { rootStack.addArrangedSubview(memberCell) } if let parent = walletGroupMembers.first { + walletGroupCompactCell?.listStyle = listStyle walletGroupCompactCell?.bind( item: parent, theme: theme, @@ -4417,7 +4429,9 @@ final class NativeListCell: UICollectionViewCell { } let isWalletSidebar = currentItem?.type == "identity" && currentItem?.data.string("presentation") == "walletSidebar" - layer.cornerRadius = isWalletSidebar ? 20 : 12 + layer.cornerRadius = listStyle?["groupCornerRadius"] == nil + ? (isWalletSidebar ? 20 : 12) + : CGFloat(listStyle?.double("groupCornerRadius") ?? 12) layer.cornerCurve = isWalletSidebar ? .continuous : .circular layer.masksToBounds = true } diff --git a/native-views/react-native-native-list/ios/NativeListModels.swift b/native-views/react-native-native-list/ios/NativeListModels.swift index 195713a1a..9b37268eb 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -70,6 +70,7 @@ struct NativeListConfig { let sectionIndexHapticsEnabled: Bool let sectionIndexCenteredInWindow: Bool let theme: [String: Any]? + let listStyle: [String: Any]? let fixedFooter: NativeListItem? var items: [NativeListItem] @@ -132,6 +133,7 @@ struct NativeListConfig { sectionIndexHapticsEnabled: sectionIndex?["hapticsEnabled"] as? Bool ?? true, sectionIndexCenteredInWindow: sectionIndex?["centeredInWindow"] as? Bool ?? false, theme: root["theme"] as? [String: Any], + listStyle: root["listStyle"] as? [String: Any], fixedFooter: footer, items: items ) diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index 06facdc01..901750c03 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -352,7 +352,10 @@ final class NativeListView: UIView { cancelInteractiveReorderForStructuralUpdate() } let oldItems = itemsByKey + // Chrome lives outside the row payload, so a changed list style must rebind + // rows whose own content is unchanged. let themeChanged = !dictionariesEqual(config?.theme, next.theme) + || !dictionariesEqual(config?.listStyle, next.listStyle) if config?.generation != next.generation { endReachedGeneration = nil } config = next itemsByKey = Dictionary(uniqueKeysWithValues: next.items.map { ($0.key, $0) }) @@ -1318,6 +1321,7 @@ final class NativeListView: UIView { private func bind(cell: NativeListCell, item: NativeListItem, itemIndex: Int? = nil) { guard let config else { return } + cell.listStyle = config.listStyle cell.bind( item: item, theme: config.theme, @@ -1470,6 +1474,7 @@ final class NativeListView: UIView { current.layout == next.layout, current.orientation == next.orientation, current.gridColumns == next.gridColumns, + dictionariesEqual(current.listStyle, next.listStyle), current.stickyHeaders == next.stickyHeaders, current.contentPadding == next.contentPadding, current.contentPaddingHorizontal == next.contentPaddingHorizontal, @@ -1517,6 +1522,7 @@ final class NativeListView: UIView { guard current.layout == next.layout, current.orientation == next.orientation, current.gridColumns == next.gridColumns, + dictionariesEqual(current.listStyle, next.listStyle), current.stickyHeaders == next.stickyHeaders, current.contentPadding == next.contentPadding, current.contentPaddingHorizontal == next.contentPaddingHorizontal, diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index df0bc0199..ec594bbbd 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -628,6 +628,20 @@ describe('web row style', () => { expect(value?.style.fontWeight).toBe('700'); }); + it("keeps today's numbers as the chrome fallbacks", () => { + // An untouched list must render exactly as before, so every chrome variable + // carries the current value as its CSS fallback. + expect(WEB_LIST_CSS).toContain( + 'border-bottom:1px solid var(--nl-separator-color,var(--nl-separator))' + ); + expect(WEB_LIST_CSS).toContain('border-radius:var(--nl-group-radius,12px)'); + // The inset variant keeps the transparent border so row height is unchanged. + expect(WEB_LIST_CSS).toContain('border-bottom-color:transparent'); + expect(WEB_LIST_CSS).toContain( + 'inset-inline-start:var(--nl-separator-inset,0)' + ); + }); + it('applies box padding only when the row asks for it', () => { const base: RowModel = { type: 'identity', diff --git a/native-views/react-native-native-list/src/__tests__/validation.test.ts b/native-views/react-native-native-list/src/__tests__/validation.test.ts index 8aa3974ac..31b9de0d1 100644 --- a/native-views/react-native-native-list/src/__tests__/validation.test.ts +++ b/native-views/react-native-native-list/src/__tests__/validation.test.ts @@ -1302,6 +1302,35 @@ describe('NativeList style contract', () => { ).toThrow('is not a style key of the "identity" template'); }); + it('accepts list chrome and rejects anything outside it', () => { + const withChrome = (listStyle: unknown): NativeListSnapshot => + ({ ...snapshot(), listStyle } as NativeListSnapshot); + expect( + validateSnapshot( + withChrome({ + separator: { inset: 16, color: '#E0E0E0' }, + groupCornerRadius: 8, + }) + ).listStyle + ).toEqual({ + separator: { inset: 16, color: '#E0E0E0' }, + groupCornerRadius: 8, + }); + expect(() => + validateSnapshot(withChrome({ separator: { inset: 65 } })) + ).toThrow('separator.inset'); + expect(() => + validateSnapshot(withChrome({ groupCornerRadius: 41 })) + ).toThrow('groupCornerRadius'); + // Chrome the platforms cannot all honour is rejected rather than ignored. + expect(() => validateSnapshot(withChrome({ pullToRefresh: {} }))).toThrow( + 'is not a list style key' + ); + expect(() => + validateSnapshot(withChrome({ separator: { thickness: 2 } })) + ).toThrow('is not a separator style key'); + }); + it('keeps the Market style surface intact', () => { const [first] = validateSnapshot( snapshot([ diff --git a/native-views/react-native-native-list/src/models.ts b/native-views/react-native-native-list/src/models.ts index bfa396db2..10de9daaf 100644 --- a/native-views/react-native-native-list/src/models.ts +++ b/native-views/react-native-native-list/src/models.ts @@ -654,6 +654,28 @@ export type SectionIndexConfig = Readonly<{ centeredInWindow?: boolean; }>; +export type NativeListSeparatorStyle = Readonly<{ + /** + * Leading inset in logical pixels. Omitted keeps each platform's own default, + * which is not the same everywhere — see docs/STYLE_SPEC.md §6.2. + */ + inset?: number; + /** Overrides the `separator` theme token for this list only. */ + color?: string; +}>; + +/** + * List chrome that every platform can honour. Pull to refresh, the section index + * rail and the reorder preview are deliberately absent: the first two are system + * controls or three independent constant sets, and the third is drawn with + * platform-specific primitives. See docs/STYLE_SPEC.md §5. + */ +export type NativeListListStyle = Readonly<{ + separator?: NativeListSeparatorStyle; + /** Corner radius of a `groupId` card. Does not affect rail or media tiles. */ + groupCornerRadius?: number; +}>; + export type NativeListSnapshot = Readonly<{ schemaVersion: 1; generation: number; @@ -685,6 +707,7 @@ export type NativeListSnapshot = Readonly<{ emptyState?: ActionRow | SystemRow; fixedFooter?: ActionRow | SystemRow; theme?: NativeListTheme; + listStyle?: NativeListListStyle; }>; // OneKey patch: include selector-only mutable presentation fields. diff --git a/native-views/react-native-native-list/src/validation.ts b/native-views/react-native-native-list/src/validation.ts index 214ae76c1..32d094462 100644 --- a/native-views/react-native-native-list/src/validation.ts +++ b/native-views/react-native-native-list/src/validation.ts @@ -5,6 +5,7 @@ import type { LeadingVisual, MarketRow, MarketRowStyle, + NativeListListStyle, NativeListSnapshot, NativeListTextStyle, NativeListTypographyToken, @@ -347,6 +348,62 @@ function assertRowStyle(row: RowModel, path: string): void { ); } +const LIST_STYLE_KEYS: readonly string[] = ['separator', 'groupCornerRadius']; +const SEPARATOR_STYLE_KEYS: readonly string[] = ['inset', 'color']; + +function assertUnknownKeys( + value: Record, + allowed: readonly string[], + path: string, + subject: string +): void { + Object.keys(value).forEach((key) => { + if (!allowed.includes(key)) fail(`${path}.${key}`, `is not a ${subject}`); + }); +} + +function assertListStyle( + listStyle: NativeListListStyle | undefined, + path: string +): void { + if (listStyle === undefined) return; + if ( + typeof listStyle !== 'object' || + listStyle === null || + Array.isArray(listStyle) + ) { + fail(path, 'must be an object'); + } + assertUnknownKeys( + listStyle as Record, + LIST_STYLE_KEYS, + path, + 'list style key' + ); + assertBoundedStyleNumber( + listStyle.groupCornerRadius, + `${path}.groupCornerRadius`, + 0, + 40 + ); + const separator = listStyle.separator; + if (separator === undefined) return; + if ( + typeof separator !== 'object' || + separator === null || + Array.isArray(separator) + ) { + fail(`${path}.separator`, 'must be an object'); + } + assertUnknownKeys( + separator as Record, + SEPARATOR_STYLE_KEYS, + `${path}.separator`, + 'separator style key' + ); + assertBoundedStyleNumber(separator.inset, `${path}.separator.inset`, 0, 64); +} + /** Token resolution is idempotent: a resolved style carries no token. */ function resolveTextStyle( style: NativeListTextStyle | undefined @@ -1162,6 +1219,8 @@ export function validateSnapshot( } } + assertListStyle(snapshot.listStyle, 'snapshot.listStyle'); + const rowKeys = new Set(); const sectionIndexTitles = new Set(); snapshot.rows.forEach((row, index) => { diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index 9b9c94b05..c4b1fbfeb 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -986,10 +986,13 @@ export const WEB_LIST_CSS = ` .ok-native-list-reorder-preview>.ok-native-list-row{background:var(--nl-pressed);cursor:grabbing} .ok-native-list-reorder-preview[data-native-list-selected="true"]>.ok-native-list-row{background:var(--nl-selected)} .ok-native-list-reorder-count{position:absolute;right:4px;bottom:4px;display:flex;align-items:center;justify-content:center;box-sizing:border-box;min-width:24px;height:24px;padding:0 6px;border:1px solid var(--nl-row);border-radius:12px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:12px;line-height:22px;font-weight:600} -.ok-native-list-item[data-separator="true"]>.ok-native-list-row{border-bottom:1px solid var(--nl-separator)} -.ok-native-list-item[data-group-position="first"]>.ok-native-list-row{border-radius:12px 12px 0 0} -.ok-native-list-item[data-group-position="last"]>.ok-native-list-row{border-radius:0 0 12px 12px} -.ok-native-list-item[data-group-position="single"]>.ok-native-list-row{border-radius:12px} +.ok-native-list-item[data-separator="true"]>.ok-native-list-row{border-bottom:1px solid var(--nl-separator-color,var(--nl-separator))} +/* An inset separator keeps the transparent border so the row's height is unchanged, and paints the visible line with a logical-inset overlay so it follows RTL. */ +.ok-native-list-root[data-separator-inset="true"] .ok-native-list-item[data-separator="true"]>.ok-native-list-row{position:relative;border-bottom-color:transparent} +.ok-native-list-root[data-separator-inset="true"] .ok-native-list-item[data-separator="true"]>.ok-native-list-row::after{content:"";position:absolute;inset-inline-start:var(--nl-separator-inset,0);inset-inline-end:0;bottom:-1px;height:1px;background:var(--nl-separator-color,var(--nl-separator))} +.ok-native-list-item[data-group-position="first"]>.ok-native-list-row{border-radius:var(--nl-group-radius,12px) var(--nl-group-radius,12px) 0 0} +.ok-native-list-item[data-group-position="last"]>.ok-native-list-row{border-radius:0 0 var(--nl-group-radius,12px) var(--nl-group-radius,12px)} +.ok-native-list-item[data-group-position="single"]>.ok-native-list-row{border-radius:var(--nl-group-radius,12px)} .ok-native-list-standard{padding:8px 12px}.ok-native-list-network-row{padding:0 12px}.ok-native-list-wallet-row{padding:4px 8px;flex-direction:column;justify-content:center;gap:4px}.ok-native-list-account-row{padding:4px 12px;gap:8px} .ok-native-list-account-row .ok-native-list-visual,.ok-native-list-account-action-row .ok-native-list-visual{width:32px;height:32px;flex-basis:32px}.ok-native-list-account-row .ok-native-list-visual>img,.ok-native-list-account-row .ok-native-list-visual-main,.ok-native-list-account-action-row .ok-native-list-visual>img,.ok-native-list-account-action-row .ok-native-list-visual-main{width:32px;height:32px}.ok-native-list-account-row .ok-native-list-visual>.ok-native-list-visual-corner{width:20px;height:20px;padding:2px}.ok-native-list-account-action-row .ok-native-list-visual{border-radius:8px!important}.ok-native-list-wallet-row .ok-native-list-title{color:var(--nl-secondary);font-size:12px;line-height:16px;font-weight:400}.ok-native-list-item[data-native-list-selected="true"]>.ok-native-list-wallet-row .ok-native-list-title{color:var(--nl-primary)}.ok-native-list-account-row .ok-native-list-title{font-size:16px;line-height:20px;font-weight:400}.ok-native-list-account-row .ok-native-list-secondary{font-size:14px;line-height:20px;font-weight:400} .ok-native-list-flex{display:flex;flex:1;min-width:0;flex-direction:column;justify-content:center}.ok-native-list-title{font-size:15px;line-height:20px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ok-native-list-secondary{font-size:13px;line-height:18px;color:var(--nl-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ok-native-list-tertiary{color:var(--nl-secondary)}.ok-native-list-info{color:var(--nl-info)} @@ -4219,6 +4222,31 @@ export class NativeListWebEngine { this.indexRail.style.setProperty(name, value); this.reorderPreview.style.setProperty(name, value); }); + this.applyListStyle(); + } + + /** + * List chrome from `snapshot.listStyle`. Every value is absent by default and + * the stylesheet carries today's number as the fallback, so an untouched list + * renders exactly as before. See docs/STYLE_SPEC.md §5. + */ + private applyListStyle() { + const listStyle = this.snapshot.listStyle; + const inset = listStyle?.separator?.inset; + setData(this.root, 'separatorInset', inset !== undefined && inset > 0); + const chrome: Readonly> = { + '--nl-separator-inset': + inset === undefined ? undefined : String(inset) + 'px', + '--nl-separator-color': listStyle?.separator?.color, + '--nl-group-radius': + listStyle?.groupCornerRadius === undefined + ? undefined + : String(listStyle.groupCornerRadius) + 'px', + }; + Object.entries(chrome).forEach(([name, value]) => { + if (value === undefined) this.root.style.removeProperty(name); + else this.root.style.setProperty(name, value); + }); } private recomputeLayout = () => { From b47323b18d6f42cd36ae39636494221c52f164ea Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 15:07:01 +0800 Subject: [PATCH 06/71] docs(native-list): define illustrated templates and shared capability boundaries Document all 12 RowModel types with structural SVG diagrams, local style layout constraints, and shared container ownership for headers, footers, sections, scrolling and the indexed bar. Record source-verified implementation gaps from the consolidated PR stack. Validation: 83 NativeList tests; package and focused example/document TypeScript checks; package and focused example lint; Swift syntax parsing; document link and SVG checks plus offline visual inspection. Native SDK builds and device acceptance remain pending. --- CHANGELOG.md | 3 +- .../react-native-native-list/README.md | 6 +- .../react-native-native-list/docs/DESIGN.md | 21 +- .../docs/ROW_TEMPLATES.md | 288 ++++++++++++++++++ .../docs/STYLE_SPEC.md | 233 +++++++++++--- .../docs/images/list-capabilities.svg | 39 +++ .../docs/images/row-action.svg | 17 ++ .../docs/images/row-activity.svg | 24 ++ .../docs/images/row-data.svg | 24 ++ .../docs/images/row-identity.svg | 25 ++ .../docs/images/row-market.svg | 23 ++ .../docs/images/row-media-tile.svg | 26 ++ .../docs/images/row-message.svg | 20 ++ .../docs/images/row-metric-card.svg | 33 ++ .../docs/images/row-rail.svg | 18 ++ .../docs/images/row-section-header.svg | 19 ++ .../docs/images/row-system.svg | 30 ++ .../docs/images/row-wallet-group.svg | 26 ++ 18 files changed, 825 insertions(+), 50 deletions(-) create mode 100644 native-views/react-native-native-list/docs/ROW_TEMPLATES.md create mode 100644 native-views/react-native-native-list/docs/images/list-capabilities.svg create mode 100644 native-views/react-native-native-list/docs/images/row-action.svg create mode 100644 native-views/react-native-native-list/docs/images/row-activity.svg create mode 100644 native-views/react-native-native-list/docs/images/row-data.svg create mode 100644 native-views/react-native-native-list/docs/images/row-identity.svg create mode 100644 native-views/react-native-native-list/docs/images/row-market.svg create mode 100644 native-views/react-native-native-list/docs/images/row-media-tile.svg create mode 100644 native-views/react-native-native-list/docs/images/row-message.svg create mode 100644 native-views/react-native-native-list/docs/images/row-metric-card.svg create mode 100644 native-views/react-native-native-list/docs/images/row-rail.svg create mode 100644 native-views/react-native-native-list/docs/images/row-section-header.svg create mode 100644 native-views/react-native-native-list/docs/images/row-system.svg create mode 100644 native-views/react-native-native-list/docs/images/row-wallet-group.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d4115347..ecf5105b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,11 @@ All notable changes to this project will be documented in this file. - **native-list**: Add a bounded per-template row style. `style` keys name the model field they modify rather than the view that carries it, so `metricCard.style.value` reaches the large number even though it shares a view with `identity.style.title`. Typography can be given as a named step (`{ token: '$bodyLg' }`) which is resolved to numbers in JavaScript before serialization, so no native renderer learns the vocabulary; explicit values still override. Keys a template does not declare are rejected by `validateSnapshot` and `validatePatches`, and styles carried by a patch are resolved the same way. Implemented on Web: text slots for every template plus `horizontalPadding`, `verticalPadding`, and `lineGap`. Row heights are unchanged — a styled row that grows still needs an explicit `height`. ### Documentation +- **native-list**: Consolidate the row-style spec on current main. Define all 12 row types with structural diagrams, local style/layout boundaries, and shared header/footer, section, scroll and indexed-bar ownership. Record implementation and example-coverage gaps separately from the contract. - **native-list**: Add `docs/STYLE_SPEC.md`, the shared style vocabulary for rows, section headers, fixed footers, and empty states. It records the design tokens (aliased to the application's own token names), the per-template style surface keyed by model field, list chrome, the template isolation rules, and a review checklist. Cross-platform divergences — row-height tables, typography, the Android sticky-header renderer, list-wide source scale — are registered rather than changed. ### Chores -- Add the "Native List Row Style" example page. Every template is rendered twice, plain and styled, behind a toggle; with the toggle off the page must match the build from before the row style existed, which makes it the cross-platform regression check. It also covers the cases the mapping is most likely to get wrong: `metricCard` styling `value` versus `title`, and `message` styling `body` and `time`. +- Add the "Native List Row Style" example page with plain/styled pairs for identity, metricCard, message, rail and action, plus list chrome and explicit-height examples. Full template/variant coverage and native visual acceptance remain pending; see `docs/STYLE_SPEC.md`. - **native-list (Web)**: Remove dead duplicated rules from `WEB_LIST_CSS`. Four blocks (`.ok-native-list-footer`/`-sticky`/`-index-rail`/`-index-button`, `-refresh`, `-warning`, `-subtitle-segments`) were emitted twice and a `prefers-reduced-motion` block three times; every property of the earlier copies was redeclared by the later ones, so removing them changes no rendering. ## [3.0.156] - 2026-09-21 diff --git a/native-views/react-native-native-list/README.md b/native-views/react-native-native-list/README.md index 3c9da2ebe..8c9da9435 100644 --- a/native-views/react-native-native-list/README.md +++ b/native-views/react-native-native-list/README.md @@ -108,6 +108,7 @@ second delta. | Row type | Intended template | | --------------- | ---------------------------------------------------------- | | `identity` | Asset, account, wallet, network, or settings identity | +| `walletGroup` | Parent and child wallet-sidebar identity rows in one group | | `rail` | Compact horizontal item with optional native drag support | | `activity` | Transaction/activity with amounts and up to three actions | | `message` | Notification/message with bounded body lines and thumbnail | @@ -403,5 +404,6 @@ use Instruments or Android Studio Profiler for native frame and memory data. See [docs/DESIGN.md](docs/DESIGN.md) for the architecture and ownership model, and [docs/STYLE_SPEC.md](docs/STYLE_SPEC.md) for the shared style vocabulary: design -tokens, the per-template style surface with each platform's current values, and the -registered cross-platform divergences. +tokens, local layout-safety rules, common list capabilities and current implementation +gaps. [docs/ROW_TEMPLATES.md](docs/ROW_TEMPLATES.md) defines all 12 row types with +structural illustrations, required fields, variants and styling boundaries. diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index 2f0890717..74234423e 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -8,8 +8,9 @@ selection feedback, image request cancellation, and scrolling. JavaScript sends normalized plain data; rows never contain a `ReactNode`, function, or arbitrary style object. -The component supports `identity`, `rail`, `activity`, `message`, `dataRow`, -`mediaTile`, `metricCard`, `sectionHeader`, `action`, and `system` rows. +The component supports 12 row types: `identity`, `walletGroup`, `rail`, `activity`, +`message`, `dataRow`, `market`, `mediaTile`, `metricCard`, `sectionHeader`, `action`, +and `system`. [ROW_TEMPLATES.md](ROW_TEMPLATES.md) defines each with an illustration. `metricCard` supports a compact KPI template and the fixed `activity` and `performance` Portfolio & PnL composites. Their bounded metric arrays and progress value have dedicated native binders on both mobile platforms. @@ -26,7 +27,10 @@ Each row also accepts a bounded `style`, whose keys name model fields rather tha views. [STYLE_SPEC.md](STYLE_SPEC.md) is the shared vocabulary: the design tokens, the per-template style surface with each platform's current values, the template isolation rules, and the cross-platform divergences that are registered rather -than fixed. +than fixed. Its container capability contract keeps header/footer placement, +sections, scrolling and the indexed bar independent of content templates. A local +style must preserve the template skeleton and allocated row frame; current +numeric validation alone does not guarantee that a combination fits. The wrapper validates and normalizes data before serializing it. A structural change is one snapshot payload. Frequently changing fields use one batch patch @@ -34,10 +38,13 @@ payload keyed by row key. Neither API performs one native call per row. The native side rejects malformed or duplicate-key payloads without partially applying them. -Section headers, footers, separators, empty rows, and fixed-footer descriptors -are represented as rows in one flattened native data source. `groupId` and -`groupPosition` preserve continuous card geometry without nesting lists inside -cells. +Section headers and scrolling footer/status rows use the flattened row stream. +`fixedFooter` is a separate descriptor outside that stream, and `emptyState` is +used when content is empty; both reuse row binders and currently accept only +`action` or `system` through the public API. A generic public list-header slot is +not yet exposed. `groupId` and `groupPosition` preserve continuous card geometry +without nesting lists inside cells. These placement/grouping rules belong to the +container even when the displayed content uses an existing row template. ## Native architecture diff --git a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md new file mode 100644 index 000000000..c0a8d9870 --- /dev/null +++ b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md @@ -0,0 +1,288 @@ +# NativeList row template catalog + +This catalog defines the **12 types in `RowModel`** on the consolidated spec +branch. Each template owns a fixed internal structure. Its `style` customizes +named fields inside that structure; it does not define a new layout or enable a +list feature. See [STYLE_SPEC.md](STYLE_SPEC.md) for tokens, numeric bounds, +layout-safety rules, common capabilities and implementation gaps. + +The diagrams are **schematics, not device screenshots or pixel specifications**. +Blue areas denote content fields; green areas denote accessories/actions; dashed +areas are optional. They illustrate slot relationships, not literal native view +names. Legacy platform dimensions remain in STYLE_SPEC §4/§6. A diagram showing +an optional field does not mean every variant displays it. + +All rows have a stable `key` and discriminant `type`. Shared `RowBase` metadata +includes `height`, `size`, `density`, `sectionKey`, `groupId`, `groupPosition`, +selection/disabled state and separator intent. These are model/layout inputs, +not a freeform style object. Declaring `sectionKey` never makes a business +template the owner of sections or the indexed bar. + +| Type | Purpose | Fixed structure | +| --- | --- | --- | +| [identity](#identity) | Asset, account, wallet or network | Leading visual + identity text + trailing accessories; named selector variants | +| [walletGroup](#walletgroup) | Wallet family in the sidebar | Parent wallet followed by child wallets | +| [rail](#rail) | Compact navigation/status item | Compact visual + title + optional status/badge | +| [activity](#activity) | Transaction/activity item | Identity/description + amounts + optional row-local actions | +| [message](#message) | Notification | Title/body/time + optional leading visual/thumbnail | +| [dataRow](#datarow) | Comparable values | Two to four stable weighted columns | +| [market](#market) | Token/stock/perpetual quote | Asset identity + price + change block | +| [mediaTile](#mediatile) | Gallery or browser preview | Media area over caption metadata | +| [metricCard](#metriccard) | KPI or portfolio summary | Label/value card or named composite metrics arrangement | +| [sectionHeader](#sectionheader) | Structural section heading | Header text + optional summary value/controls | +| [action](#action) | A row-level command | Label + optional icon/value/controls | +| [system](#system) | Loading, retry, warning, empty/end or spacer | Variant-specific bounded status content | + +The style roles below describe the **declared surface**, not completion on all +platforms. All styles extend `RowBoxStyle`: `horizontalPadding`, `verticalPadding`, +`leadingGap`, `lineGap`, `titleBadgeGap`, `trailingGap`, `image`. Only supported +parameters for visible slots may be relied on. In this stack, gaps other than +`lineGap` and image overrides are applied only by Market; other coverage gaps are +listed in STYLE_SPEC §6.5. An absent optional field is not created by styling it. + +## identity + +![Identity: leading visual, title and optional secondary text, badges and trailing accessories](images/row-identity.svg) + +- **Use:** an identifiable asset, account, wallet, network or setting. +- **Required data:** `leading`, `title`; optional `subtitle`, `tertiary`, `badges`, + `trailing`, `leadingAction`, match ranges and structured subtitle segments. +- **Variants:** default horizontal row; `accountSelector` and `networkSelector` + preserve selector control geometry; `walletSidebar` uses a compact, centered + wallet presentation. A variant is explicit model data, never inferred from font size. +- **Text style roles:** `title`, `subtitle`, `tertiary`, `badge`, `value`, + `valueSecondary`. The last two refer to value accessories in `trailing`; `badge` + refers to badge text. They are semantic roles, not extra top-level data fields. +- **Layout boundary:** reserve visual and accessory slots; text compresses within + its own column. Styling cannot move a checkbox/menu into the title column, + reorder accessories or convert a horizontal row into the sidebar presentation. +- **Current gap:** Web explicit-height selector defaults can overwrite title + style. Verify both selector presentations, not just the default identity row. + +## walletGroup + +![Wallet group: parent wallet and child wallet members inside one composite container](images/row-wallet-group.svg) + +- **Use:** one wallet family in the wallet sidebar, as a single composite row. +- **Required data:** `parent: IdentityRow`, nonempty `children: IdentityRow[]`. + Every member must use `presentation: 'walletSidebar'`; `parent.key` must equal + the group row's `key`, and member keys must be unique within the group. +- **Style surface:** group `style` is box-only. Text styles belong to + `parent.style` or the individual `children[i].style`; there is no inherited + group `title`/`value` text style. +- **Layout boundary:** preserve parent-first member order and wallet member + structure. Local padding cannot change ownership, member count or drag identity. +- **Common-capability boundary:** this is not a generic section and does not + control header/footer placement. General grouped cards use `groupId` and + `groupPosition`; do not conflate those with this composite template. + +## rail + +![Rail: compact visual, title, optional badge and status](images/row-rail.svg) + +- **Use:** a compact navigation or status item, including horizontal rails. +- **Required data:** `visual`, `title`; optional `badge`, `status` and `draggable`. + Status is one of `none`, `online`, `warning`, `error`. +- **Text style roles:** `title`, `badge`, `status` where rendered. Status can be a + platform-specific indicator rather than a text label; accepting a text style + does not prove that path has a visible effect. +- **Layout boundary:** retain the compact visual/title/accessory structure. + Row styling cannot enable horizontal scrolling or reorder for the whole list; + orientation and reorder orchestration belong to the container. +- **Current divergence:** default height is 40 on iOS/Web and 28 on Android. + This catalog does not change those defaults. + +## activity + +![Activity: leading visual and description, trailing amounts, optional actions within the row](images/row-activity.svg) + +- **Use:** a transaction/activity record with amounts and optional commands. +- **Required data:** `leading`, `title`; optional `secondaryLeading`, + `description`, `status`, `primaryAmount`, `secondaryAmount`, `footerActions`. + At most three footer actions are accepted, each with a unique key. +- **Text style roles:** `title`, `description`, `status`, `primaryAmount`, + `secondaryAmount`. `footerActions` do not gain a freeform style surface. +- **Layout boundary:** amounts remain in the amount column, description stays + in its text region, actions stay in the row's action area. Adding actions is a + model/height choice, not a typography trick. +- **Common-capability boundary:** `footerActions` are inside this activity row. + The list footer is independently configured through the container. + +## message + +![Message: title and timestamp over body text, with optional leading visual and thumbnail](images/row-message.svg) + +- **Use:** a notification or message preview. +- **Required data:** `title`, `body`, `time`; optional `leading`, `thumbnail`, + `unread`. `bodyLines` selects one, two or three body lines. +- **Text style roles:** `title`, `body`, `time`. `NativeListTextStyle.lines` + currently allows only one or two; it is not the same field as `bodyLines`. +- **Layout boundary:** time and thumbnail keep their allocated regions; body + text wraps/truncates within the message area. Styling cannot displace the + timestamp, grow over the next row, or turn the preview into unbounded content. +- **Current divergence:** leading-image size and body typography differ across + platforms. Larger line boxes need explicit height and visual verification. + +## dataRow + +![Data row: optional index and visual, stable columns with primary and secondary text, optional checkbox](images/row-data.svg) + +- **Use:** compare values in a linear list or table. +- **Required data:** `columns` with two to four entries. Each column has `key`, + `text`, optional secondary text, `weight` (1/2/3), alignment and tones. + Optional row fields include `leading`, `index`, badges, checkbox and favorite state. +- **Text style roles:** `columns` styles primary column text; `columnSecondary` + styles secondary column text; `index` styles the ordinal. These are collective + semantic roles, not arbitrary per-native-label selectors. +- **Layout boundary:** column order/count/weights are data, shared table column + alignment is layout. Text styles cannot change those or move favorite/checkbox + controls into a data column. +- **Current gap:** native secondary styles and native table-column styles are + incomplete; Web slot coverage does not establish native parity. + +## market + +![Market: asset visual and title/subtitle/badges, followed by price and a bounded change block](images/row-market.svg) + +- **Use:** a recycled market quote row with already formatted/localized values. +- **Required data:** `variant: token | stock | perp`, `leading`, `title`, `price`, + `change` (text and tone). Optional subtitle segments/prefix, badges, leading + action and press/press-in/long-press action keys remain data. +- **Text style roles:** `title`, `subtitle`, `price`, `change`. +- **Additional local parameters:** `titleBadgeLayout: 'inline'`, + `contentTrailingGap`, `subtitleTrailingPadding`, `changeWidth`, `changeHeight`, + `changeCornerRadius`, plus box/image metrics. Badge models and subtitle prefix + have their own existing style fields; they do not create arbitrary child views. +- **Layout boundary:** price and change keep their regions; the localized name + compresses independently from volume where supported. Styling does not change + token/stock/perp structure or affect sections, index navigation or pagination. + +## mediaTile + +![Media tile: image and optional badge/network/close overlays above title and subtitle](images/row-media-tile.svg) + +- **Use:** a gallery tile or browser preview. +- **Required data:** `variant: gallery | browserPreview`, `title`. Optional + image/image-state, subtitle, badge, network image and close-action key. +- **Text style roles:** `title`, `subtitle`, `badge`. +- **Layout boundary:** media stays above caption metadata; network/close/badge + overlays keep their template anchors. A text style cannot change the grid + column count, tile span or move the caption over the image. +- **Common-capability boundary:** grid columns and list scrolling belong to + `snapshot.layout`, not to `MediaTileRowStyle`. Image overrides outside Market + are still a declared-but-unapplied surface in this stack. + +## metricCard + +![Metric card: standard label and large value; named composite variant with bounded metric cells](images/row-metric-card.svg) + +- **Use:** a single KPI or a fixed portfolio activity/performance summary. +- **Required data:** `title`, `value`; optional subtitle, trend/tone, visual, badge, + metrics and progress. The model still requires `value` for composite variants. +- **Variants:** default/`standard` shows a large value; `activity` and `performance` + use the fixed composite arrangement. `metrics`, when supplied, contains two to + five entries; each entry has key, label and value. Progress is bounded to 0…1. +- **Text style roles:** `title` is the small label on the standard card; `value` + is the large number; `subtitle` and `trend` have their own roles. Do not map them + by shared UIKit/Android view names, which are different from these meanings. +- **Layout boundary:** styles cannot change the number/order of metric cells or + turn the standard card into a composite card. Composite subfield mapping needs + explicit coverage; do not assume `style.value` reaches every nested metric. +- **Current gap:** Web composite variants lack the standard card's text slot tags. + The standard card example alone cannot validate composite styling. + +## sectionHeader + +![Section header: title/subtitle and optional summary value, help or selection control](images/row-section-header.svg) + +- **Use:** a structural section heading or list summary, independent of the + business templates in the section. +- **Required data:** `sectionKey`, `title`; optional subtitle, value, title/value + icons/actions, checkbox, `sticky`, `indexTitle`. Variants: default, `summary`, + `gallery`, `history`; `presentation: 'networkSelector'` is also available. +- **Text style roles:** `title`, `subtitle`, `value`. +- **Layout boundary:** preserve heading/value/control regions. `summary` does + not accept a checkbox and is not sticky. Pinning and index-entry generation are + container behavior, not a text-style property. +- **Common-capability boundary:** `sectionHeader` is the current serialized + heading descriptor, not a header feature attached to `identity` or another + content template. `indexTitle` supplies the shared indexed bar's label. +- **Current gap:** Android draws sticky headers separately and excludes complex + headers with a value or checkbox from that path; STYLE_SPEC §6.3 records it. + +## action + +![Action: optional icon, command title, optional trailing value/accessories or checkbox](images/row-action.svg) + +- **Use:** a row-level command, including content used in a fixed footer. +- **Required data:** `title`, `actionKey`; optional tone (`primary`, `neutral`, + `danger`), icon, checkbox and trailing accessories. `accountSelector` is a + named presentation variant. +- **Text style roles:** `title`, `value` (a trailing value accessory). +- **Layout boundary:** preserve icon/text/control ordering and action hit areas. + Styling cannot create a new command or change action routing. +- **Common-capability boundary:** the same action descriptor can live in + `rows`, `fixedFooter` or `emptyState`. The container decides placement and + visibility; the action template only renders its content. + +## system + +![System variants: loading indicator, retry message and action, warning, no-match/end text and explicit spacer](images/row-system.svg) + +- **Use:** bounded status content rather than a business record. +- **Variants and required data:** `loading` has optional message and + skeleton/spinner choice; `retry` requires message/action key with optional + action text; `warning` requires title/message; `noMatch` requires message; + `end` has optional message; `spacer` requires explicit height. +- **Text style roles:** `title` for a visible title, `message` for status copy, + `actionText` for retry text. A spacer has no text slot; skeleton/spinner styling + does not become arbitrary drawing through these text roles. +- **Layout boundary:** keep each variant's indicator/text/action structure and + bounded height. Styling cannot convert loading into retry, turn a spacer into + content, or hide adjacent rows. +- **Common-capability boundary:** a system row can be regular content, + `emptyState` or `fixedFooter`. Refresh/pagination state remains on the list; + a loading row alone does not enable either capability. + +## Composition example: shared sections, footer and indexed bar + +This uses existing public fields. Both content templates share one section; +neither implements its own header, footer, scrolling or indexed bar. There is no +new header prop in this example: the heading is a structural row. + +```ts +import type { NativeListSnapshot } from '@onekeyfe/react-native-native-list'; + +const snapshot: NativeListSnapshot = { + schemaVersion: 1, + generation: 1, + layout: { kind: 'sectioned', stickyHeaders: true }, + capabilities: { sectionIndex: { enabled: true } }, + rows: [ + { + type: 'sectionHeader', key: 'section-a', sectionKey: 'a', + title: 'Assets', indexTitle: 'A', + }, + { + type: 'identity', key: 'asset-btc', sectionKey: 'a', + leading: { kind: 'icon', name: 'StarOutline' }, title: 'Bitcoin', + style: { title: { token: '$bodyMd' } }, + }, + { + type: 'market', key: 'quote-eth', sectionKey: 'a', variant: 'token', + leading: { kind: 'token', fallbackText: 'ETH' }, title: 'Ethereum', + price: '$2,400', change: { text: '+2.4%', tone: 'positive' }, + }, + ], + fixedFooter: { + type: 'action', key: 'continue', title: 'Continue', actionKey: 'continue', + }, +}; +``` + +`listRef.current?.scrollToKey({ key: 'quote-eth', animated: true })` addresses the +stable row key through the shared list API. Changing the quote's content template +while preserving a valid key/section does not introduce a new scroll API. + +For completion criteria and known source-level gaps, return to +[STYLE_SPEC §6–§9](STYLE_SPEC.md#65-consolidated-implementation-gaps). diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index b05d929cd..2c647a370 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -1,7 +1,9 @@ # NativeList Style Spec -Status: **draft**. Line numbers are as of `d91262e98`; symbol names are the stable -anchor and should be preferred when they disagree. +Status: **draft contract; implementation is partial**. Consolidated on 2026-09-22 +from PRs #107, #109, #110, #111 and #112, on main `feac35eb6`. +Use symbol names as source anchors. Tables of legacy defaults below are an +inventory, not evidence of three-platform visual acceptance. This document is the shared design vocabulary for `@onekeyfe/react-native-native-list`. iOS, Android, and Web each own a separate renderer, and nothing in the build forces @@ -10,10 +12,14 @@ It records what a value is called, what it should be, and what each platform currently does — so a divergence is visible in review instead of on a device. See [DESIGN.md](DESIGN.md) for the architecture and ownership model. +Start with the [illustrated template catalog](ROW_TEMPLATES.md) for each template's +purpose, fields, variants, fixed structure and allowed local styling. ## 1. Why this exists -The list serves 13 row templates from one shared view pool per platform. Every +The current `RowModel` union declares **12** row types, including `walletGroup` +and the structural `sectionHeader`. Presentation variants are not additional row +types. Every template hard-codes its own typography and geometry in three places, and the three vocabularies have drifted: @@ -24,8 +30,28 @@ vocabularies have drifted: no shared meaning. - The same concept resolves to different numbers per platform (see §6). -Only `market` rows accept a `style` object today. Every other template is tuned by -adding a `presentation` variant plus a branch in each renderer. +This branch extends the original Market style API to the other row types. A +template defines structure; a local style parameter customizes a named part of +that structure. Header/footer placement, sections, scrolling and the indexed bar +belong to the list container, independently of the content template. + +### 1.1 Ownership contract + +![List container owns common capabilities; templates own only their internal slots](images/list-capabilities.svg) + +| Owner | Responsibility | Must not control | +| --- | --- | --- | +| List container | Viewport, header/footer placement, sections, scroll position, refresh/load more, indexed bar and list chrome | The business meaning of a content row | +| Layout | Orientation, row allocation, grid spans/table columns, content padding and item spacing | Typography of a particular row field | +| Row template + variant | Slot order, hierarchy, required/optional fields, compression and bounded line counts | Whether the list has a header, footer, section index or scroll API | +| `row.style` | Allowlisted typography, colors and bounded local metrics | Arbitrary children, flex direction, positioning, reparenting or list-level geometry | + +**Normative requirement:** replacing an `identity` row with a `market` or +`dataRow` in the same valid container must not remove that container's common +capabilities. Compatibility conditions may depend on layout/orientation (for +example the indexed bar requires vertical sections), not a particular business +row template. This requirement does not claim every current renderer satisfies it. +Current API limits and implementation gaps are explicit in §5 and §6.5. ## 2. How this spec is enforced @@ -53,10 +79,10 @@ object identity. | Layer | State | | --- | --- | | Contract, validation, token resolution, patch support | Implemented | -| Text slots, `horizontalPadding`, `verticalPadding`, `lineGap` | Implemented on Web, iOS and Android | +| Text slots, `horizontalPadding`, `verticalPadding`, `lineGap` | Style passes exist on Web, iOS and Android; coverage and reset gaps remain (§6.5) | | `leadingGap`, `trailingGap`, `titleBadgeGap`, `image` | Validated but applied only on `market`. The others need a per-template default gap, which is not readable from the DOM on Web and is spread across the binders on the native sides | -| `listStyle.separator` and `listStyle.groupCornerRadius` | Implemented on Web, iOS and Android | -| `dataRow` columns in the `table` layout | Not applied. The text lives inside `NativeListTableColumnView` / `tableDataColumns`, which own their own labels; the `linear` layout is covered | +| `listStyle.separator` and `listStyle.groupCornerRadius` | Implementations exist; Android initial snapshot propagation and radius scope have known gaps (§6.5) | +| `dataRow` column styles | Web has primary/secondary slots. Native linear primary styling exists; native secondary and table column styling are incomplete (§6.5) | | Row heights | Unchanged. A styled row that grows still needs an explicit `row.height` (§6.1) | | Android list-wide source scale (§6.3) | Unchanged. Making it per-row would move every selector list's metrics and needs device verification | @@ -65,16 +91,19 @@ in `NativeListModels.kt` and `styleSlot` in `NativeListCell.swift`. §4 is the s truth for both; the Kotlin copy is unit-tested, including a check that no template maps two style keys onto one view. -The example application's **Native List Row Style** page renders every template twice, -plain and styled, behind a toggle. With the toggle off the page must match the build -from before the style existed — that is the regression check for all three platforms. +The example application's **Native List Row Style** page currently has plain/styled +pairs for `identity`, `metricCard`, `message`, `rail` and `action`, plus list chrome +and an explicit-height example. It is not yet the complete catalog or acceptance +suite. §9 defines the remaining coverage. The catalog illustrations are structural +diagrams, not screenshots or proof of rendering parity. ## 3. T1 — Design tokens ### 3.1 Color tokens -`NativeListTheme` keys and the application's tokens are aliases. New code should -use the application name; the theme key is retained for compatibility. +This table maps application token names to `NativeListTheme` keys by meaning. +It does not add runtime aliases: consumers still pass the actual theme keys and +resolved colors; `style.color` does not resolve application color-token names. | Application token | `NativeListTheme` key | Role | | --- | --- | --- | @@ -123,12 +152,16 @@ Weight names map to the bundled Roobert faces: `regular`, `medium`, `semibold`, | Kind | Allowed values | Bound | | --- | --- | --- | -| Padding / gap | any integer | `0…64` | -| `lineGap` (title to subtitle) | any integer | `0…16` | -| Font size | any integer | `8…48` | -| Line height | any integer | `8…64` | -| Corner radius | any integer | `0…80` | -| Image edge | any integer | `1…160` | +| Padding / gap | finite number | `0…64` | +| `lineGap` (title to subtitle) | finite number | `0…16` | +| Font size | finite number | `8…48` | +| Line height | finite number | `8…64` | +| Corner radius | finite number | `0…80` | +| Image edge | finite number | `1…160` | + +These are the validator's numeric bounds, not a guarantee that every combination +fits a row. Fractional values are accepted; native rounding can differ. See §7.1 +for the stronger layout-safety contract and its current enforcement limits. Radius steps in use: `4` (chip), `8` (rail, small visual), `10` (visual `rounded`), `12` (row, card), `16` (media tile), `20` (wallet sidebar row on iOS only — §6). @@ -146,8 +179,10 @@ views would therefore mis-target. `market` already follows this rule with On Web the element rendering a field carries `data-nl-slot=""`, so the style pass resolves a slot by name rather than by CSS class — on its own, `.ok-native-list-secondary` is the identity subtitle, the rail status, a metric -label, a data column's secondary text, and a system message. iOS and Android will -need the same field-to-view mapping expressed in native code. +label, a data column's secondary text, and a system message. iOS and Android express +the same semantic mapping in native code. Singular style roles such as `badge` +and `value` can refer to repeated badges or values in `trailing`; their precise +meaning is defined in the [catalog](ROW_TEMPLATES.md). Legend: **=** all three platforms agree; **≠** registered divergence, see §6. @@ -316,16 +351,43 @@ the subtitle and title views respectively — do not follow the view names. ### Carrier coverage -`sectionHeader`, `fixedFooter`, and `emptyState` are ordinary rows on all three -platforms — iOS binds the footer through `NativeListCell`, Android through -`NativeListRowView.bind`, Web through `renderElement`, and an empty snapshot's -`emptyState` is spliced into `rows` during parsing. One `style` surface therefore -covers all four carriers. The only exception is the Android sticky header (§6). +Rows in `snapshot.rows`, `snapshot.fixedFooter`, and `snapshot.emptyState` reuse +the row binders. The two standalone descriptors currently accept only `action` +or `system` in the public TypeScript API. `sectionHeader` is a structural row in +`rows`. Reusing a binder does not transfer ownership of footer positioning, +empty-state placement or header pinning to the row template. Android's separate +sticky-header renderer has additional limitations (§6.3). ## 5. T3 — List chrome -Chrome is not part of a row and needs its own surface (`snapshot.listStyle`, -not yet implemented). +Chrome is not part of a row. This branch has `snapshot.listStyle` for separator +inset/color and grouped-card radius. Common capabilities remain in their existing +container APIs; being public does not require placing everything in `listStyle`. + +### 5.1 Common capabilities and current API + +| Capability | Existing entry point | Contract and current limit | +| --- | --- | --- | +| List header | A leading structural row can carry summary content; native pager integration can host external header views | Header placement belongs to the container. There is currently **no generic public `listHeader`/`ListHeaderComponent` prop**; a full shared header slot remains a design/implementation gap | +| List footer | A trailing `action`/`system` row scrolls with content; `snapshot.fixedFooter` stays outside the scrolling rows | Available independently of the content row type. `fixedFooter` currently accepts only `ActionRow \| SystemRow`; row style controls its content, not its fixed placement | +| Sections / sticky headers | `layout.kind: 'sectioned'`, `sectionHeader.sectionKey`, member `sectionKey`, `layout.stickyHeaders`, header `sticky` | A section can contain different content templates. `sectionHeader` describes the heading; the container owns membership, positioning and pinning. Android complex-header pinning remains limited (§6.3) | +| Scroll and restoration | `NativeListRef.scrollToKey/Index/Item/Offset/End/Location`, `initialScrollKey/Index` and view position/offset props | Shared viewport behavior, independent of row template. Style must not silently change measured offsets or invalidate stable row keys | +| Indexed bar | `capabilities.sectionIndex`, `sectionHeader.indexTitle`; Web has `webSectionIndexContainerRef` | Requires vertical `sectioned` layout; entries follow indexed header order and target header keys. No dependency on `identity`, `market`, or another content template | +| Refresh / pagination | `capabilities.pullToRefresh/refreshing/loadMore/endReachedThreshold`, `onRefresh`, `onEndReached`, `setRefreshing` | Container gestures/state; indicator implementation may differ by platform | +| Empty state | `snapshot.emptyState` (`ActionRow \| SystemRow`) | Container decides when to display it, independently of the regular content templates | +| Selection / reorder | `snapshot.selection`, `capabilities.reorderable`, selection/reorder events | Common orchestration; each row's available controls/draggable support still depend on its declared model | +| Separator / grouped cards | `listStyle`, row `separator`, `groupId` and `groupPosition` | Styling scope is the list; group membership is data. A custom group radius must not change unrelated template cards | + +The header/footer slots in the diagram are ownership boundaries, not newly +available props. Do not introduce `marketHeader`, `identityFooter`, or per-template +scroll/index configuration. A future public header or broader footer carrier must +extend the container contract once, with the same placement rules for all content. + +`activity.footerActions` means buttons **inside one activity row**. It is unrelated +to the list footer. Similarly, `walletGroup` is a composite wallet template, not +the list's general section mechanism. + +### 5.2 Chrome surface | Item | Data-driven today | iOS | Android | Web | | --- | --- | --- | --- | --- | @@ -439,6 +501,30 @@ exposed — a hairline is correct on iOS and a whole pixel is correct elsewhere. tokens, because `--nl-inverse-text` defaults to `#fcfcfc` and `--nl-secondary` to `#6b7280`. Swapping them changes untinted rendering. +### 6.5 Consolidated implementation gaps + +These items were checked against the consolidated source on 2026-09-22. The +linked PR discussions provide context; none of these entries means a device +reproduction or a fix has been completed. + +| Gap | Source anchor / context | Acceptance needed | +| --- | --- | --- | +| Web selector defaults overwrite caller styles | `renderElement` calls `applyRowStyle` before the explicit-height account/network selector overrides ([#109](https://github.com/OneKeyHQ/app-modules/pull/109)) | Apply title size/line height/weight after defaults for both presentations | +| Composite metric-card text lacks Web slots | `createMetricRow` branches on `variant: activity/performance`; those elements do not get the standard card's slot tags ([#109](https://github.com/OneKeyHQ/app-modules/pull/109)) | Define composite heading/metric role mapping and test both variants; do not infer support from the standard card | +| Native data secondary/table styles are missing | `nativeListStyleSlot` / `styleSlot` omit `columnSecondary`; native table labels have their own renderer ([#110](https://github.com/OneKeyHQ/app-modules/pull/110)) | Verify primary and secondary text in both linear and table layouts | +| Android alignment can survive reuse | `applyStyledText` writes gravity; `resetRowStyle` does not restore every styled label's baseline gravity ([#110](https://github.com/OneKeyHQ/app-modules/pull/110)) | Bind centered/end-aligned content, then unstyled content into the same holder and verify every label | +| Android initial list chrome is not propagated | Normal `NativeListView` snapshot path sets `adapter.theme` but omits `adapter.listStyle`; only the stable-content path assigns it ([#112](https://github.com/OneKeyHQ/app-modules/pull/112)) | Initial mount, normal update, chrome-only update and footer must use the same list style | +| Android group radius affects non-group cards | `applySelectionState` synthesizes `single` for some selectors/metric cards; `groupedBackground` reads the custom radius without checking actual `groupId` membership ([#112](https://github.com/OneKeyHQ/app-modules/pull/112)) | Grouped rows change; standalone selectors and metric cards retain template geometry | +| Example does not cover all templates | `NativeListRowStylePage.buildRows` has five template pairs ([#111](https://github.com/OneKeyHQ/app-modules/pull/111)) | Add the seven missing row types and relevant variants, then execute §9 | +| Validation is not a layout-fit check | `assertRowStyle`, `assertTextStyle`, `assertBoundedStyleNumber` | Numeric bounds alone do not detect clipping, insufficient row height, or all unsupported nested keys; satisfy §7.1 before claiming safe arbitrary overrides | +| General header slot is absent | `NativeListProps`, `NativeListSnapshot` | Define one container-level header contract before expanding the public API | + +`leadingGap`, `trailingGap`, `titleBadgeGap` and `image` remain accepted but unused +outside Market in this stack. Android sticky-header styling remains separate +(§6.3). Unsupported paths must be completed or explicitly rejected before being +advertised as cross-platform style support. This consolidation does not change +the runtime API to resolve those gaps. + ## 7. Isolation rules The view pool is shared across templates, so a styled row must not be able to @@ -447,21 +533,64 @@ affect any other row. Four rules: 1. **Style types are per template.** `IdentityRowStyle` carries only identity's fields; `MetricCardRowStyle` only metricCard's. A key that the row type does not declare fails `validateSnapshot` instead of silently hitting a shared view. -2. **Every property gets an explicit default.** Android's `resetViews()` restores - visibility, gravity, maxLines, layout params, background, and padding — but - **not** `textSize`, `typeface`, or `lineHeight`. A style pass that writes those - only when a style is present will leak a font size into the next row that reuses - the view. The pass must write the template default when the style omits a value. +2. **Every property gets an explicit default.** Restore font, line height, line + count, alignment/gravity, padding, color and all other changed state before + binding the next row. Current native resets are incomplete (§6.5). An omitted + value must use that template's default, never a previous row's value. 3. **No list-wide side effects.** Any style-dependent decision is made per row. `usesSelectorSourceScale` (§6.3) is the counter-example to avoid. -4. **Bounded and fail-soft.** Bounds are validated in JavaScript; native readers use - `opt*(key, default)` and fall back to the template default. Bad data degrades to - "this row is unstyled", never to a crash or to a neighbouring row changing. +4. **Bounded and fail-soft.** Validate input in JavaScript before serialization; + native readers must safely handle absent fields. A per-field default does not + prove that the whole combination fits. Invalid style must not affect another + row or crash the list. §7.1 states the required layout boundary. + +### 7.1 Local styling must preserve template layout + +These are **normative acceptance rules**, not additional API keys or a claim that +the current validators enforce geometry: + +1. **Keep the skeleton fixed.** A style may change a declared semantic field's + typography/color and supported local padding/gaps/image metrics. It cannot + change slot order, orientation, column count/weight, structural spans, section + membership, control placement or parent constraints. Those belong to the + template, its explicit variant, or the container layout. +2. **Keep the allocated row frame authoritative.** A style-only update must not + alter the row's measured outer width/height, the next row's origin, scroll + offsets, sticky bounds or footer placement. If content needs more height, the + caller must supply an appropriate explicit `row.height` as a separate geometry + change and verify it with the container. There is no style-driven auto-height + in this stack. +3. **Fit all content within that frame.** After local padding, each visual/control + and text line box must fit. For a simple two-line identity row, a necessary + check is `2 * verticalPadding + max(visualHeight, titleLineHeight + lineGap + + subtitleLineHeight, trailingHeight) <= row.height`. More lines and template + subrows add their own budget. This formula is an example, not a general row + measurement algorithm; it does not cover platform font scaling or rounding. +4. **Preserve compression and interaction.** Text may truncate/wrap only inside + its declared slot and line limit. It must not push a checkbox, menu, price or + action outside the row, overlap a neighbor, or steal another control's hit + target. Do not shrink hit areas to make an oversized style fit. `start`/`end` + follow layout direction; color changes must preserve readable contrast. +5. **Reject unsupported structure.** No arbitrary React/RN/CSS style bag is part + of this contract. `position`, `transform`, `flexDirection`, negative margins, + freeform children and view-slot names are not supported style parameters. + Public types and row-level allowlists already constrain part of this; deeper + runtime validation and combination-fit protection remain incomplete (§6.5). +6. **Retain local scope through every path.** Snapshot, patch, style removal, + scrolling reuse, footer binding and sticky rendering must agree. A parent + `walletGroup.style` must not silently become the child members' text style; + members use their own `IdentityRow.style`. + +Example: `height: 48` with two 24-point text lines and 12-point top/bottom padding +needs at least 72 points before considering other content. Each number is legal +individually, but that combination is **not layout-safe**. Current validation +accepts such combinations; callers must choose a fitting explicit height and +verify the result. Do not describe numeric bounds as a clipping-prevention feature. ## 8. Where style is applied -Each renderer already runs a pass after the per-template binder. The style pass -belongs there, so none of the 13 binders change. +The style pass belongs after template and presentation defaults. All 12 row types +retain their own binders; adding a local style must not replace their structure. All three are implemented as `applyRowStyle`. @@ -469,9 +598,9 @@ All three are implemented as `applyRowStyle`. | --- | --- | --- | | iOS | `NativeListCell.bind()`, after the `switch item.type` | The binders install an attributed string through `setLineHeight`, so assigning `font` or `textColor` alone would not take effect — the style pass rebuilds the line box | | Android | `NativeListRowView.bind()`, **after** `applySize(item)` | `applySize` re-dispatches font size and typeface by row type and would otherwise overwrite the style | -| Web | `renderElement()`, after `createRowBody()` | The selector inline overrides already live here | +| Web | `renderElement()`, after `createRowBody()` | Currently before some selector overrides; move after those defaults to satisfy the contract (§6.5) | -Each platform also gained a `resetRowStyle` that runs before the binder, per §7 rule 2. +Both native platforms gained a `resetRowStyle` before the binder, per §7 rule 2. On Android `resetViews()` restores neither `textSize`, `typeface` nor `lineHeight`; on iOS `reset()` restores the shared label fonts but not the data, metric and media label fonts, nor any text alignment. Without the reset, a style would leak into the next row that @@ -497,3 +626,27 @@ For any PR that touches list typography or geometry: (§7 rule 3)? - [ ] For a section header: does the Android sticky decoration need the same change (§6.3)? +- [ ] Does each style-only update preserve the allocated row frame, slot order, + neighbor position and control hit areas (§7.1)? +- [ ] Can a content template be replaced while retaining header/footer, sections, + scrolling and the indexed bar (§5.1)? + +### Acceptance matrix + +Run these checks on iOS, Android and Web before calling the spec implemented. +Source review, Swift parsing and JS unit tests do not replace native builds and +rendered interaction checks. + +| Area | Required cases | Pass condition | +| --- | --- | --- | +| Template inventory | All 12 types in the catalog; selector presentations; composite metric cards; linear/table data rows; system variants | Named fields receive the intended style, unsupported fields fail explicitly | +| Baseline | Style omitted, style toggled off, existing Market consumers | Platform's baseline rendering and row geometry preserved | +| Layout boundary | Narrow width, long/localized text, one/two lines, RTL, supported font scaling, largest supported fitting style | No overlap, clipping, moved controls or neighbor-frame drift; invalid combinations are not advertised as safe | +| Reuse and updates | Styled → unstyled → another template; snapshot and patch; rapid scrolling | No leaked typography, alignment, colors or metrics | +| Public composition | Mixed `identity`/`market`/`dataRow` sections, summary header, fixed footer, indexed bar | Swapping content templates preserves common capabilities and stable scroll targets | +| Scroll / chrome | Initial scroll, scroll to key/index/location/end, resize, refresh, pagination, initial/chrome-only snapshots | Correct target/offset and consistent separator/group style without row-size changes | +| Independent scopes | Two list instances, grouped and non-grouped rows, nested wallet members | No style or index-host effect outside the intended list/group/row | + +Next implementation work should close §6.5, complete the example coverage, and +record platform-specific build and interaction evidence. The shared document is +the contract; no shared cross-language renderer or code generator is required. diff --git a/native-views/react-native-native-list/docs/images/list-capabilities.svg b/native-views/react-native-native-list/docs/images/list-capabilities.svg new file mode 100644 index 000000000..f9a059d77 --- /dev/null +++ b/native-views/react-native-native-list/docs/images/list-capabilities.svg @@ -0,0 +1,39 @@ + +NativeList · container capability boundaries +Structural schematic. Blue is content; green is controls; dashed slots are optional. Not a device screenshot or a pixel specification. + + +NativeList · container capability boundaries + + +Header placement · container responsibility + +Scrolling viewport + +Section heading · structural row + +identity · internal slots only + +market / dataRow · internal slots only + +Next section … + +A +B +C + +Footer placement · fixed or scrolling +Common container capabilities +Header / footer positioning +Section membership and sticky headers +Scroll, restoration, refresh, pagination +Indexed bar and its host / hit area +Padding, separators and grouped cards + +Row template +Owns slot structure and local style only. +Changing content templates preserves +the container’s public capabilities. +Ownership diagram, not new API: generic header slot is still absent; fixedFooter currently accepts action/system. + + diff --git a/native-views/react-native-native-list/docs/images/row-action.svg b/native-views/react-native-native-list/docs/images/row-action.svg new file mode 100644 index 000000000..4332acc1e --- /dev/null +++ b/native-views/react-native-native-list/docs/images/row-action.svg @@ -0,0 +1,17 @@ + +action · content reusable in rows / footer / empty state +Structural schematic. Blue is content; green is controls; dashed slots are optional. Not a device screenshot or a pixel specification. + + +action · content reusable in rows / footer / empty state + + +icon + +title → actionKey + +trailing value / +accessories / checkbox +Placement is chosen by the container. Styling the action never makes it fixed or sticky. + + diff --git a/native-views/react-native-native-list/docs/images/row-activity.svg b/native-views/react-native-native-list/docs/images/row-activity.svg new file mode 100644 index 000000000..daa945748 --- /dev/null +++ b/native-views/react-native-native-list/docs/images/row-activity.svg @@ -0,0 +1,24 @@ + +activity · record plus row-local actions +Structural schematic. Blue is content; green is controls; dashed slots are optional. Not a device screenshot or a pixel specification. + + +activity · record plus row-local actions + + +leading + +title + +description + +primaryAmount + +secondaryAmount + +status + +footerActions · up to 3 buttons +footerActions are inside this row. They are unrelated to the shared list footer. + + diff --git a/native-views/react-native-native-list/docs/images/row-data.svg b/native-views/react-native-native-list/docs/images/row-data.svg new file mode 100644 index 000000000..1b405d143 --- /dev/null +++ b/native-views/react-native-native-list/docs/images/row-data.svg @@ -0,0 +1,24 @@ + +dataRow · stable column geometry +Structural schematic. Blue is content; green is controls; dashed slots are optional. Not a device screenshot or a pixel specification. + + +dataRow · stable column geometry + + +index + +leading + +columns[0].text +secondaryText? + +columns[1].text +secondaryText? + +… + +check +2–4 columns; order, weight and alignment are data/layout inputs, not arbitrary text-style overrides. + + diff --git a/native-views/react-native-native-list/docs/images/row-identity.svg b/native-views/react-native-native-list/docs/images/row-identity.svg new file mode 100644 index 000000000..bd3193e46 --- /dev/null +++ b/native-views/react-native-native-list/docs/images/row-identity.svg @@ -0,0 +1,25 @@ + +identity · fixed leading / text / trailing regions +Structural schematic. Blue is content; green is controls; dashed slots are optional. Not a device screenshot or a pixel specification. + + +identity · fixed leading / text / trailing regions + + +leading +visual + +title + +badges + +subtitle + +tertiary + +trailing +value / valuePair +checkbox / menu +Variants: default, accountSelector, networkSelector, walletSidebar. Styles preserve each variant’s structure. + + diff --git a/native-views/react-native-native-list/docs/images/row-market.svg b/native-views/react-native-native-list/docs/images/row-market.svg new file mode 100644 index 000000000..f222188e3 --- /dev/null +++ b/native-views/react-native-native-list/docs/images/row-market.svg @@ -0,0 +1,23 @@ + +market · identity / price / change +Structural schematic. Blue is content; green is controls; dashed slots are optional. Not a device screenshot or a pixel specification. + + +market · identity / price / change + + +leading + +title + +badges + +subtitle + +price + +change +text + tone +Explicit variants: token, stock, perp. Local metrics keep price/change inside the allocated row frame. + + diff --git a/native-views/react-native-native-list/docs/images/row-media-tile.svg b/native-views/react-native-native-list/docs/images/row-media-tile.svg new file mode 100644 index 000000000..1b2dc280b --- /dev/null +++ b/native-views/react-native-native-list/docs/images/row-media-tile.svg @@ -0,0 +1,26 @@ + +mediaTile · media over caption metadata +Structural schematic. Blue is content; green is controls; dashed slots are optional. Not a device screenshot or a pixel specification. + + +mediaTile · media over caption metadata + + +image / imageState + +badge + +close + +network + +title + +subtitle +gallery / browserPreview +The container owns grid spans. +Overlay anchors stay fixed. +Caption remains below the media. +Schematic shows one tile; row style does not choose grid columns or change tile placement. + + diff --git a/native-views/react-native-native-list/docs/images/row-message.svg b/native-views/react-native-native-list/docs/images/row-message.svg new file mode 100644 index 000000000..e638eb52c --- /dev/null +++ b/native-views/react-native-native-list/docs/images/row-message.svg @@ -0,0 +1,20 @@ + +message · bounded text with time / thumbnail +Structural schematic. Blue is content; green is controls; dashed slots are optional. Not a device screenshot or a pixel specification. + + +message · bounded text with time / thumbnail + + +leading + +title + +time + +body · bounded line count + +thumbnail +Timestamp and thumbnail retain their regions; body wrapping cannot move controls or adjacent rows. + + diff --git a/native-views/react-native-native-list/docs/images/row-metric-card.svg b/native-views/react-native-native-list/docs/images/row-metric-card.svg new file mode 100644 index 000000000..c7bf0ffa2 --- /dev/null +++ b/native-views/react-native-native-list/docs/images/row-metric-card.svg @@ -0,0 +1,33 @@ + +metricCard · standard and named composite variants +Structural schematic. Blue is content; green is controls; dashed slots are optional. Not a device screenshot or a pixel specification. + + +metricCard · standard and named composite variants + + +title · small label + +value · large number + +subtitle + +trend + + +title · composite heading + +metrics[0] +label / value + +metrics[1] +label / value + +divider or progress + +metrics[2…] + +metrics[3…] +Composite activity/performance layouts remain fixed. Standard-card style coverage does not prove composite support. + + diff --git a/native-views/react-native-native-list/docs/images/row-rail.svg b/native-views/react-native-native-list/docs/images/row-rail.svg new file mode 100644 index 000000000..bf9aabba6 --- /dev/null +++ b/native-views/react-native-native-list/docs/images/row-rail.svg @@ -0,0 +1,18 @@ + +rail · compact navigation / status item +Structural schematic. Blue is content; green is controls; dashed slots are optional. Not a device screenshot or a pixel specification. + + +rail · compact navigation / status item + + +visual + +title + +badge + +status +Compact template regions; the list container supplies scroll orientation and reorder orchestration. + + diff --git a/native-views/react-native-native-list/docs/images/row-section-header.svg b/native-views/react-native-native-list/docs/images/row-section-header.svg new file mode 100644 index 000000000..c46fc4aad --- /dev/null +++ b/native-views/react-native-native-list/docs/images/row-section-header.svg @@ -0,0 +1,19 @@ + +sectionHeader · shared structural heading +Structural schematic. Blue is content; green is controls; dashed slots are optional. Not a device screenshot or a pixel specification. + + +sectionHeader · shared structural heading + + +title + +subtitle + +value + +icon / +check +sectionKey/indexTitle are structural metadata. The list owns pinning, membership and the indexed bar. + + diff --git a/native-views/react-native-native-list/docs/images/row-system.svg b/native-views/react-native-native-list/docs/images/row-system.svg new file mode 100644 index 000000000..0af057736 --- /dev/null +++ b/native-views/react-native-native-list/docs/images/row-system.svg @@ -0,0 +1,30 @@ + +system · bounded state variants +Structural schematic. Blue is content; green is controls; dashed slots are optional. Not a device screenshot or a pixel specification. + + +system · bounded state variants + + +loading + +skeleton / spinner + optional message + +retry + +message + +actionText + +warning + +title + message + +noMatch / end + +message + +spacer: height only +Variants are mutually exclusive. Refresh/pagination capability is owned by the list, not by a loading row. + + diff --git a/native-views/react-native-native-list/docs/images/row-wallet-group.svg b/native-views/react-native-native-list/docs/images/row-wallet-group.svg new file mode 100644 index 000000000..8f594d62f --- /dev/null +++ b/native-views/react-native-native-list/docs/images/row-wallet-group.svg @@ -0,0 +1,26 @@ + +walletGroup · a composite wallet family +Structural schematic. Blue is content; green is controls; dashed slots are optional. Not a device screenshot or a pixel specification. + + +walletGroup · a composite wallet family + + +parent: identity +walletSidebar + +Parent key equals the walletGroup key + +children[0]: identity + +Member text uses children[0].style + +children[1…]: identity + +Fixed parent-first membership + + + +Group style is box-only. This template does not own the list’s general sections or header/footer. + + From 09baf113d3ff93a163e99638a8fbabf8c80f1b25 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 15:34:42 +0800 Subject: [PATCH 07/71] fix(native-list): align style targeting and isolation with audited spec --- CHANGELOG.md | 6 +- .../pages/NativeListRowStylePage.tsx | 112 ++++++++++++++++++ .../onekey/nativelist/NativeListRowView.kt | 59 +++++---- .../com/onekey/nativelist/NativeListView.kt | 1 + .../react-native-native-list/docs/DESIGN.md | 2 +- .../docs/ROW_TEMPLATES.md | 11 +- .../docs/STYLE_SPEC.md | 85 ++++++++----- .../ios/NativeListCell.swift | 90 +++++++++----- .../src/__tests__/NativeList.web.test.ts | 106 +++++++++++++++++ .../src/__tests__/validation.test.ts | 28 +++++ .../src/validation.ts | 46 ++++++- .../src/web/NativeListWebEngine.ts | 19 +-- 12 files changed, 471 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecf5105b2..92dcdf79a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,16 @@ All notable changes to this project will be documented in this file. - **native-list (iOS and Android)**: Apply the row style natively. A style key is mapped to the view that renders that model field by `nativeListStyleSlot` (Kotlin, unit-tested) and `styleSlot` (Swift), because the view pool is shared and the mapping is not one to one. Both platforms gained a `resetRowStyle` that runs before the binder: Android's `resetViews` restores neither `textSize`, `typeface` nor `lineHeight`, and iOS's `reset` misses the data, metric and media label fonts, so without it a style would leak into the next row reusing the view. On iOS the pass rebuilds the attributed string, because the binders install one through `setLineHeight` and a plain `font` assignment would not take effect. - **native-list**: Add a bounded per-template row style. `style` keys name the model field they modify rather than the view that carries it, so `metricCard.style.value` reaches the large number even though it shares a view with `identity.style.title`. Typography can be given as a named step (`{ token: '$bodyLg' }`) which is resolved to numbers in JavaScript before serialization, so no native renderer learns the vocabulary; explicit values still override. Keys a template does not declare are rejected by `validateSnapshot` and `validatePatches`, and styles carried by a patch are resolved the same way. Implemented on Web: text slots for every template plus `horizontalPadding`, `verticalPadding`, and `lineGap`. Row heights are unchanged — a styled row that grows still needs an explicit `height`. +### Bug Fixes +- **native-list**: Apply Web selector styles after presentation defaults, isolate wallet-member styles, and target composite metric-card headings by their semantic role. Restore native text state on reuse while retaining unspecified iOS attributed-string properties. Propagate Android list chrome on initial snapshots and restrict custom group radius to actual grouped rows. Reject malformed nested text/image styles and unknown nested keys; layout-fit enforcement remains pending. + ### Documentation +- **native-list**: Audit the consolidated spec against template definitions, local layout safety and common container capabilities. Record unresolved geometry-fit and generic header/footer interface decisions separately from implemented fixes. - **native-list**: Consolidate the row-style spec on current main. Define all 12 row types with structural diagrams, local style/layout boundaries, and shared header/footer, section, scroll and indexed-bar ownership. Record implementation and example-coverage gaps separately from the contract. - **native-list**: Add `docs/STYLE_SPEC.md`, the shared style vocabulary for rows, section headers, fixed footers, and empty states. It records the design tokens (aliased to the application's own token names), the per-template style surface keyed by model field, list chrome, the template isolation rules, and a review checklist. Cross-platform divergences — row-height tables, typography, the Android sticky-header renderer, list-wide source scale — are registered rather than changed. ### Chores -- Add the "Native List Row Style" example page with plain/styled pairs for identity, metricCard, message, rail and action, plus list chrome and explicit-height examples. Full template/variant coverage and native visual acceptance remain pending; see `docs/STYLE_SPEC.md`. +- Add the "Native List Row Style" example page with plain/styled pairs for all 12 row types, plus list chrome and explicit-height examples. Variant coverage and native visual acceptance remain pending; see `docs/STYLE_SPEC.md`. - **native-list (Web)**: Remove dead duplicated rules from `WEB_LIST_CSS`. Four blocks (`.ok-native-list-footer`/`-sticky`/`-index-rail`/`-index-button`, `-refresh`, `-warning`, `-subtitle-segments`) were emitted twice and a `prefers-reduced-motion` block three times; every property of the earlier copies was redeclared by the later ones, so removing them changes no rendering. ## [3.0.156] - 2026-09-21 diff --git a/example/react-native/pages/NativeListRowStylePage.tsx b/example/react-native/pages/NativeListRowStylePage.tsx index fecf229be..98bc2c52b 100644 --- a/example/react-native/pages/NativeListRowStylePage.tsx +++ b/example/react-native/pages/NativeListRowStylePage.tsx @@ -161,6 +161,118 @@ function buildRows(styled: boolean): RowModel[] { : {}), }); + // Cover the remaining row types with appearance-only overrides. These pairs + // keep geometry identical so field targeting can be checked independently. + const additional: readonly [RowModel, RowModel['style']][] = [ + [ + { + type: 'activity', + key: 'activity', + leading: { kind: 'icon', name: 'StarOutline' }, + title: 'Sent Bitcoin', + description: 'Confirmed', + primaryAmount: '-0.5 BTC', + secondaryAmount: '$32,115', + }, + { title: { color: '#0D74CE' }, primaryAmount: { color: '#CE2C31' } }, + ], + [ + { + type: 'dataRow', + key: 'data', + index: 1, + columns: [ + { key: 'asset', text: 'Bitcoin', secondaryText: 'BTC' }, + { key: 'value', text: '$64,230', alignment: 'end' }, + ], + }, + { index: { color: '#0D74CE' } }, + ], + [ + { + type: 'market', + key: 'market', + variant: 'token', + leading: { kind: 'token', fallbackText: 'BTC' }, + title: 'Bitcoin', + subtitle: 'BTC', + price: '$64,230', + change: { text: '+2.4%', tone: 'positive' }, + }, + { title: { color: '#0D74CE' }, price: { color: '#108303' } }, + ], + [ + { + type: 'mediaTile', + key: 'media', + variant: 'gallery', + imageState: 'empty', + title: 'Collectible', + subtitle: 'Ethereum', + }, + { title: { color: '#0D74CE' }, subtitle: { color: '#108303' } }, + ], + [ + { + type: 'sectionHeader', + key: 'section', + sectionKey: 'section', + title: 'Section heading', + subtitle: 'Shared list structure', + }, + { title: { color: '#0D74CE' }, subtitle: { color: '#108303' } }, + ], + [ + { + type: 'system', + key: 'system', + variant: 'warning', + title: 'Connection unavailable', + message: 'Please try again later.', + }, + { title: { color: '#CE2C31' }, message: { color: '#0D74CE' } }, + ], + ]; + additional.forEach(([base, style]) => { + rows.push(header(base.key, base.type, 'plain / styled')); + rows.push({ ...base, key: `${base.key}-plain` }); + rows.push({ + ...base, + key: `${base.key}-styled`, + ...(styled ? { style } : {}), + } as RowModel); + }); + + rows.push(header('wallet-group', 'walletGroup', 'member text styles')); + for (const variant of ['plain', 'styled'] as const) { + const key = `wallet-group-${variant}`; + const memberStyle = + styled && variant === 'styled' + ? { title: { color: '#0D74CE' } } + : undefined; + rows.push({ + type: 'walletGroup', + key, + parent: { + type: 'identity', + key, + presentation: 'walletSidebar', + leading: { kind: 'wallet', fallbackText: 'A' }, + title: 'Wallet A', + ...(memberStyle ? { style: memberStyle } : {}), + }, + children: [ + { + type: 'identity', + key: `${key}-child`, + presentation: 'walletSidebar', + leading: { kind: 'wallet', fallbackText: 'B' }, + title: 'Unstyled child', + }, + ], + }); + } + // listStyle is chrome, not a row: the separator inset and the group card // radius below come from the snapshot, not from these rows. rows.push(header('chrome', 'listStyle', 'separator / group')); diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index 2369823f7..92b34b8d1 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -525,6 +525,8 @@ internal class NativeListRowView( private var marketLongPressFired = false // OneKey patch: a row style wrote view properties that resetViews does not restore. private var styledViewsDirty = false + private val styledTextRestorations = mutableMapOf Unit>() + private var compositeMetricTitle: TextView? = null private var reorderActive = false private var checkboxCheckedColor = Color.rgb(32, 32, 32) private var checkboxUncheckedColor = Color.rgb(252, 252, 252) @@ -1070,6 +1072,12 @@ internal class NativeListRowView( dp(style.optDouble("lineGap").roundToInt()) } val variant = item.json.optString("variant") + if (item.type == "metricCard" && variant in setOf("activity", "performance")) { + style.optJSONObject("title")?.let { titleStyle -> + compositeMetricTitle?.let { applyStyledText(it, titleStyle) } + } + return + } val fields = style.keys() while (fields.hasNext()) { val field = fields.next() @@ -1096,6 +1104,27 @@ internal class NativeListRowView( } private fun applyStyledText(view: TextView, style: JSONObject) { + // Save the bound template's actual defaults, including alignment. Guessing + // one shared baseline loses the differences between labels and variants. + if (view !in styledTextRestorations) { + val textSize = view.textSize + val typeface = view.typeface + val colors = view.textColors + val lineSpacingExtra = view.lineSpacingExtra + val lineSpacingMultiplier = view.lineSpacingMultiplier + val maxLines = view.maxLines + val ellipsize = view.ellipsize + val gravity = view.gravity + styledTextRestorations[view] = { + view.setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, textSize) + view.typeface = typeface + view.setTextColor(colors) + view.setLineSpacing(lineSpacingExtra, lineSpacingMultiplier) + view.maxLines = maxLines + view.ellipsize = ellipsize + view.gravity = gravity + } + } if (style.has("fontSize")) view.textSize = sp(style.optDouble("fontSize").toFloat()) style.optString("fontWeight").takeIf(String::isNotEmpty)?.let { view.typeface = marketTypeface(it, "regular") @@ -1302,32 +1331,20 @@ internal class NativeListRowView( } /** - * docs/STYLE_SPEC.md section 7 rule 2: resetViews() restores visibility, gravity, - * maxLines, layout params, background and padding - but not textSize, typeface or - * lineHeight, which each binding path re-establishes for itself. A style that wrote - * one of those would therefore leak into the next row reusing the view, so put them - * back to the constructor baseline before the binder runs. + * docs/STYLE_SPEC.md section 7 rule 2: restore the bound template's actual text + * defaults before the normal reset and next binder. Shared constructor defaults + * cannot represent the alignment and metrics of every template and variant. */ private fun resetRowStyle() { if (!styledViewsDirty) return styledViewsDirty = false - listOf(title, subtitle, tertiary, status, metricSubtitle).forEach { view -> - view.typeface = NativeListFonts.regular(context) - view.setLineSpacing(0f, 1f) - } - badgeLine.typeface = NativeListFonts.medium(context) - badgeLine.setLineSpacing(0f, 1f) - mediaBadge.typeface = NativeListFonts.regular(context) - mediaBadge.setLineSpacing(0f, 1f) - trailingViews.forEach { it.setLineSpacing(0f, 1f) } - dataColumns.forEach { - it.typeface = NativeListFonts.medium(context) - it.setLineSpacing(0f, 1f) - } + styledTextRestorations.values.forEach { it() } + styledTextRestorations.clear() } private fun resetViews() { resetRowStyle() + compositeMetricTitle = null clipChildren = true clipToPadding = true selectorOriginalFontFeatures.forEach { (view, original) -> view.fontFeatureSettings = original } @@ -2751,7 +2768,7 @@ internal class NativeListRowView( typeface = NativeListFonts.regular(context), textColor = color(theme, "disabledText", "#00000072"), letterSpacingDp = 1.2f, - ), + ).also { compositeMetricTitle = it }, weightedWidth(), ) if (variant == "activity") { @@ -4457,7 +4474,9 @@ internal class NativeListRowView( private fun groupedBackground(position: String, color: Int) = GradientDrawable().apply { setColor(color) - val radius = listStyle?.takeIf { it.has("groupCornerRadius") } + val radius = listStyle?.takeIf { + it.has("groupCornerRadius") && !(tag as? NativeListItem)?.json?.optString("groupId").isNullOrEmpty() + } ?.let { scaledDp(it.optDouble("groupCornerRadius").toFloat()) } ?: scaledDp(12f) cornerRadii = when (position) { diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt index 3e2f69547..cd96e0971 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt @@ -536,6 +536,7 @@ class NativeListView( if (previous?.generation != next.generation) endReachedGeneration = null pendingReorder = null adapter.theme = next.theme + adapter.listStyle = next.listStyle adapter.layout = next.layout adapter.orientation = next.orientation adapter.selectedKeys = next.selectedKeys diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index 74234423e..e5491b490 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -132,7 +132,7 @@ and `reorder`. ## Height, layout, and performance constraints Template sizes are fixed to a small set of variants. `message` body text is -bounded to four lines and uses a deterministic template height. System spacers +bounded to three lines and uses a deterministic template height. System spacers are explicit and bounded. Images have explicit sizes, and grid columns are fixed per snapshot. This keeps layout work predictable for 1,000- and 5,000-row workloads without an unbounded self-sizing pass. diff --git a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md index c0a8d9870..f4a6401b1 100644 --- a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md +++ b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md @@ -56,8 +56,8 @@ listed in STYLE_SPEC §6.5. An absent optional field is not created by styling i - **Layout boundary:** reserve visual and accessory slots; text compresses within its own column. Styling cannot move a checkbox/menu into the title column, reorder accessories or convert a horizontal row into the sidebar presentation. -- **Current gap:** Web explicit-height selector defaults can overwrite title - style. Verify both selector presentations, not just the default identity row. +- **Verification:** Web style ordering and removal have DOM regression coverage + for both selector presentations. Rendered geometry remains to be verified. ## walletGroup @@ -72,6 +72,7 @@ listed in STYLE_SPEC §6.5. An absent optional field is not created by styling i group `title`/`value` text style. - **Layout boundary:** preserve parent-first member order and wallet member structure. Local padding cannot change ownership, member count or drag identity. + Group `lineGap` does not override the text spacing inside member rows. - **Common-capability boundary:** this is not a generic section and does not control header/footer placement. General grouped cards use `groupId` and `groupPosition`; do not conflate those with this composite template. @@ -185,11 +186,13 @@ listed in STYLE_SPEC §6.5. An absent optional field is not created by styling i - **Text style roles:** `title` is the small label on the standard card; `value` is the large number; `subtitle` and `trend` have their own roles. Do not map them by shared UIKit/Android view names, which are different from these meanings. + In `activity`/`performance`, only `title` maps to the composite heading. The + other standard-card roles do not address `metrics[].label` or `metrics[].value`. - **Layout boundary:** styles cannot change the number/order of metric cells or turn the standard card into a composite card. Composite subfield mapping needs explicit coverage; do not assume `style.value` reaches every nested metric. -- **Current gap:** Web composite variants lack the standard card's text slot tags. - The standard card example alone cannot validate composite styling. +- **Current gap:** nested metric fields have no declared local text-style roles. + The standard card example alone cannot validate composite heading styling. ## sectionHeader diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index 2c647a370..3c0e86ff4 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -53,6 +53,22 @@ example the indexed bar requires vertical sections), not a particular business row template. This requirement does not claim every current renderer satisfies it. Current API limits and implementation gaps are explicit in §5 and §6.5. +### 1.2 Design audit — 2026-09-22 + +The ownership model matches the intended design. The consolidated documents are +not yet a fully implemented contract: + +| Design intent | Audit conclusion / remaining decision | +| --- | --- | +| Each row template has a clear definition and illustration | All 12 types have a catalog entry and structural diagram. Variant-specific style applicability still needs coverage: composite metric-card `title` means its heading; the standard card's `value`, `subtitle` and `trend` do not name nested metric fields. | +| Local styles cannot break the row layout | §7.1 states the requirement, but numeric bounds do not enforce it. Retaining dimension overrides requires a fitting/rejection policy, including updates against existing row data, available width and native font metrics. Alternatively, the public surface can be restricted to appearance. This is an unresolved contract decision. | +| Header/footer/sections/scroll/indexed bar are common capabilities | Ownership is correctly assigned to the container. Existing section headers and fixed action/system footers are independent of business templates. A generic header/footer slot needs an explicit interface decision covering placement, measurement, scroll indexing and event routing. | + +The five source PRs are closed; implementation continues on the consolidated +branch. The first refactor pass fixes existing field targeting, nested style +validation, text-state restoration and list-chrome scope. It does not settle the +open interface decisions or claim layout-fit enforcement. + ## 2. How this spec is enforced The three renderers cannot constrain each other, but they all read the same @@ -78,10 +94,10 @@ object identity. | Layer | State | | --- | --- | -| Contract, validation, token resolution, patch support | Implemented | +| Key validation, token resolution, patch support | Implemented; combination-fit protection is pending | | Text slots, `horizontalPadding`, `verticalPadding`, `lineGap` | Style passes exist on Web, iOS and Android; coverage and reset gaps remain (§6.5) | | `leadingGap`, `trailingGap`, `titleBadgeGap`, `image` | Validated but applied only on `market`. The others need a per-template default gap, which is not readable from the DOM on Web and is spread across the binders on the native sides | -| `listStyle.separator` and `listStyle.groupCornerRadius` | Implementations exist; Android initial snapshot propagation and radius scope have known gaps (§6.5) | +| `listStyle.separator` and `listStyle.groupCornerRadius` | Android initial snapshot propagation and radius scope corrected; rendered verification pending (§6.5) | | `dataRow` column styles | Web has primary/secondary slots. Native linear primary styling exists; native secondary and table column styling are incomplete (§6.5) | | Row heights | Unchanged. A styled row that grows still needs an explicit `row.height` (§6.1) | | Android list-wide source scale (§6.3) | Unchanged. Making it per-row would move every selector list's metrics and needs device verification | @@ -91,11 +107,11 @@ in `NativeListModels.kt` and `styleSlot` in `NativeListCell.swift`. §4 is the s truth for both; the Kotlin copy is unit-tested, including a check that no template maps two style keys onto one view. -The example application's **Native List Row Style** page currently has plain/styled -pairs for `identity`, `metricCard`, `message`, `rail` and `action`, plus list chrome -and an explicit-height example. It is not yet the complete catalog or acceptance -suite. §9 defines the remaining coverage. The catalog illustrations are structural -diagrams, not screenshots or proof of rendering parity. +The example application's **Native List Row Style** page has plain/styled pairs +for all 12 types, plus list chrome and an explicit-height example. Its wallet +group styles the parent member while leaving a child unstyled to expose leakage. +Variant and rendered acceptance coverage is still pending (§9). The catalog +illustrations are structural diagrams, not screenshots or proof of rendering parity. ## 3. T1 — Design tokens @@ -503,20 +519,20 @@ exposed — a hairline is correct on iOS and a whole pixel is correct elsewhere. ### 6.5 Consolidated implementation gaps -These items were checked against the consolidated source on 2026-09-22. The -linked PR discussions provide context; none of these entries means a device -reproduction or a fix has been completed. +These items were checked against the consolidated source on 2026-09-22. Source +fixes and regression tests below do not establish native rendered acceptance. | Gap | Source anchor / context | Acceptance needed | | --- | --- | --- | -| Web selector defaults overwrite caller styles | `renderElement` calls `applyRowStyle` before the explicit-height account/network selector overrides ([#109](https://github.com/OneKeyHQ/app-modules/pull/109)) | Apply title size/line height/weight after defaults for both presentations | -| Composite metric-card text lacks Web slots | `createMetricRow` branches on `variant: activity/performance`; those elements do not get the standard card's slot tags ([#109](https://github.com/OneKeyHQ/app-modules/pull/109)) | Define composite heading/metric role mapping and test both variants; do not infer support from the standard card | +| Web selector style order | Corrected: `renderElement` now applies styles after presentation defaults. DOM regressions cover account/network selectors and style removal | Rendered geometry and interactions still need verification | +| Composite metric-card heading | `style.title` now targets the heading on Web/iOS/Android. Standard-card value/subtitle/trend roles must not target the heading or nested metrics | Verify both variants; nested metric styling needs a separate declared role before implementation | | Native data secondary/table styles are missing | `nativeListStyleSlot` / `styleSlot` omit `columnSecondary`; native table labels have their own renderer ([#110](https://github.com/OneKeyHQ/app-modules/pull/110)) | Verify primary and secondary text in both linear and table layouts | -| Android alignment can survive reuse | `applyStyledText` writes gravity; `resetRowStyle` does not restore every styled label's baseline gravity ([#110](https://github.com/OneKeyHQ/app-modules/pull/110)) | Bind centered/end-aligned content, then unstyled content into the same holder and verify every label | -| Android initial list chrome is not propagated | Normal `NativeListView` snapshot path sets `adapter.theme` but omits `adapter.listStyle`; only the stable-content path assigns it ([#112](https://github.com/OneKeyHQ/app-modules/pull/112)) | Initial mount, normal update, chrome-only update and footer must use the same list style | -| Android group radius affects non-group cards | `applySelectionState` synthesizes `single` for some selectors/metric cards; `groupedBackground` reads the custom radius without checking actual `groupId` membership ([#112](https://github.com/OneKeyHQ/app-modules/pull/112)) | Grouped rows change; standalone selectors and metric cards retain template geometry | -| Example does not cover all templates | `NativeListRowStylePage.buildRows` has five template pairs ([#111](https://github.com/OneKeyHQ/app-modules/pull/111)) | Add the seven missing row types and relevant variants, then execute §9 | -| Validation is not a layout-fit check | `assertRowStyle`, `assertTextStyle`, `assertBoundedStyleNumber` | Numeric bounds alone do not detect clipping, insufficient row height, or all unsupported nested keys; satisfy §7.1 before claiming safe arbitrary overrides | +| Native text state on reuse | The style pass now saves the bound view's actual text defaults and restores them before reuse, including alignment. iOS retains attributed-string properties not overridden by a style | Styled → unstyled → different-template reuse still needs device verification | +| Android initial list chrome | Normal snapshots now assign `adapter.listStyle`, matching the stable-content path | Initial mount, normal update, chrome-only update and footer must use the same list style | +| Android grouped-radius scope | Custom radius now requires actual `groupId` membership, not a synthetic `single` position | Grouped rows change; standalone selectors and metric cards retain template geometry | +| Wallet-group member scope on Web | Members now apply their own styles; group `lineGap` no longer traverses member text columns | Parent and child styles must remain independent on every platform | +| Example inventory | `NativeListRowStylePage.buildRows` now covers all 12 types | Complete relevant variants and execute §9 | +| Validation is not a layout-fit check | Text/image nested object shape and unknown keys are now checked. Numeric bounds still do not detect insufficient space | Decide fitting policy and satisfy §7.1 before claiming safe arbitrary overrides | | General header slot is absent | `NativeListProps`, `NativeListSnapshot` | Define one container-level header contract before expanding the public API | `leadingGap`, `trailingGap`, `titleBadgeGap` and `image` remain accepted but unused @@ -535,7 +551,8 @@ affect any other row. Four rules: declare fails `validateSnapshot` instead of silently hitting a shared view. 2. **Every property gets an explicit default.** Restore font, line height, line count, alignment/gravity, padding, color and all other changed state before - binding the next row. Current native resets are incomplete (§6.5). An omitted + binding the next row. Native style passes save the actual bound text defaults + for restoration (§6.5). An omitted value must use that template's default, never a previous row's value. 3. **No list-wide side effects.** Any style-dependent decision is made per row. `usesSelectorSourceScale` (§6.3) is the counter-example to avoid. @@ -574,8 +591,8 @@ the current validators enforce geometry: 5. **Reject unsupported structure.** No arbitrary React/RN/CSS style bag is part of this contract. `position`, `transform`, `flexDirection`, negative margins, freeform children and view-slot names are not supported style parameters. - Public types and row-level allowlists already constrain part of this; deeper - runtime validation and combination-fit protection remain incomplete (§6.5). + Public types and nested runtime allowlists constrain this surface; + combination-fit protection remains incomplete (§6.5). 6. **Retain local scope through every path.** Snapshot, patch, style removal, scrolling reuse, footer binding and sticky rendering must agree. A parent `walletGroup.style` must not silently become the child members' text style; @@ -596,15 +613,15 @@ All three are implemented as `applyRowStyle`. | Platform | Anchor | Note | | --- | --- | --- | -| iOS | `NativeListCell.bind()`, after the `switch item.type` | The binders install an attributed string through `setLineHeight`, so assigning `font` or `textColor` alone would not take effect — the style pass rebuilds the line box | +| iOS | `NativeListCell.bind()`, after the `switch item.type` | The style pass updates specified attributed-string properties while preserving omitted template attributes | | Android | `NativeListRowView.bind()`, **after** `applySize(item)` | `applySize` re-dispatches font size and typeface by row type and would otherwise overwrite the style | -| Web | `renderElement()`, after `createRowBody()` | Currently before some selector overrides; move after those defaults to satisfy the contract (§6.5) | +| Web | `renderElement()`, after template and presentation defaults | Wallet-group members apply their own local pass before the group pass | -Both native platforms gained a `resetRowStyle` before the binder, per §7 rule 2. -On Android `resetViews()` restores neither `textSize`, `typeface` nor `lineHeight`; on iOS -`reset()` restores the shared label fonts but not the data, metric and media label fonts, -nor any text alignment. Without the reset, a style would leak into the next row that -reuses the view. +Both native platforms use `resetRowStyle` before the binder, per §7 rule 2. +They capture the bound text view's defaults before modifying it and restore that +state before the normal template reset. This includes text metrics, color and +alignment; iOS also restores attributed text. This avoids guessing one baseline +for views shared by different templates. Device reuse checks remain required. The existing market helpers generalize rather than being rewritten: `applyMarketTextStyle` / `applyMarketButtonStyle` / `marketAttributedText` (iOS), @@ -650,3 +667,17 @@ rendered interaction checks. Next implementation work should close §6.5, complete the example coverage, and record platform-specific build and interaction evidence. The shared document is the contract; no shared cross-language renderer or code generator is required. + +### First-pass verification — 2026-09-22 + +- JavaScript: 95 tests across five suites pass, including selector style removal, + composite heading targeting, wallet-member isolation and nested style validation. +- TypeScript: package, example page and documented composition sample pass. +- Lint: package has no errors (21 existing warnings); example page passes. +- Example data: both toggle states validate, each with 42 rows covering all 12 types. +- Android: `compileDebugKotlin` and `testDebugUnitTest` pass; seven tests in three + suites. Dependency Nitrogen outputs were generated locally for the build. +- iOS: `NativeListCell.swift` passes Swift syntax parsing only. +- No device or browser rendering/interaction acceptance has been completed for + this refactor. Geometry fitting, native data secondary/table style coverage, + non-Market image/gap support and the generic header/footer decision remain open. diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index 8741c471b..fec245310 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -550,6 +550,7 @@ final class NativeListCell: UICollectionViewCell { private var currentLayout = "linear" // OneKey patch: a row style wrote view properties that reset() does not restore. private var styledViewsDirty = false + private var styledTextRestorations: [() -> Void] = [] private var currentTheme: [String: Any]? private var currentItemIndex: Int? // OneKey patch: delayed image retries belong to the current reusable cell binding. @@ -1213,16 +1214,8 @@ final class NativeListCell: UICollectionViewCell { private func resetRowStyle() { guard styledViewsDirty else { return } styledViewsDirty = false - metricSubtitleLabel.font = nativeListFont(ofSize: 12) - mediaBadgeLabel.font = nativeListFont(ofSize: 14, weight: .medium) - dataLabels.forEach { $0.font = nativeListFont(ofSize: 12) } - let aligned: [UILabel] = [ - subtitleLabel, tertiaryLabel, statusLabel, badgeLabel, metricSubtitleLabel, - ] - aligned.forEach { - $0.textAlignment = .natural - $0.lineBreakMode = .byTruncatingTail - } + styledTextRestorations.reversed().forEach { $0() } + styledTextRestorations.removeAll(keepingCapacity: true) } private func reset() { @@ -2392,6 +2385,13 @@ final class NativeListCell: UICollectionViewCell { mainStack.spacing = CGFloat(style.double("lineGap")) } let variant = item.data.string("variant") + if item.type == "metricCard" && ["activity", "performance"].contains(variant) { + // Composite cards render the heading, not the standard card's value slot. + if let titleStyle = style.dictionary("title") { + applyStyledText(titleLabel, titleStyle) + } + return + } for (field, value) in style { guard let slotStyle = value as? [String: Any], let slot = styleSlot(type: item.type, variant: variant, field: field) @@ -2419,6 +2419,20 @@ final class NativeListCell: UICollectionViewCell { private func applyStyledText(_ label: UILabel, _ style: [String: Any]) { let text = label.attributedText?.string ?? label.text ?? "" guard !text.isEmpty else { return } + let originalFont = label.font + let originalColor = label.textColor + let originalText = label.attributedText + let originalLines = label.numberOfLines + let originalAlignment = label.textAlignment + let originalBreakMode = label.lineBreakMode + styledTextRestorations.append { + label.font = originalFont + label.textColor = originalColor + label.attributedText = originalText + label.numberOfLines = originalLines + label.textAlignment = originalAlignment + label.lineBreakMode = originalBreakMode + } let baseFont = label.font ?? nativeListFont(ofSize: 14) let size = CGFloat(style.double("fontSize", default: Double(baseFont.pointSize))) let font: UIFont @@ -2442,14 +2456,18 @@ final class NativeListCell: UICollectionViewCell { } label.font = font label.textColor = color - let paragraph = NSMutableParagraphStyle() - paragraph.alignment = label.textAlignment - paragraph.lineBreakMode = label.lineBreakMode - var attributes: [NSAttributedString.Key: Any] = [ - .font: font, - .foregroundColor: color, - .paragraphStyle: paragraph, - ] + let attributed = originalText.map { NSMutableAttributedString(attributedString: $0) } + ?? NSMutableAttributedString(string: text) + let paragraph = (attributed.attribute(.paragraphStyle, at: 0, effectiveRange: nil) + as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() + if style["alignment"] != nil { paragraph.alignment = label.textAlignment } + if style["lines"] != nil { paragraph.lineBreakMode = label.lineBreakMode } + var attributes: [NSAttributedString.Key: Any] = [:] + if style["fontSize"] != nil || style["fontWeight"] != nil { attributes[.font] = font } + if style["color"] != nil { attributes[.foregroundColor] = color } + if style["alignment"] != nil || style["lines"] != nil || style["lineHeight"] != nil { + attributes[.paragraphStyle] = paragraph + } if style["lineHeight"] != nil { let box = CGFloat(style.double("lineHeight")) paragraph.minimumLineHeight = box @@ -2457,7 +2475,8 @@ final class NativeListCell: UICollectionViewCell { // React Native centers font metrics inside an explicit line height. attributes[.baselineOffset] = max(0, (box - font.lineHeight) / 2) } - label.attributedText = NSAttributedString(string: text, attributes: attributes) + attributed.addAttributes(attributes, range: NSRange(location: 0, length: attributed.length)) + label.attributedText = attributed } private func applyStyledButton(_ button: UIButton, _ style: [String: Any]) { @@ -2465,6 +2484,16 @@ final class NativeListCell: UICollectionViewCell { ?? button.title(for: .normal) ?? "" guard !text.isEmpty else { return } + let originalFont = button.titleLabel?.font + let originalLines = button.titleLabel?.numberOfLines + let originalText = button.attributedTitle(for: .normal) + let originalAlignment = button.contentHorizontalAlignment + styledTextRestorations.append { + button.titleLabel?.font = originalFont + if let originalLines { button.titleLabel?.numberOfLines = originalLines } + button.setAttributedTitle(originalText, for: .normal) + button.contentHorizontalAlignment = originalAlignment + } let baseFont = button.titleLabel?.font ?? nativeListFont(ofSize: 14) let size = CGFloat(style.double("fontSize", default: Double(baseFont.pointSize))) let font: UIFont @@ -2480,19 +2509,22 @@ final class NativeListCell: UICollectionViewCell { let color = (style["color"] as? String) .map { UIColor(nativeListHex: $0, fallback: fallbackColor) } ?? fallbackColor - let paragraph = NSMutableParagraphStyle() - paragraph.alignment = button.titleLabel?.textAlignment ?? .natural + let attributed = button.attributedTitle(for: .normal) + .map { NSMutableAttributedString(attributedString: $0) } ?? NSMutableAttributedString(string: text) + let paragraph = (attributed.attribute(.paragraphStyle, at: 0, effectiveRange: nil) + as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() if let alignmentName = style["alignment"] as? String { paragraph.alignment = marketTextAlignment(alignmentName) button.contentHorizontalAlignment = alignmentName == "start" ? .leading : alignmentName == "end" ? .trailing : .center } - var attributes: [NSAttributedString.Key: Any] = [ - .font: font, - .foregroundColor: color, - .paragraphStyle: paragraph, - ] + var attributes: [NSAttributedString.Key: Any] = [:] + if style["fontSize"] != nil || style["fontWeight"] != nil { attributes[.font] = font } + if style["color"] != nil { attributes[.foregroundColor] = color } + if style["alignment"] != nil || style["lineHeight"] != nil { + attributes[.paragraphStyle] = paragraph + } if style["lineHeight"] != nil { let box = CGFloat(style.double("lineHeight")) paragraph.minimumLineHeight = box @@ -2503,10 +2535,8 @@ final class NativeListCell: UICollectionViewCell { button.titleLabel?.numberOfLines = min(2, max(1, style.int("lines", default: 1))) } button.titleLabel?.font = font - button.setAttributedTitle( - NSAttributedString(string: text, attributes: attributes), - for: .normal - ) + attributed.addAttributes(attributes, range: NSRange(location: 0, length: attributed.length)) + button.setAttributedTitle(attributed, for: .normal) } private func applyMarketTextStyle( diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index ec594bbbd..56c94e1e5 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -2,6 +2,7 @@ import type { IdentityRow, NativeListSnapshot, RowModel } from '../models'; import { WEB_LIST_CSS, WEB_REORDER_ANIMATION, + NativeListWebEngine, applyRowStyle, canStartWebWalletGroupReorder, cancelWebReorderRows, @@ -628,6 +629,64 @@ describe('web row style', () => { expect(value?.style.fontWeight).toBe('700'); }); + it.each(['activity', 'performance'] as const)( + 'targets the %s metric heading without restyling nested metric values', + (variant) => { + const body = render({ + type: 'metricCard', + key: 'composite', + variant, + title: 'Summary', + value: 'Unused standard value', + metrics: [ + { key: 'a', label: 'Sent', value: '12' }, + { key: 'b', label: 'Received', value: '34' }, + ], + style: { + title: { color: '#ff0000' }, + value: { color: '#00ff00' }, + }, + }); + const heading = body.querySelector('[data-nl-slot="title"]'); + expect(heading?.textContent).toBe('Summary'); + expect(heading?.style.color).toBe('rgb(255, 0, 0)'); + expect(body.querySelector('[data-nl-slot="value"]')).toBeNull(); + expect(body.textContent).toContain('12'); + expect(body.textContent).toContain('34'); + expect(body.querySelector('[style*="rgb(0, 255, 0)"]')).toBeNull(); + } + ); + + it('keeps wallet member typography independent from the group and siblings', () => { + const parent: IdentityRow = { + type: 'identity', + key: 'wallet', + presentation: 'walletSidebar', + leading: { kind: 'icon', name: 'StarOutline' }, + title: 'Parent wallet', + style: { title: { color: '#ff0000' }, lineGap: 7 }, + }; + const body = render({ + type: 'walletGroup', + key: 'wallet', + parent, + children: [ + { ...parent, key: 'child', title: 'Child wallet', style: undefined }, + ], + style: { lineGap: 13 }, + }); + const titles = body.querySelectorAll('[data-nl-slot="title"]'); + expect(titles).toHaveLength(2); + expect(titles[0]?.style.color).toBe('rgb(255, 0, 0)'); + expect(titles[1]?.style.color).toBe(''); + expect( + titles[0]?.closest('.ok-native-list-flex')?.style.rowGap + ).toBe('7px'); + expect( + titles[1]?.closest('.ok-native-list-flex')?.style.rowGap + ).toBe(''); + }); + it("keeps today's numbers as the chrome fallbacks", () => { // An untouched list must render exactly as before, so every chrome variable // carries the current value as its CSS fallback. @@ -657,4 +716,51 @@ describe('web row style', () => { expect(styled.style.paddingInline).toBe('16px'); expect(styled.style.paddingBlock).toBe('10px'); }); + + it.each(['accountSelector', 'networkSelector'] as const)( + 'applies %s styles after presentation defaults and restores them on removal', + (presentation) => { + const { document } = new JSDOM('').window; + const host = document.createElement('div'); + document.body.appendChild(host); + const base: IdentityRow = { + type: 'identity', + key: 'selector', + height: 76, + presentation, + leading: { kind: 'icon', name: 'StarOutline' }, + title: 'Selector title', + }; + const engine = new NativeListWebEngine( + host, + snapshot({ kind: 'sectioned' }, [ + { + ...base, + style: { + title: { fontSize: 18, lineHeight: 28, fontWeight: 'bold' }, + }, + }, + ]), + {}, + false + ); + try { + const title = () => + host.querySelector('[data-nl-slot="title"]'); + expect(title()?.style.fontSize).toBe('18px'); + expect(title()?.style.lineHeight).toBe('28px'); + expect(title()?.style.fontWeight).toBe('700'); + const item = host.querySelector( + '[data-native-list-row-key="selector"]' + ); + const height = item?.style.height; + engine.applySnapshot(snapshot({ kind: 'sectioned' }, [base])); + expect(title()?.style.lineHeight).toBe('24px'); + expect(title()?.style.fontWeight).not.toBe('700'); + expect(item?.style.height).toBe(height); + } finally { + engine.destroy(); + } + } + ); }); diff --git a/native-views/react-native-native-list/src/__tests__/validation.test.ts b/native-views/react-native-native-list/src/__tests__/validation.test.ts index 31b9de0d1..e5766b2ba 100644 --- a/native-views/react-native-native-list/src/__tests__/validation.test.ts +++ b/native-views/react-native-native-list/src/__tests__/validation.test.ts @@ -1269,6 +1269,34 @@ describe('NativeList style contract', () => { ); }); + it.each([ + ['title', null], + ['title', 'large'], + ['title', []], + ['image', null], + ['image', 32], + ['image', []], + ])('rejects malformed nested %s styles', (field, value) => { + expect(() => validateSnapshot(styled({ [field]: value }))).toThrow( + `style.${field}: must be an object` + ); + }); + + it('rejects arbitrary nested layout styles in snapshots and patches', () => { + for (const style of [ + { title: { position: 'absolute' } }, + { image: { transform: 'scale(2)' } }, + { subtitle: { color: 123 } }, + ]) { + expect(() => validateSnapshot(styled(style))).toThrow('style.'); + expect(() => + validatePatches([ + { type: 'identity', key: 'btc', changes: { style } }, + ] as unknown as RowPatch[]) + ).toThrow('style.'); + } + }); + it('rejects an unknown token and out-of-range metrics', () => { expect(() => validateSnapshot(styled({ title: { token: '$displayXl' } })) diff --git a/native-views/react-native-native-list/src/validation.ts b/native-views/react-native-native-list/src/validation.ts index 32d094462..ec3dbac6b 100644 --- a/native-views/react-native-native-list/src/validation.ts +++ b/native-views/react-native-native-list/src/validation.ts @@ -212,9 +212,31 @@ const EXTRA_STYLE_KEYS_BY_ROW_TYPE: Readonly< function assertTextStyle( style: NativeListTextStyle | undefined, - path: string + path: string, + additionalKeys: readonly string[] = [] ): void { - if (!style) return; + if (style === undefined) return; + if (typeof style !== 'object' || style === null || Array.isArray(style)) { + fail(path, 'must be an object'); + } + assertUnknownKeys( + style as Record, + [ + 'token', + 'fontSize', + 'fontWeight', + 'color', + 'lineHeight', + 'lines', + 'alignment', + ...additionalKeys, + ], + path, + 'text style key' + ); + if (style.color !== undefined && typeof style.color !== 'string') { + fail(`${path}.color`, 'must be a color string'); + } if ( style.token !== undefined && !Object.prototype.hasOwnProperty.call(TYPOGRAPHY_TOKENS, style.token) @@ -254,7 +276,20 @@ function assertBoxStyle(style: RowBoxStyle, path: string): void { assertBoundedStyleNumber(style[field], `${path}.${field}`, 0, 64); } assertBoundedStyleNumber(style.lineGap, `${path}.lineGap`, 0, 16); - if (style.image) { + if (style.image !== undefined) { + if ( + typeof style.image !== 'object' || + style.image === null || + Array.isArray(style.image) + ) { + fail(`${path}.image`, 'must be an object'); + } + assertUnknownKeys( + style.image as Record, + ['width', 'height', 'shape', 'cornerRadius', 'contentFit'], + `${path}.image`, + 'image style key' + ); assertBoundedStyleNumber(style.image.width, `${path}.image.width`, 1, 160); assertBoundedStyleNumber( style.image.height, @@ -540,7 +575,10 @@ function assertMarketRow(row: MarketRow, path: string): void { badgeKeys.add(badge.key); assertText(badge.text, `${badgePath}.text`); // OneKey patch: share typography bounds with Market text styles. - assertTextStyle(badge.style, `${badgePath}.style`); + assertTextStyle(badge.style, `${badgePath}.style`, [ + 'height', + 'horizontalPadding', + ]); assertBoundedStyleNumber( badge.style?.height, `${badgePath}.style.height`, diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index c4b1fbfeb..deebfc295 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -2748,11 +2748,14 @@ function createMetricRow( 'ok-native-list-row ok-native-list-composite' ); body.appendChild( - createElement( - context.document, - 'div', - 'ok-native-list-composite-heading', - row.title + tagSlot( + createElement( + context.document, + 'div', + 'ok-native-list-composite-heading', + row.title + ), + 'title' ) ); const metrics = row.metrics ?? []; @@ -3263,6 +3266,7 @@ function createWalletGroupRow( // memberElement.appendChild(createIdentityActivityOrMessageRow(context, member)); const memberBody = createIdentityActivityOrMessageRow(context, member); applySelectorTabularNumbers(memberBody, member); + applyRowStyle(memberBody, member); memberElement.appendChild(memberBody); // OneKey patch: group children use their own measured badge height. memberElement.style.flexBasis = @@ -3644,7 +3648,7 @@ export function applyRowStyle(body: HTMLElement, row: RowModel): void { body.style.paddingInline = String(box.horizontalPadding) + 'px'; if (box.verticalPadding !== undefined) body.style.paddingBlock = String(box.verticalPadding) + 'px'; - if (box.lineGap !== undefined) { + if (box.lineGap !== undefined && row.type !== 'walletGroup') { const lineGap = String(box.lineGap) + 'px'; body .querySelectorAll('.ok-native-list-flex') @@ -4495,7 +4499,6 @@ export class NativeListWebEngine { }; const body = createRowBody(context, row); applySelectorTabularNumbers(body, row); - applyRowStyle(body, row); // OneKey patch: explicit selector fields preserve original page geometry. element.style.contain = row.backgroundFullWidth ? 'layout style' : ''; if (row.backgroundColor) body.style.backgroundColor = row.backgroundColor; @@ -4571,6 +4574,8 @@ export class NativeListWebEngine { }); } } + // Template and presentation defaults must precede caller overrides. + applyRowStyle(body, row); element.replaceChildren(body); if ( row.type === 'market' && From 7fa6bb53eb64a6f8bd903eb5965ad450faad5222 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 15:38:56 +0800 Subject: [PATCH 08/71] docs(native-list): assign overflow handling to integrators --- CHANGELOG.md | 4 +-- .../react-native-native-list/docs/DESIGN.md | 6 ++-- .../docs/STYLE_SPEC.md | 36 ++++++++++--------- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92dcdf79a..67e9aece0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,10 @@ All notable changes to this project will be documented in this file. - **native-list**: Add a bounded per-template row style. `style` keys name the model field they modify rather than the view that carries it, so `metricCard.style.value` reaches the large number even though it shares a view with `identity.style.title`. Typography can be given as a named step (`{ token: '$bodyLg' }`) which is resolved to numbers in JavaScript before serialization, so no native renderer learns the vocabulary; explicit values still override. Keys a template does not declare are rejected by `validateSnapshot` and `validatePatches`, and styles carried by a patch are resolved the same way. Implemented on Web: text slots for every template plus `horizontalPadding`, `verticalPadding`, and `lineGap`. Row heights are unchanged — a styled row that grows still needs an explicit `height`. ### Bug Fixes -- **native-list**: Apply Web selector styles after presentation defaults, isolate wallet-member styles, and target composite metric-card headings by their semantic role. Restore native text state on reuse while retaining unspecified iOS attributed-string properties. Propagate Android list chrome on initial snapshots and restrict custom group radius to actual grouped rows. Reject malformed nested text/image styles and unknown nested keys; layout-fit enforcement remains pending. +- **native-list**: Apply Web selector styles after presentation defaults, isolate wallet-member styles, and target composite metric-card headings by their semantic role. Restore native text state on reuse while retaining unspecified iOS attributed-string properties. Propagate Android list chrome on initial snapshots and restrict custom group radius to actual grouped rows. Reject malformed nested text/image styles and unknown nested keys; developers integrating the list own overflow prevention. ### Documentation -- **native-list**: Audit the consolidated spec against template definitions, local layout safety and common container capabilities. Record unresolved geometry-fit and generic header/footer interface decisions separately from implemented fixes. +- **native-list**: Audit the consolidated spec against template definitions, local layout responsibility and common container capabilities. Assign overflow handling to integrators and distinguish the generic header/footer interface question from implemented fixes. - **native-list**: Consolidate the row-style spec on current main. Define all 12 row types with structural diagrams, local style/layout boundaries, and shared header/footer, section, scroll and indexed-bar ownership. Record implementation and example-coverage gaps separately from the contract. - **native-list**: Add `docs/STYLE_SPEC.md`, the shared style vocabulary for rows, section headers, fixed footers, and empty states. It records the design tokens (aliased to the application's own token names), the per-template style surface keyed by model field, list chrome, the template isolation rules, and a review checklist. Cross-platform divergences — row-height tables, typography, the Android sticky-header renderer, list-wide source scale — are registered rather than changed. diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index e5491b490..5ac98bcec 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -29,8 +29,10 @@ the per-template style surface with each platform's current values, the template isolation rules, and the cross-platform divergences that are registered rather than fixed. Its container capability contract keeps header/footer placement, sections, scrolling and the indexed bar independent of content templates. A local -style must preserve the template skeleton and allocated row frame; current -numeric validation alone does not guarantee that a combination fits. +style must preserve the template skeleton and allocated row frame. Developers +integrating the list own overflow prevention and choose fitting content, style +values and row heights; the component does not implement combination-fit checks +or automatic size correction. The wrapper validates and normalizes data before serializing it. A structural change is one snapshot payload. Frequently changing fields use one batch patch diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index 3c0e86ff4..48e23d195 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -61,13 +61,14 @@ not yet a fully implemented contract: | Design intent | Audit conclusion / remaining decision | | --- | --- | | Each row template has a clear definition and illustration | All 12 types have a catalog entry and structural diagram. Variant-specific style applicability still needs coverage: composite metric-card `title` means its heading; the standard card's `value`, `subtitle` and `trend` do not name nested metric fields. | -| Local styles cannot break the row layout | §7.1 states the requirement, but numeric bounds do not enforce it. Retaining dimension overrides requires a fitting/rejection policy, including updates against existing row data, available width and native font metrics. Alternatively, the public surface can be restricted to appearance. This is an unresolved contract decision. | +| Local styles cannot change the template structure | The component owns the slot structure and style isolation. By the 2026-09-22 scope decision, developers integrating the list own overflow prevention: they choose fitting text/image/padding values and row heights. Automatic measurement, combination-fit rejection and style-driven auto-height are not part of this work. | | Header/footer/sections/scroll/indexed bar are common capabilities | Ownership is correctly assigned to the container. Existing section headers and fixed action/system footers are independent of business templates. A generic header/footer slot needs an explicit interface decision covering placement, measurement, scroll indexing and event routing. | The five source PRs are closed; implementation continues on the consolidated branch. The first refactor pass fixes existing field targeting, nested style validation, text-state restoration and list-chrome scope. It does not settle the -open interface decisions or claim layout-fit enforcement. +header/footer interface questions or provide layout-fit enforcement. Overflow +handling belongs to the developer integrating the list, not a pending component feature. ## 2. How this spec is enforced @@ -94,7 +95,7 @@ object identity. | Layer | State | | --- | --- | -| Key validation, token resolution, patch support | Implemented; combination-fit protection is pending | +| Key validation, token resolution, patch support | Implemented; combination fitting is the integrator's responsibility | | Text slots, `horizontalPadding`, `verticalPadding`, `lineGap` | Style passes exist on Web, iOS and Android; coverage and reset gaps remain (§6.5) | | `leadingGap`, `trailingGap`, `titleBadgeGap`, `image` | Validated but applied only on `market`. The others need a per-template default gap, which is not readable from the DOM on Web and is spread across the binders on the native sides | | `listStyle.separator` and `listStyle.groupCornerRadius` | Android initial snapshot propagation and radius scope corrected; rendered verification pending (§6.5) | @@ -532,7 +533,7 @@ fixes and regression tests below do not establish native rendered acceptance. | Android grouped-radius scope | Custom radius now requires actual `groupId` membership, not a synthetic `single` position | Grouped rows change; standalone selectors and metric cards retain template geometry | | Wallet-group member scope on Web | Members now apply their own styles; group `lineGap` no longer traverses member text columns | Parent and child styles must remain independent on every platform | | Example inventory | `NativeListRowStylePage.buildRows` now covers all 12 types | Complete relevant variants and execute §9 | -| Validation is not a layout-fit check | Text/image nested object shape and unknown keys are now checked. Numeric bounds still do not detect insufficient space | Decide fitting policy and satisfy §7.1 before claiming safe arbitrary overrides | +| Layout fitting is an integration responsibility | Text/image nested object shape, unknown keys and numeric bounds are checked; available space is not | Integrators choose fitting parameters and verify their layouts (§7.1); no component-level overflow prevention is planned | | General header slot is absent | `NativeListProps`, `NativeListSnapshot` | Define one container-level header contract before expanding the public API | `leadingGap`, `trailingGap`, `titleBadgeGap` and `image` remain accepted but unused @@ -561,10 +562,12 @@ affect any other row. Four rules: prove that the whole combination fits. Invalid style must not affect another row or crash the list. §7.1 states the required layout boundary. -### 7.1 Local styling must preserve template layout +### 7.1 Template structure and integration layout responsibility -These are **normative acceptance rules**, not additional API keys or a claim that -the current validators enforce geometry: +The component preserves template structure and style isolation. Developers +integrating the list are responsible for choosing sizes and content that fit; +overflow measurement, automatic size correction and combination-fit rejection +are explicitly out of scope. The following rules distinguish those responsibilities: 1. **Keep the skeleton fixed.** A style may change a declared semantic field's typography/color and supported local padding/gaps/image metrics. It cannot @@ -577,13 +580,13 @@ the current validators enforce geometry: caller must supply an appropriate explicit `row.height` as a separate geometry change and verify it with the container. There is no style-driven auto-height in this stack. -3. **Fit all content within that frame.** After local padding, each visual/control +3. **Integrator: fit content within that frame.** After local padding, each visual/control and text line box must fit. For a simple two-line identity row, a necessary check is `2 * verticalPadding + max(visualHeight, titleLineHeight + lineGap + subtitleLineHeight, trailingHeight) <= row.height`. More lines and template subrows add their own budget. This formula is an example, not a general row measurement algorithm; it does not cover platform font scaling or rounding. -4. **Preserve compression and interaction.** Text may truncate/wrap only inside +4. **Integrator: verify compression and interaction.** Text may truncate/wrap only inside its declared slot and line limit. It must not push a checkbox, menu, price or action outside the row, overlap a neighbor, or steal another control's hit target. Do not shrink hit areas to make an oversized style fit. `start`/`end` @@ -591,8 +594,8 @@ the current validators enforce geometry: 5. **Reject unsupported structure.** No arbitrary React/RN/CSS style bag is part of this contract. `position`, `transform`, `flexDirection`, negative margins, freeform children and view-slot names are not supported style parameters. - Public types and nested runtime allowlists constrain this surface; - combination-fit protection remains incomplete (§6.5). + Public types and nested runtime allowlists constrain this surface. These + checks deliberately do not validate whether a combination fits the row. 6. **Retain local scope through every path.** Snapshot, patch, style removal, scrolling reuse, footer binding and sticky rendering must agree. A parent `walletGroup.style` must not silently become the child members' text style; @@ -643,8 +646,8 @@ For any PR that touches list typography or geometry: (§7 rule 3)? - [ ] For a section header: does the Android sticky decoration need the same change (§6.3)? -- [ ] Does each style-only update preserve the allocated row frame, slot order, - neighbor position and control hit areas (§7.1)? +- [ ] Does each style-only update preserve the allocated row frame and slot order? + Has the integrator verified fitting content and control hit areas (§7.1)? - [ ] Can a content template be replaced while retaining header/footer, sections, scrolling and the indexed bar (§5.1)? @@ -658,7 +661,7 @@ rendered interaction checks. | --- | --- | --- | | Template inventory | All 12 types in the catalog; selector presentations; composite metric cards; linear/table data rows; system variants | Named fields receive the intended style, unsupported fields fail explicitly | | Baseline | Style omitted, style toggled off, existing Market consumers | Platform's baseline rendering and row geometry preserved | -| Layout boundary | Narrow width, long/localized text, one/two lines, RTL, supported font scaling, largest supported fitting style | No overlap, clipping, moved controls or neighbor-frame drift; invalid combinations are not advertised as safe | +| Integration layout | Integrator-selected values at narrow widths, with long/localized text, RTL and supported font scaling | The integrator verifies overflow and hit areas; the component preserves the template structure and allocated frame | | Reuse and updates | Styled → unstyled → another template; snapshot and patch; rapid scrolling | No leaked typography, alignment, colors or metrics | | Public composition | Mixed `identity`/`market`/`dataRow` sections, summary header, fixed footer, indexed bar | Swapping content templates preserves common capabilities and stable scroll targets | | Scroll / chrome | Initial scroll, scroll to key/index/location/end, resize, refresh, pagination, initial/chrome-only snapshots | Correct target/offset and consistent separator/group style without row-size changes | @@ -679,5 +682,6 @@ the contract; no shared cross-language renderer or code generator is required. suites. Dependency Nitrogen outputs were generated locally for the build. - iOS: `NativeListCell.swift` passes Swift syntax parsing only. - No device or browser rendering/interaction acceptance has been completed for - this refactor. Geometry fitting, native data secondary/table style coverage, - non-Market image/gap support and the generic header/footer decision remain open. + this refactor. Native data secondary/table style coverage, non-Market image/gap + support and the generic header/footer question remain open. Geometry fitting + is assigned to integrators and is not a remaining implementation gap. From 0cd86631751eacc35f8cb7cbcff41361c36704f9 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 17:05:57 +0800 Subject: [PATCH 09/71] fix(native-list): align row style contracts across renderers --- CHANGELOG.md | 7 +- .../pages/NativeListRowStylePage.tsx | 22 +- .../com/onekey/nativelist/NativeListModels.kt | 3 +- .../onekey/nativelist/NativeListRowView.kt | 367 +++++++++++------- .../com/onekey/nativelist/NativeListView.kt | 202 +++++----- .../nativelist/NativeListStyleSlotTest.kt | 5 +- .../react-native-native-list/docs/DESIGN.md | 10 + .../docs/ROW_TEMPLATES.md | 34 +- .../docs/STYLE_SPEC.md | 226 +++++++---- .../ios/NativeListCell.swift | 285 +++++++++----- .../src/__tests__/NativeList.web.test.ts | 197 +++++++++- .../src/__tests__/validation.test.ts | 51 +++ .../react-native-native-list/src/models.ts | 119 +++++- .../src/validation.ts | 21 +- .../src/web/NativeListWebEngine.ts | 268 +++++++++++-- 15 files changed, 1303 insertions(+), 514 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67e9aece0..6e727adf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,16 @@ All notable changes to this project will be documented in this file. ### Features - **native-list**: Add `snapshot.listStyle` for list chrome: `separator.inset`, `separator.color`, and `groupCornerRadius`, applied on all three platforms. The surface carries only what every platform can honour — pull to refresh is a system control on both native platforms, the section index belongs to `capabilities.sectionIndex`, and the reorder preview is drawn with platform-specific primitives — and `validateSnapshot` rejects any other key rather than accepting one that some platform would ignore. Every value is absent by default and each platform keeps its own number as the fallback, so an untouched list renders exactly as before; on Web the inset separator keeps a transparent border for layout and paints the line with a logical-inset overlay, so row heights do not move. - **native-list (iOS and Android)**: Apply the row style natively. A style key is mapped to the view that renders that model field by `nativeListStyleSlot` (Kotlin, unit-tested) and `styleSlot` (Swift), because the view pool is shared and the mapping is not one to one. Both platforms gained a `resetRowStyle` that runs before the binder: Android's `resetViews` restores neither `textSize`, `typeface` nor `lineHeight`, and iOS's `reset` misses the data, metric and media label fonts, so without it a style would leak into the next row reusing the view. On iOS the pass rebuilds the attributed string, because the binders install one through `setLineHeight` and a plain `font` assignment would not take effect. -- **native-list**: Add a bounded per-template row style. `style` keys name the model field they modify rather than the view that carries it, so `metricCard.style.value` reaches the large number even though it shares a view with `identity.style.title`. Typography can be given as a named step (`{ token: '$bodyLg' }`) which is resolved to numbers in JavaScript before serialization, so no native renderer learns the vocabulary; explicit values still override. Keys a template does not declare are rejected by `validateSnapshot` and `validatePatches`, and styles carried by a patch are resolved the same way. Implemented on Web: text slots for every template plus `horizontalPadding`, `verticalPadding`, and `lineGap`. Row heights are unchanged — a styled row that grows still needs an explicit `height`. +- **native-list**: Add a bounded per-template row style. `style` keys name the model field they modify rather than the view that carries it, so `metricCard.style.value` reaches the large number even though it shares a view with `identity.style.title`. Typography can be given as a named step (`{ token: '$bodyLg' }`) which is resolved to numbers in JavaScript before serialization, so no native renderer learns the vocabulary; explicit values still override. Keys a template does not declare are rejected by `validateSnapshot` and `validatePatches`, and styles carried by a patch are resolved the same way. Text slots and template-applicable image/padding/gap parameters are implemented on Web, iOS and Android. Row heights are unchanged — a styled row that grows still needs an explicit `height`. ### Bug Fixes +- **native-list**: Align the configurable row-style contract across Web, iOS and Android. Apply non-Market image/spacing overrides; isolate primary and secondary data-column text in linear/table layouts; preserve styles through selection and summary updates; reuse the ordinary Android row renderer for pinned headers including values and checkboxes. Reject template-inapplicable box keys and nonportable text colors. Keep template structure, local styling and shared list capabilities distinct; overflow fitting remains caller-owned. - **native-list**: Apply Web selector styles after presentation defaults, isolate wallet-member styles, and target composite metric-card headings by their semantic role. Restore native text state on reuse while retaining unspecified iOS attributed-string properties. Propagate Android list chrome on initial snapshots and restrict custom group radius to actual grouped rows. Reject malformed nested text/image styles and unknown nested keys; developers integrating the list own overflow prevention. ### Documentation -- **native-list**: Audit the consolidated spec against template definitions, local layout responsibility and common container capabilities. Assign overflow handling to integrators and distinguish the generic header/footer interface question from implemented fixes. +- **native-list**: Audit the consolidated spec against template definitions, local layout responsibility and common container capabilities. Assign overflow handling to integrators and document existing structural headers, fixed/scrolling footers and pager header composition. - **native-list**: Consolidate the row-style spec on current main. Define all 12 row types with structural diagrams, local style/layout boundaries, and shared header/footer, section, scroll and indexed-bar ownership. Record implementation and example-coverage gaps separately from the contract. -- **native-list**: Add `docs/STYLE_SPEC.md`, the shared style vocabulary for rows, section headers, fixed footers, and empty states. It records the design tokens (aliased to the application's own token names), the per-template style surface keyed by model field, list chrome, the template isolation rules, and a review checklist. Cross-platform divergences — row-height tables, typography, the Android sticky-header renderer, list-wide source scale — are registered rather than changed. +- **native-list**: Add `docs/STYLE_SPEC.md`, the shared style vocabulary for rows, section headers, fixed footers, and empty states. It records the design tokens (aliased to the application's own token names), the per-template style surface keyed by model field, list chrome, the template isolation rules, and a review checklist. Legacy row-height, typography and Android baseline scaling differences are documented separately from the unified configurable style contract. ### Chores - Add the "Native List Row Style" example page with plain/styled pairs for all 12 row types, plus list chrome and explicit-height examples. Variant coverage and native visual acceptance remain pending; see `docs/STYLE_SPEC.md`. diff --git a/example/react-native/pages/NativeListRowStylePage.tsx b/example/react-native/pages/NativeListRowStylePage.tsx index 98bc2c52b..e0d1d587a 100644 --- a/example/react-native/pages/NativeListRowStylePage.tsx +++ b/example/react-native/pages/NativeListRowStylePage.tsx @@ -70,6 +70,9 @@ function buildRows(styled: boolean): RowModel[] { value: { token: '$bodySm' as const }, horizontalPadding: 20, lineGap: 2, + leadingGap: 16, + titleBadgeGap: 6, + image: { width: 32, height: 32, shape: 'rounded' as const }, }, } : {}), @@ -161,8 +164,8 @@ function buildRows(styled: boolean): RowModel[] { : {}), }); - // Cover the remaining row types with appearance-only overrides. These pairs - // keep geometry identical so field targeting can be checked independently. + // Cover remaining templates with local text/image/gap overrides. Outer row + // allocation stays fixed; callers choose fitting dimensions. const additional: readonly [RowModel, RowModel['style']][] = [ [ { @@ -186,7 +189,12 @@ function buildRows(styled: boolean): RowModel[] { { key: 'value', text: '$64,230', alignment: 'end' }, ], }, - { index: { color: '#0D74CE' } }, + { + index: { color: '#0D74CE' }, + columns: { fontSize: 16, color: '#0D74CE' }, + columnSecondary: { fontSize: 12, color: '#108303' }, + lineGap: 4, + }, ], [ { @@ -210,7 +218,13 @@ function buildRows(styled: boolean): RowModel[] { title: 'Collectible', subtitle: 'Ethereum', }, - { title: { color: '#0D74CE' }, subtitle: { color: '#108303' } }, + { + title: { color: '#0D74CE' }, + subtitle: { color: '#108303' }, + image: { width: 120, height: 80, shape: 'rounded' }, + leadingGap: 8, + lineGap: 4, + }, ], [ { diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index 99fe28997..54cf20d0f 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -60,6 +60,7 @@ internal fun nativeListStyleSlot(type: String, variant: String, field: String): } "dataRow" -> when (field) { "columns" -> "dataPrimary" + "columnSecondary" -> "dataSecondary" "index" -> "value" else -> null } @@ -85,7 +86,7 @@ internal fun nativeListStyleSlot(type: String, variant: String, field: String): else -> null } "system" -> when (field) { - "title" -> "title" + "title" -> if (variant == "warning") "title" else null // Only the warning variant renders a separate title; every other variant // puts its message in the title view. "message" -> if (variant == "warning") "subtitle" else "title" diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index 92b34b8d1..52d3c5679 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -310,21 +310,26 @@ private class NativeListTableColumnView(context: android.content.Context) : Line TextViewCompat.setLineHeight(label, dp(16)) } secondaryLeading.maxWidth = dp(120) - secondaryLine.addView(secondaryLeading, LayoutParams(LayoutParams.WRAP_CONTENT, dp(16))) - secondaryLine.addView(secondary, LayoutParams(LayoutParams.WRAP_CONTENT, dp(16)).apply { + secondaryLine.addView(secondaryLeading, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + secondaryLine.addView(secondary, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { marginStart = dp(4) }) - primaryLine.addView(primary, LayoutParams(LayoutParams.WRAP_CONTENT, dp(20))) - primaryLine.addView(badges, LayoutParams(LayoutParams.WRAP_CONTENT, dp(16)).apply { + primaryLine.addView(primary, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + primaryLine.addView(badges, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { marginStart = dp(6) }) - addView(primaryLine, LayoutParams(LayoutParams.WRAP_CONTENT, dp(20))) - addView(secondaryLine, LayoutParams(LayoutParams.MATCH_PARENT, dp(16)).apply { + addView(primaryLine, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + addView(secondaryLine, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { topMargin = dp(4) }) } fun reset() { + primaryLine.layoutParams.width = LayoutParams.WRAP_CONTENT + primary.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + secondary.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { marginStart = dp(4) } + (secondaryLine.layoutParams as MarginLayoutParams).topMargin = dp(4) + (badges.layoutParams as MarginLayoutParams).marginStart = dp(6) primary.text = "" secondaryLeading.text = "" secondary.text = "" @@ -341,8 +346,10 @@ private class NativeListTableColumnView(context: android.content.Context) : Line primaryColor: Int, secondaryColor: Int, infoColor: Int, + primarySize: Float, ) { reset() + primary.textSize = sp(primarySize) gravity = when (column.optString("alignment", "start")) { "center" -> Gravity.CENTER_HORIZONTAL "end" -> Gravity.END @@ -370,7 +377,7 @@ private class NativeListTableColumnView(context: android.content.Context) : Line cornerRadius = dp(4).toFloat() } } - badges.addView(badge, LayoutParams(LayoutParams.WRAP_CONTENT, dp(16)).apply { + badges.addView(badge, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { if (index > 0) marginStart = dp(4) }) } @@ -391,6 +398,23 @@ private class NativeListTableColumnView(context: android.content.Context) : Line } } + fun applyStyle(style: JSONObject, applyText: (TextView, JSONObject) -> Unit) { + style.optJSONObject("columns")?.let { + applyText(primary, it) + if (it.has("alignment")) { + primaryLine.layoutParams.width = LayoutParams.MATCH_PARENT + primary.layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) + } + } + style.optJSONObject("columnSecondary")?.let { + applyText(secondaryLeading, it) + applyText(secondary, it) + if (it.has("alignment")) secondary.layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f).apply { marginStart = dp(4) } + } + (secondaryLine.layoutParams as MarginLayoutParams).topMargin = style.optDouble("lineGap", 4.0).let { (it * resources.displayMetrics.density).roundToInt() } + (badges.layoutParams as MarginLayoutParams).marginStart = style.optDouble("titleBadgeGap", 6.0).let { (it * resources.displayMetrics.density).roundToInt() } + } + private fun dp(value: Int): Int = NativeListScale.dp(resources, value) private fun sp(value: Float): Float = NativeListScale.font(resources, value) } @@ -484,8 +508,6 @@ internal class NativeListRowView( private val trailingIcons = List(2) { OneKeyIconView(context) } private val checkbox = OneKeyCheckboxView(context) private val spinner = ProgressBar(context) - private val dataContainer = LinearLayout(context) - private val dataColumns = List(4) { TextView(context) } private val tableDataContainer = LinearLayout(context) private val tableDataColumns = List(4) { NativeListTableColumnView(context) } private val unreadDot = View(context) @@ -527,6 +549,11 @@ internal class NativeListRowView( private var styledViewsDirty = false private val styledTextRestorations = mutableMapOf Unit>() private var compositeMetricTitle: TextView? = null + private val styledBoxRestorations = mutableListOf<() -> Unit>() + private val semanticBadgeLabels = mutableListOf() + private val semanticSubtitleLabels = mutableListOf() + private val semanticValueViews = mutableListOf() + private var walletBadgeLine: View? = null private var reorderActive = false private var checkboxCheckedColor = Color.rgb(32, 32, 32) private var checkboxUncheckedColor = Color.rgb(252, 252, 252) @@ -639,9 +666,6 @@ internal class NativeListRowView( trailingColumn.addView(checkbox) trailingColumn.addView(spinner) - dataContainer.orientation = HORIZONTAL - dataContainer.gravity = Gravity.CENTER_VERTICAL - dataColumns.forEach { dataContainer.addView(it) } tableDataContainer.orientation = HORIZONTAL tableDataContainer.gravity = Gravity.CENTER_VERTICAL tableDataColumns.forEach { tableDataContainer.addView(it) } @@ -801,7 +825,15 @@ internal class NativeListRowView( if (isMediaTile) { val availableWidth = (MeasureSpec.getSize(widthMeasureSpec) - paddingLeft - paddingRight) .coerceAtLeast(0) - leadingFrame.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, availableWidth) + val imageStyle = (tag as? NativeListItem)?.json?.optJSONObject("style")?.optJSONObject("image") + val params = leadingFrame.layoutParams + params.width = if (imageStyle?.has("width") == true) styleDp(imageStyle.optDouble("width")) else LayoutParams.MATCH_PARENT + params.height = when { + imageStyle?.has("height") == true -> styleDp(imageStyle.optDouble("height")) + imageStyle?.has("width") == true -> styleDp(imageStyle.optDouble("width")) + else -> availableWidth + } + leadingFrame.layoutParams = params } // OneKey patch: RecyclerView must use the adapter's measured selector height. super.onMeasure(widthMeasureSpec, selectorHeight?.let { MeasureSpec.makeMeasureSpec(it, MeasureSpec.EXACTLY) } ?: heightMeasureSpec) @@ -1043,9 +1075,9 @@ internal class NativeListRowView( TextViewCompat.setLineHeight(subtitle, dp(20)) minimumHeight = 0 } - applyRowStyle(item) applyListOrientation(item, listOrientation) applySelectorTypography(item) + applyRowStyle(item) } /** @@ -1059,18 +1091,88 @@ internal class NativeListRowView( if (item.type == "market") return val style = item.json.optJSONObject("style") ?: return styledViewsDirty = true + val padding = intArrayOf(paddingLeft, paddingTop, paddingRight, paddingBottom) + styledBoxRestorations.add { setPadding(padding[0], padding[1], padding[2], padding[3]) } if (style.has("horizontalPadding")) { - val inset = dp(style.optDouble("horizontalPadding").roundToInt()) + val inset = styleDp(style.optDouble("horizontalPadding")) setPadding(inset, paddingTop, inset, paddingBottom) } if (style.has("verticalPadding")) { - val inset = dp(style.optDouble("verticalPadding").roundToInt()) + val inset = styleDp(style.optDouble("verticalPadding")) setPadding(paddingLeft, inset, paddingRight, inset) } - if (style.has("lineGap")) { - (subtitle.layoutParams as? MarginLayoutParams)?.topMargin = - dp(style.optDouble("lineGap").roundToInt()) + if (style.has("lineGap") && item.type != "walletGroup" && item.type != "dataRow") { + val target = if (item.type == "metricCard" && item.json.optString("variant") in setOf("activity", "performance")) this else mainColumn + styleGap(target, styleDp(style.optDouble("lineGap"))) + } + if (style.has("leadingGap") && leadingFrame.parent != null) { + val gap = styleDp(style.optDouble("leadingGap")) + if ((leadingFrame.parent as? LinearLayout)?.orientation == VERTICAL) { + styleMargins(leadingFrame, end = 0, bottom = gap) + if (mainColumn.parent === this) styleMargins(mainColumn, top = 0) + } else styleMargins(leadingFrame, end = gap) + } + if (style.has("titleBadgeGap")) { + val gap = styleDp(style.optDouble("titleBadgeGap")) + walletBadgeLine?.let { styleMargins(it, top = gap) } ?: styleMargins(badgeLine, start = gap) + } + if (style.has("trailingGap")) { + val gap = styleDp(style.optDouble("trailingGap")) + if (item.type == "rail") styleMargins(status, start = gap) + else styleGap(trailingColumn, gap) + } + style.optJSONObject("image")?.let { image -> + if (leadingFrame.parent != null) { + val params = leadingFrame.layoutParams + val oldWidth = params.width + val oldHeight = params.height + styledBoxRestorations.add { params.width = oldWidth; params.height = oldHeight; leadingFrame.layoutParams = params } + if (image.has("width")) params.width = styleDp(image.optDouble("width")) + if (image.has("height")) params.height = styleDp(image.optDouble("height")) + leadingFrame.layoutParams = params + if (image.has("width") || image.has("height")) { + listOf(leadingImages[0], leadingFallback).forEach { child -> + val childParams = child.layoutParams + val width = childParams.width + val height = childParams.height + styledBoxRestorations.add { childParams.width = width; childParams.height = height; child.layoutParams = childParams } + if (image.has("width")) childParams.width = params.width + if (image.has("height")) childParams.height = params.height + child.layoutParams = childParams + } + } + val shape = image.optString("shape") + val radius = when { + image.has("cornerRadius") -> styleDp(image.optDouble("cornerRadius")).toFloat() + shape == "square" -> 0f + shape == "rounded" -> styleDp(10.0).toFloat() + shape == "circle" -> -1f + else -> null + } + if (radius != null) { + listOf(leadingFrame, leadingImages[0], leadingFallback).forEach { view -> + val original = view.outlineProvider + val clip = view.clipToOutline + styledBoxRestorations.add { view.outlineProvider = original; view.clipToOutline = clip } + view.outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + if (radius < 0) outline.setOval(0, 0, view.width, view.height) + else outline.setRoundRect(0, 0, view.width, view.height, radius) + } + } + view.clipToOutline = view !== leadingFrame || (leadingImages[1].visibility == GONE && leadingCornerIconFrame.visibility == GONE && selectorViews.none { it.parent === leadingFrame }) + } + } + } + } + if (item.type == "activity" && actionLine.parent === this) { + if (style.has("lineGap")) styleMargins(actionLine, top = styleDp(style.optDouble("lineGap"))) + if (style.has("leadingGap") || style.optJSONObject("image")?.has("width") == true) { + val leading = leadingFrame.layoutParams as MarginLayoutParams + styleMargins(actionLine, start = leading.width + leading.marginEnd) + } } + if (item.type == "dataRow") tableDataColumns.forEach { it.applyStyle(style, ::applyStyledText) } val variant = item.json.optString("variant") if (item.type == "metricCard" && variant in setOf("activity", "performance")) { style.optJSONObject("title")?.let { titleStyle -> @@ -1084,12 +1186,42 @@ internal class NativeListRowView( val slotStyle = style.optJSONObject(field) ?: continue when (val slot = nativeListStyleSlot(item.type, variant, field)) { null -> continue - "dataPrimary" -> dataColumns.forEach { applyStyledText(it, slotStyle) } + "dataPrimary", "dataSecondary" -> Unit // Independent column labels are styled above. + "value", "valueSecondary" -> { + val view = if (item.type in setOf("identity", "action")) semanticValueViews.getOrNull(if (slot == "value") 0 else 1) else styledSlotView(slot) + view?.let { applyStyledText(it, slotStyle) } + } + "status" -> applyStyledText(if (item.type == "activity" && item.json.optString("status") == "Failed") badgeLine else status, slotStyle) + "badge" -> (semanticBadgeLabels.ifEmpty { listOf(badgeLine) }).forEach { applyStyledText(it, slotStyle) } + "subtitle" -> (semanticSubtitleLabels.ifEmpty { listOf(subtitle) }).forEach { applyStyledText(it, slotStyle) } else -> styledSlotView(slot)?.let { applyStyledText(it, slotStyle) } } } } + private fun styleDp(value: Double): Int = (value * resources.displayMetrics.density).roundToInt() + + private fun styleMargins(view: View, start: Int? = null, end: Int? = null, top: Int? = null, bottom: Int? = null) { + val params = view.layoutParams as? MarginLayoutParams ?: return + val saved = intArrayOf(params.marginStart, params.marginEnd, params.topMargin, params.bottomMargin) + styledBoxRestorations.add { + params.marginStart = saved[0]; params.marginEnd = saved[1] + params.topMargin = saved[2]; params.bottomMargin = saved[3] + view.layoutParams = params + } + start?.let { params.marginStart = it }; end?.let { params.marginEnd = it } + top?.let { params.topMargin = it }; bottom?.let { params.bottomMargin = it } + view.layoutParams = params + } + + private fun styleGap(stack: LinearLayout, gap: Int) { + val visible = (0 until stack.childCount).map(stack::getChildAt).filter { it.visibility != GONE } + visible.forEachIndexed { index, view -> + if (stack.orientation == HORIZONTAL) styleMargins(view, start = if (index == 0) 0 else gap, end = 0) + else styleMargins(view, top = if (index == 0) 0 else gap, bottom = 0) + } + } + private fun styledSlotView(slot: String): TextView? = when (slot) { "title" -> title "subtitle" -> subtitle @@ -1110,6 +1242,7 @@ internal class NativeListRowView( val textSize = view.textSize val typeface = view.typeface val colors = view.textColors + val originalText = view.text val lineSpacingExtra = view.lineSpacingExtra val lineSpacingMultiplier = view.lineSpacingMultiplier val maxLines = view.maxLines @@ -1119,21 +1252,34 @@ internal class NativeListRowView( view.setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, textSize) view.typeface = typeface view.setTextColor(colors) + view.text = originalText view.setLineSpacing(lineSpacingExtra, lineSpacingMultiplier) view.maxLines = maxLines view.ellipsize = ellipsize view.gravity = gravity } } - if (style.has("fontSize")) view.textSize = sp(style.optDouble("fontSize").toFloat()) + if (style.has("fontSize")) { + view.setTextSize(TypedValue.COMPLEX_UNIT_PX, (style.optDouble("fontSize") * resources.displayMetrics.density).toFloat()) + val text = SpannableStringBuilder(view.text) + text.getSpans(0, text.length, AbsoluteSizeSpan::class.java).forEach(text::removeSpan) + view.text = text + } style.optString("fontWeight").takeIf(String::isNotEmpty)?.let { view.typeface = marketTypeface(it, "regular") } style.optString("color").takeIf(String::isNotEmpty)?.let { - view.setTextColor(safeColor(it, view.currentTextColor)) + val color = safeColor(it, view.currentTextColor) + view.setTextColor(color) + val text = SpannableStringBuilder(view.text) + text.getSpans(0, text.length, ForegroundColorSpan::class.java).forEach(text::removeSpan) + view.text = text } if (style.has("lineHeight")) { - TextViewCompat.setLineHeight(view, dp(style.optDouble("lineHeight").roundToInt())) + val text = SpannableStringBuilder(view.text) + text.getSpans(0, text.length, SelectorLineHeightSpan::class.java).forEach(text::removeSpan) + view.text = text + TextViewCompat.setLineHeight(view, styleDp(style.optDouble("lineHeight"))) } if (style.has("lines")) { view.maxLines = style.optInt("lines").coerceIn(1, 2) @@ -1193,6 +1339,7 @@ internal class NativeListRowView( if (boundKey != item.key) return // OneKey patch: keep row callbacks and checkbox fallback state current without resetting images. tag = item + resetRowStyle() if (item.type == "walletGroup") { val members = buildList { add(item.json.getJSONObject("parent")) @@ -1214,6 +1361,7 @@ internal class NativeListRowView( // applying the translucent selected color to both levels produces dark // bands in the 12dp gaps and double-composites the parent highlight. background = restingRowBackground + applyRowStyle(item) return } applySelectionState(item, theme, layout, itemIndex, selected) @@ -1238,6 +1386,7 @@ internal class NativeListRowView( boundCheckboxData = latestCheckbox ?: boundCheckboxData boundCheckboxData?.let { bindCheckbox(item, it, checkboxState) } } + applyRowStyle(item) } fun bindStableSummary(item: NativeListItem) { @@ -1248,6 +1397,7 @@ internal class NativeListRowView( ) { return } + resetRowStyle() tag = item contentDescription = item.json.optString("accessibilityLabel", item.json.optString("title")) title.text = item.json.optString("title") @@ -1338,6 +1488,8 @@ internal class NativeListRowView( private fun resetRowStyle() { if (!styledViewsDirty) return styledViewsDirty = false + styledBoxRestorations.asReversed().forEach { it() } + styledBoxRestorations.clear() styledTextRestorations.values.forEach { it() } styledTextRestorations.clear() } @@ -1345,6 +1497,10 @@ internal class NativeListRowView( private fun resetViews() { resetRowStyle() compositeMetricTitle = null + semanticBadgeLabels.clear() + semanticSubtitleLabels.clear() + semanticValueViews.clear() + walletBadgeLine = null clipChildren = true clipToPadding = true selectorOriginalFontFeatures.forEach { (view, original) -> view.fontFeatureSettings = original } @@ -1546,8 +1702,6 @@ internal class NativeListRowView( leadingActionIcon.setTag(com.facebook.react.R.id.react_test_id, null) leadingActionIcon.contentDescription = null mainColumn.visibility = VISIBLE - dataContainer.visibility = GONE - dataColumns.forEach { it.visibility = GONE } tableDataContainer.visibility = GONE tableDataColumns.forEach { it.reset() @@ -1844,8 +1998,10 @@ internal class NativeListRowView( setPadding(dp(if (isSelector) 6 else 4), dp(2), dp(if (isSelector) 6 else 4), dp(2)) } TextViewCompat.setLineHeight(badge, dp(badgeLineHeight)) + semanticBadgeLabels.add(badge) line.addView(badge, LayoutParams(LayoutParams.WRAP_CONTENT, dp(badgeHeight)).apply { if (index > 0) marginStart = dp(4) }) } + walletBadgeLine = line mainColumn.addView(line, LayoutParams(LayoutParams.WRAP_CONTENT, dp(badgeHeight)).apply { topMargin = dp(4) }) selectorViews.add(line) } @@ -1926,6 +2082,7 @@ internal class NativeListRowView( } TextViewCompat.setLineHeight(label, dp(20)) applyValueSegments(label, segment.optJSONArray("textSegments"), 14, 20, false) + semanticSubtitleLabels.add(label) line.addView(label, LayoutParams(LayoutParams.WRAP_CONTENT, dp(20))) } mainColumn.addView(line, 2, LayoutParams(LayoutParams.MATCH_PARENT, dp(20))) @@ -2156,54 +2313,23 @@ internal class NativeListRowView( } val columns = item.json.getJSONArray("columns") val rowBadges = item.json.optJSONArray("badges") - if (currentLayout == "table") { - tableDataContainer.visibility = VISIBLE - for (index in 0 until minOf(4, columns.length())) { - val column = columns.getJSONObject(index) - tableDataColumns[index].visibility = VISIBLE - tableDataColumns[index].bind( - column = column, - rowBadges = if (index == 0) rowBadges else null, - primaryColor = dataTextColor(column.optString("tone"), theme), - secondaryColor = dataTextColor( - column.optString("secondaryTone", "secondary"), - theme, - ), - infoColor = color(theme, "info", "#006DCBF2"), - ) - tableDataColumns[index].layoutParams = LayoutParams( - 0, - dp(40), - column.optInt("weight", 1).toFloat(), - ) - } - addView(tableDataContainer, weighted()) - return - } - - dataContainer.visibility = VISIBLE + tableDataContainer.visibility = VISIBLE for (index in 0 until minOf(4, columns.length())) { val column = columns.getJSONObject(index) - val view = dataColumns[index] - view.text = styledDataText(column, if (index == 0) rowBadges else null, theme) - view.maxLines = if (column.optString("secondaryText").isEmpty()) 1 else 2 - view.gravity = when (column.optString("alignment", "start")) { - "center" -> Gravity.CENTER - "end" -> Gravity.END - else -> Gravity.START - } - view.visibility = VISIBLE - view.setTextColor( - when (column.optString("tone", "primary")) { - "secondary" -> color(theme, "secondaryText", "#0000009B") - "positive" -> color(theme, "positive", "#00713FDE") - "negative" -> color(theme, "negative", "#C40006D3") - else -> color(theme, "primaryText", "#000000DF") - }, + tableDataColumns[index].visibility = VISIBLE + tableDataColumns[index].bind( + column = column, + rowBadges = if (index == 0) rowBadges else null, + primaryColor = dataTextColor(column.optString("tone"), theme), + secondaryColor = dataTextColor(column.optString("secondaryTone", "secondary"), theme), + infoColor = color(theme, "info", "#006DCBF2"), + primarySize = if (currentLayout == "table") 14f else 16f, + ) + tableDataColumns[index].layoutParams = LayoutParams( + 0, LayoutParams.WRAP_CONTENT, column.optInt("weight", 1).toFloat(), ) - view.layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, column.optInt("weight", 1).toFloat()) } - addView(dataContainer, weighted()) + addView(tableDataContainer, weighted()) } private fun marketTypeface(weight: String, fallback: String): Typeface = when ( @@ -2581,49 +2707,6 @@ internal class NativeListRowView( ) } - private fun styledDataText( - column: JSONObject, - badges: JSONArray?, - theme: JSONObject?, - ): CharSequence { - val result = SpannableStringBuilder(column.optString("text")) - result.setSpan( - ForegroundColorSpan(dataTextColor(column.optString("tone"), theme)), - 0, - result.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - if (badges != null) { - for (index in 0 until minOf(2, badges.length())) { - val start = result.length - result.append(" ${badges.getJSONObject(index).optString("text")} ") - result.setSpan( - ForegroundColorSpan(color(theme, "info", "#006DCBF2")), - start, - result.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - result.setSpan( - BackgroundColorSpan(parseNativeListColor("#008FF519")), - start, - result.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - } - } - column.optString("secondaryText").takeIf(String::isNotEmpty)?.let { secondary -> - val start = result.length - result.append("\n$secondary") - result.setSpan( - ForegroundColorSpan(dataTextColor(column.optString("secondaryTone", "secondary"), theme)), - start, - result.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - } - return result - } - private fun dataTextColor(tone: String, theme: JSONObject?): Int = when (tone.ifEmpty { "primary" }) { "secondary" -> color(theme, "secondaryText", "#0000009B") "positive" -> color(theme, "positive", "#00713FDE") @@ -3339,7 +3422,7 @@ internal class NativeListRowView( setOnClickListener { onRowPress?.invoke(item, actionOrigin(this, "row")) } - showTrailing(0, "Retry", true, item.json.optString("actionKey")) + showTrailing(0, item.json.optString("actionText", "Retry"), true, item.json.optString("actionKey")) trailingViews[0].textSize = sp(14f) trailingViews[0].background = roundedFill(color(theme, "strongBackground", "#0000000F"), 16f) trailingViews[0].setPadding(dp(10), dp(4), dp(10), dp(4)) @@ -3356,14 +3439,17 @@ internal class NativeListRowView( heightDp: Int = sizeDp, cornerRadiusDp: Float? = null, ) { + val imageStyle = (tag as? NativeListItem)?.json?.optJSONObject("style")?.optJSONObject("image") + val styledWidthDp = imageStyle?.optDouble("width", sizeDp.toDouble())?.roundToInt() ?: sizeDp + val styledHeightDp = imageStyle?.optDouble("height", heightDp.toDouble())?.roundToInt() ?: heightDp leadingFrame.visibility = VISIBLE - leadingFrame.layoutParams = LayoutParams(dp(sizeDp), dp(heightDp)).apply { + leadingFrame.layoutParams = LayoutParams(dp(styledWidthDp), dp(styledHeightDp)).apply { val item = tag as? NativeListItem // OneKey patch: Yoga rounds cumulative selector edges, not each 12dp gap separately. - marginEnd = if (item?.json?.has("height") == true && item.json.optString("presentation") in setOf("accountSelector", "networkSelector")) dp(12 + sizeDp + spacingDp) - dp(12) - dp(sizeDp) else dp(spacingDp) + marginEnd = if (item?.json?.has("height") == true && item.json.optString("presentation") in setOf("accountSelector", "networkSelector")) dp(12 + styledWidthDp + spacingDp) - dp(12) - dp(styledWidthDp) else dp(spacingDp) } addView(leadingFrame) - leadingFallback.layoutParams = FrameLayout.LayoutParams(dp(sizeDp), dp(heightDp)) + leadingFallback.layoutParams = FrameLayout.LayoutParams(dp(styledWidthDp), dp(styledHeightDp)) if (visual == null) return val kind = visual.optString("kind") val shape = visual.optString( @@ -3377,7 +3463,7 @@ internal class NativeListRowView( val fallback = visual.optString("fallbackText").take(2) val fallbackIconData = visual.optJSONObject("fallbackIcon") val fallbackIconSizeDp = fallbackIconData?.let { - val slotSizeDp = minOf(sizeDp, heightDp) + val slotSizeDp = minOf(styledWidthDp, styledHeightDp) if (it.optString("name") == "GlobusOutline") { (slotSizeDp * 1.2f).roundToInt() } else { @@ -3406,12 +3492,12 @@ internal class NativeListRowView( if (!isIcon && visual.optString("backgroundColor").isNotEmpty()) { leadingFrame.background = roundedFill( visualBackground, - cornerRadiusDp ?: leadingCornerRadius(shape, minOf(sizeDp, heightDp)), + cornerRadiusDp ?: leadingCornerRadius(shape, minOf(styledWidthDp, styledHeightDp)), ) } leadingFallback.background = roundedFill( if (handlesSourceFallback) parseNativeListColor(imagePlaceholderColor) else visualBackground, - cornerRadiusDp ?: leadingCornerRadius(shape, minOf(sizeDp, heightDp)), + cornerRadiusDp ?: leadingCornerRadius(shape, minOf(styledWidthDp, styledHeightDp)), ) leadingFallback.visibility = if (!isIcon && (sources.isEmpty() || handlesSourceFallback)) VISIBLE else GONE @@ -3422,8 +3508,8 @@ internal class NativeListRowView( parseNativeListColor("#0000009B"), ) leadingIcon.layoutParams = FrameLayout.LayoutParams( - dp(fallbackIconSizeDp ?: sizeDp), - dp(fallbackIconSizeDp ?: heightDp), + dp(fallbackIconSizeDp ?: styledWidthDp), + dp(fallbackIconSizeDp ?: styledHeightDp), Gravity.CENTER, ) leadingIcon.glyphSizeDp = fallbackIconSizeDp @@ -3436,7 +3522,7 @@ internal class NativeListRowView( leadingFrame.background = GradientDrawable().apply { setColor(visualBackground) setStroke(1, parseNativeListColor("#0000001F")) - cornerRadius = scaledDp(cornerRadiusDp ?: leadingCornerRadius(shape, minOf(sizeDp, heightDp))) + cornerRadius = scaledDp(cornerRadiusDp ?: leadingCornerRadius(shape, minOf(styledWidthDp, styledHeightDp))) } leadingIcon.iconName = visual.optString("name") leadingIcon.tintColor = safeColor( @@ -3452,8 +3538,8 @@ internal class NativeListRowView( parseNativeListColor("#0000009B"), ) leadingIcon.layoutParams = FrameLayout.LayoutParams( - dp(fallbackIconSizeDp ?: sizeDp), - dp(fallbackIconSizeDp ?: heightDp), + dp(fallbackIconSizeDp ?: styledWidthDp), + dp(fallbackIconSizeDp ?: styledHeightDp), Gravity.CENTER, ) leadingIcon.glyphSizeDp = fallbackIconSizeDp @@ -3476,9 +3562,9 @@ internal class NativeListRowView( val marketPadding = if (item?.type == "market") item.json.optJSONObject("style")?.optDouble("horizontalPadding", 20.0) ?: 20.0 else null val density = resources.displayMetrics.density // Yoga rounds the absolute horizontal edges, including the avatar's fractional origin. - val badgeRight = marketPadding?.let { ((it + sizeDp + 4) * density).roundToInt() } - val badgeLeft = marketPadding?.let { ((it + sizeDp - 16) * density).roundToInt() } - val avatarRight = marketPadding?.let { ((it + sizeDp) * density).roundToInt() } + val badgeRight = marketPadding?.let { ((it + styledWidthDp + 4) * density).roundToInt() } + val badgeLeft = marketPadding?.let { ((it + styledWidthDp - 16) * density).roundToInt() } + val avatarRight = marketPadding?.let { ((it + styledWidthDp) * density).roundToInt() } leadingOverlayBackground.layoutParams = FrameLayout.LayoutParams( if (badgeRight != null && badgeLeft != null) badgeRight - badgeLeft else dp(20), dp(20), @@ -3517,8 +3603,8 @@ internal class NativeListRowView( image.layoutParams = leadingImageLayout( index = index, count = visibleSources.size, - sizeDp = sizeDp, - heightDp = heightDp, + sizeDp = styledWidthDp, + heightDp = styledHeightDp, tokenPair = tokenPair, ) image.outlineProvider = when { @@ -3530,7 +3616,7 @@ internal class NativeListRowView( if ((tag as? NativeListItem)?.type == "market" && index == 0 && visual.optString("borderColor").isNotEmpty()) { val inset = dp(1) val density = resources.displayMetrics.density - val sourceRadius = cornerRadiusDp ?: leadingCornerRadius(shape, minOf(sizeDp, heightDp)) + val sourceRadius = cornerRadiusDp ?: leadingCornerRadius(shape, minOf(styledWidthDp, styledHeightDp)) // OneKey patch: retain the source's fractional border and background rendering. leadingFrame.background = null BackgroundStyleApplicator.setBackgroundColor(leadingFrame, visualBackground) @@ -3542,8 +3628,8 @@ internal class NativeListRowView( BackgroundStyleApplicator.setBorderRadius(leadingFrame, BorderRadiusProp.BORDER_RADIUS, LengthPercentage(sourceRadius, LengthPercentageType.POINT)) // OneKey patch: round the source image's absolute edges inside its border. val sourceLeft = ((tag as? NativeListItem)?.json?.optJSONObject("style")?.optDouble("horizontalPadding", 20.0) ?: 20.0) * density - val imageWidth = (sourceLeft + (sizeDp - 1) * density).roundToInt() - (sourceLeft + density).roundToInt() - image.layoutParams = FrameLayout.LayoutParams(imageWidth, dp(heightDp - 2)).apply { + val imageWidth = (sourceLeft + (styledWidthDp - 1) * density).roundToInt() - (sourceLeft + density).roundToInt() + image.layoutParams = FrameLayout.LayoutParams(imageWidth, dp(styledHeightDp - 2)).apply { leftMargin = inset topMargin = inset } @@ -3553,7 +3639,7 @@ internal class NativeListRowView( val fallbackIcon = if (index == 0) fallbackIconData else null val expectedEpoch = bindingEpoch bindImage(source, image, boundKey ?: "", index, variant, - round = shape == "circle", + round = shape == "circle" && (imageStyle == null || (!imageStyle.has("shape") && !imageStyle.has("cornerRadius"))), onLoad = if (!ownsSourceFallback) null else ({ if (bindingEpoch == expectedEpoch) { sourceFallbackKey?.let(NativeListSourceFallbackState::forget) @@ -3642,7 +3728,7 @@ internal class NativeListRowView( if (visual.optString("borderStyle") == "dashed") { leadingFrame.background = GradientDrawable().apply { setColor(visualBackground) - cornerRadius = dp(sizeDp / 2).toFloat() + cornerRadius = dp(styledWidthDp / 2).toFloat() setStroke(dp(if (selectorUsesSourceScale && (tag as? NativeListItem)?.json?.optString("presentation") == "walletSidebar") 1 else 2), safeColor(visual.optString("borderColor"), parseNativeListColor("#00000072")), dp(4).toFloat(), dp(4).toFloat()) } leadingFallback.background = null @@ -3718,10 +3804,14 @@ internal class NativeListRowView( when (accessory.optString("kind")) { "value" -> { showTrailing(textIndex, accessory.optString("text"), !accessory.optBoolean("secondary", false)) + semanticValueViews.add(trailingViews[textIndex]) applyValueSegments(trailingViews[textIndex], accessory.optJSONArray("textSegments")) textIndex++ } - "valuePair" -> showTrailingValuePair(textIndex++, accessory, theme) + "valuePair" -> { + showTrailingValuePair(textIndex, accessory, theme) + semanticValueViews.add(trailingViews[textIndex++]) + } "checkbox" -> bindCheckbox(item, accessory, checkboxState) "radio" -> showTrailing( textIndex++, @@ -4157,11 +4247,6 @@ internal class NativeListRowView( } status.textSize = sp(12f) badgeLine.textSize = sp(12f) - dataColumns.forEach { column -> - column.textSize = sp(16f) - column.typeface = NativeListFonts.medium(context) - column.fontFeatureSettings = "tnum" - } // OneKey patch: exact selector row dimensions override template minimums. selectorHeight = if (item.json.has("height")) dp(item.json.optInt("height")) else null // OneKey patch: React Native's section spacers and letter blocks truncate physical heights. @@ -4396,7 +4481,7 @@ internal class NativeListRowView( sourceUri = uri, sourceHeadersJson = sourceHeadersJson, variant = variant, - contentFit = source.optString("contentFit", "cover"), + contentFit = if (imageView === leadingImages[0]) (tag as? NativeListItem)?.json?.optJSONObject("style")?.optJSONObject("image")?.optString("contentFit")?.takeIf(String::isNotEmpty) ?: source.optString("contentFit", "cover") else source.optString("contentFit", "cover"), cachePolicy = source.optString("cachePolicy", "memory-disk"), autoplay = source.optBoolean("autoplay", false), recyclingKey = recyclingKey, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt index cd96e0971..4c3957220 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt @@ -231,7 +231,53 @@ class NativeListView( private val sectionIndexPreview = TextView(context) private var config: NativeListConfig? = null private var usesSelectorSourceScale = false - private var stickyDecoration: StickySectionHeaderDecoration? = null + private val stickyHeaderView = NativeListRowView(reactContext) + private var stickyHeaderSignature: String? = null + private var stickyHeaderConfig: NativeListConfig? = null + private val stickyHeaderHost = object : FrameLayout(reactContext) { + private var down: MotionEvent? = null + private var forwarding = false + + private fun forward(event: MotionEvent) { + val copy = MotionEvent.obtain(event) + copy.offsetLocation(x - refreshLayout.x - recyclerView.x, y - refreshLayout.y - recyclerView.y) + recyclerView.dispatchTouchEvent(copy) + copy.recycle() + } + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { down?.recycle(); down = MotionEvent.obtain(event); forwarding = false } + MotionEvent.ACTION_MOVE -> { + val initial = down + if (initial != null && kotlin.math.abs(event.y - initial.y) > pagerGestureTouchSlop) { + forward(initial) + forwarding = true + parent?.requestDisallowInterceptTouchEvent(true) + return true + } + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { down?.recycle(); down = null } + } + return false + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (!forwarding) return super.onTouchEvent(event) + forward(event) + if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) { + forwarding = false + down?.recycle(); down = null + } + return true + } + + override fun onDetachedFromWindow() { down?.recycle(); down = null; super.onDetachedFromWindow() } + } + private val stickyPreDraw = android.view.ViewTreeObserver.OnPreDrawListener { + updateStickyHeader() + true + } private var spacingDecoration: ItemSpacingDecoration? = null private var itemTouchHelper: ItemTouchHelper? = null private var reorderTouchListener: RecyclerView.OnItemTouchListener? = null @@ -307,6 +353,10 @@ class NativeListView( FrameLayout.LayoutParams.MATCH_PARENT, ), ) + stickyHeaderHost.visibility = GONE + stickyHeaderHost.addView(stickyHeaderView, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT)) + contentContainer.addView(stickyHeaderHost, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.TOP)) + recyclerView.viewTreeObserver.addOnPreDrawListener(stickyPreDraw) contentContainer.addView( sectionIndexView, FrameLayout.LayoutParams( @@ -342,6 +392,9 @@ class NativeListView( adapter.onAction = ::handleAction adapter.onBindingInvalidated = ::handleBindingInvalidated adapter.checkboxState = ::resolveCheckboxState + stickyHeaderView.onRowPress = ::handleRowPress + stickyHeaderView.onAction = ::handleAction + stickyHeaderView.onBindingInvalidated = ::handleBindingInvalidated footerView.onRowPress = ::handleRowPress footerView.onAction = ::handleAction footerView.onBindingInvalidated = ::handleBindingInvalidated @@ -1121,6 +1174,9 @@ class NativeListView( itemTouchHelper = null footerView.recycle() footerView.dispose() + recyclerView.viewTreeObserver.removeOnPreDrawListener(stickyPreDraw) + stickyHeaderView.dispose() + stickyHeaderHost.visibility = GONE recyclerView.swapAdapter(null, false) adapter.dispose() } @@ -1153,10 +1209,51 @@ class NativeListView( spacingDecoration?.let(recyclerView::removeItemDecoration) spacingDecoration = ItemSpacingDecoration(dp(next.itemSpacing), next.itemSpacing, density).also(recyclerView::addItemDecoration) - stickyDecoration?.let(recyclerView::removeItemDecoration) - stickyDecoration = if (next.stickyHeaders && orientation == RecyclerView.VERTICAL) { - StickySectionHeaderDecoration(adapter, context, next.theme, density).also(recyclerView::addItemDecoration) - } else null + stickyHeaderSignature = null + if (!next.stickyHeaders || orientation != RecyclerView.VERTICAL) stickyHeaderHost.visibility = GONE + } + + private fun updateStickyHeader() { + val current = config + if (current == null || !current.stickyHeaders || layoutManager.orientation != RecyclerView.VERTICAL || recyclerView.width <= 0) { + stickyHeaderHost.visibility = GONE + return + } + val first = layoutManager.findFirstVisibleItemPosition() + var position = first + while (position >= 0 && adapter.itemAt(position)?.let(::isStickySectionHeader) != true) position-- + val item = adapter.itemAt(position) + if (item == null || first == RecyclerView.NO_POSITION || (layoutManager.findViewByPosition(position)?.top ?: -1) > 0) { + stickyHeaderHost.visibility = GONE + return + } + val width = recyclerView.width - recyclerView.paddingLeft - recyclerView.paddingRight + if (width <= 0) return + val signature = "${item.content}:$width:${recyclerView.layoutDirection}:$usesSelectorSourceScale" + if (stickyHeaderConfig !== current || stickyHeaderSignature != signature) { + stickyHeaderConfig = current + stickyHeaderSignature = signature + stickyHeaderView.listStyle = current.listStyle + stickyHeaderView.bind(item, current.theme, current.layout, current.orientation, position, + item.json.optBoolean("selected", false) || current.selectedKeys.contains(item.key), ::resolveCheckboxState, + useSourceScale = usesSelectorSourceScale) + stickyHeaderView.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)) + stickyHeaderHost.layoutParams = (stickyHeaderHost.layoutParams as FrameLayout.LayoutParams).apply { + this.width = width + height = stickyHeaderView.measuredHeight + leftMargin = recyclerView.paddingLeft + rightMargin = recyclerView.paddingRight + } + } + val height = stickyHeaderView.measuredHeight + var nextTop = Int.MAX_VALUE + for (index in 0 until recyclerView.childCount) { + val child = recyclerView.getChildAt(index) + val childPosition = recyclerView.getChildAdapterPosition(child) + if (childPosition > position && adapter.itemAt(childPosition)?.let(::isStickySectionHeader) == true) nextTop = minOf(nextTop, child.top) + } + stickyHeaderHost.translationY = minOf(0, nextTop - height).toFloat() + stickyHeaderHost.visibility = VISIBLE } private fun configureSectionIndex(next: NativeListConfig) { @@ -2606,99 +2703,8 @@ private class ItemSpacingDecoration( } } -private class StickySectionHeaderDecoration( - private val adapter: NativeListAdapter, - private val context: android.content.Context, - theme: JSONObject?, - private val density: Float, -) : RecyclerView.ItemDecoration() { - private val backgroundPaint = Paint().apply { - color = parseColor(theme?.optString("rowBackground"), "#FFFFFF") - } - private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = parseColor(theme?.optString("secondaryText"), "#0000009B") - textSize = NativeListScale.font(context.resources, 14f) * density - typeface = NativeListFonts.semibold(context) - } - - override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { - val manager = parent.layoutManager as? LinearLayoutManager ?: return - val first = manager.findFirstVisibleItemPosition() - if (first == RecyclerView.NO_POSITION) return - var header: NativeListItem? = null - for (index in first downTo 0) { - val candidate = adapter.itemAt(index) - if (candidate?.type == "sectionHeader") { - if (candidate.json.optString("variant") == "summary" || !candidate.json.optBoolean("sticky", true)) continue - header = candidate.takeIf(::isSimpleStickySectionHeader) - break - } - } - val item = header ?: return - // OneKey patch: a pinned selector heading must use the same text rasterization as its row. - textPaint.flags = if (item.usesSelectorSourceScale) Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG or Paint.LINEAR_TEXT_FLAG else Paint.ANTI_ALIAS_FLAG - val isHistory = item.json.optString("variant") == "history" || - item.sectionKey?.startsWith("history-") == true - val sourceHeight = item.json.optInt("height", 36) * density - val height = if (item.usesSelectorSourceScale) { - if (item.json.optString("heightRounding") == "nearest") sourceHeight.roundToInt() else sourceHeight.toInt() - } else NativeListScale.dp(context.resources, if (isHistory) 16 else 36) - val textSize = if (item.usesSelectorSourceScale) 14f else NativeListScale.font(context.resources, if (isHistory) 12f else 14f) - textPaint.textSize = if (item.usesSelectorSourceScale) kotlin.math.ceil((textSize * density).toDouble()).toFloat() else textSize * density - val horizontalInset = if (item.usesSelectorSourceScale) ((20 * density).toInt() - parent.paddingLeft).toFloat() else NativeListScale.dp(context.resources, if (isHistory) 8 else 20).toFloat() - val left = parent.paddingLeft.toFloat() - val right = (parent.width - parent.paddingRight).toFloat() - canvas.drawRect(left, 0f, right, height.toFloat(), backgroundPaint) - val baseline = if (item.usesSelectorSourceScale) { - val metrics = textPaint.fontMetricsInt - val lineHeight = kotlin.math.ceil(20 * density.toDouble()).toInt() - val leading = lineHeight - (metrics.descent - metrics.ascent) - (height - lineHeight) / 2 - metrics.ascent + kotlin.math.ceil(leading / 2.0).toFloat() - } else height / 2f - (textPaint.descent() + textPaint.ascent()) / 2f - val value = item.json.optString("title").let { if (isHistory) it.uppercase() else it } - val isRightToLeft = parent.layoutDirection == View.LAYOUT_DIRECTION_RTL - val textWidth = if (isHistory) { - spacedTextWidth(value, NativeListScale.dp(context.resources, 1).toFloat()) - } else { - textPaint.measureText(value) - } - val x = if (isRightToLeft) right - horizontalInset - textWidth else left + horizontalInset - if (isHistory) { - drawSpacedText(canvas, value, x, baseline, NativeListScale.dp(context.resources, 1).toFloat()) - } else { - canvas.drawText(value, x, baseline, textPaint) - } - } - - private fun drawSpacedText(canvas: Canvas, value: String, x: Float, baseline: Float, spacing: Float) { - var cursor = x - value.forEachIndexed { index, character -> - val glyph = character.toString() - canvas.drawText(glyph, cursor, baseline, textPaint) - cursor += textPaint.measureText(glyph) - if (index < value.lastIndex) cursor += spacing * 0.8f - } - } - - private fun spacedTextWidth(value: String, spacing: Float): Float = - value.sumOf { textPaint.measureText(it.toString()).toDouble() }.toFloat() + - max(0, value.length - 1) * spacing * 0.8f - - companion object { - private fun parseColor(value: String?, fallback: String): Int = try { - parseNativeListColor(if (value.isNullOrEmpty()) fallback else value) - } catch (_: IllegalArgumentException) { - parseNativeListColor(fallback) - } - } -} - -internal fun isSimpleStickySectionHeader(item: NativeListItem): Boolean = - item.type == "sectionHeader" && - item.json.optBoolean("sticky", true) && - item.json.optString("variant") != "summary" && - item.json.optString("value").isEmpty() && - item.json.optJSONObject("checkbox") == null +internal fun isStickySectionHeader(item: NativeListItem): Boolean = + item.type == "sectionHeader" && item.json.optBoolean("sticky", true) && item.json.optString("variant") != "summary" // OneKey patch: paint only explicitly requested backgrounds into list side padding. private class SelectorBackgroundDecoration(private val adapter: NativeListAdapter) : RecyclerView.ItemDecoration() { diff --git a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt index d1c9ee386..22e67a9e2 100644 --- a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt +++ b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt @@ -31,6 +31,9 @@ class NativeListStyleSlotTest { Case(type = "activity", field = "primaryAmount", expected = "value"), Case(type = "activity", field = "secondaryAmount", expected = "valueSecondary"), Case(type = "dataRow", field = "index", expected = "value"), + Case(type = "dataRow", field = "columns", expected = "dataPrimary"), + Case(type = "dataRow", field = "columnSecondary", expected = "dataSecondary"), + Case(type = "system", field = "title", variant = "retry", expected = null), Case(type = "sectionHeader", field = "value", expected = "value"), // Only the warning variant renders a separate title. Case(type = "system", field = "message", variant = "warning", expected = "subtitle"), @@ -57,7 +60,7 @@ class NativeListStyleSlotTest { Triple("rail", "", listOf("title", "badge", "status")), Triple("activity", "", listOf("title", "description", "status", "primaryAmount", "secondaryAmount")), Triple("message", "", listOf("title", "body", "time")), - Triple("dataRow", "", listOf("columns", "index")), + Triple("dataRow", "", listOf("columns", "columnSecondary", "index")), Triple("mediaTile", "", listOf("title", "subtitle", "badge")), Triple("metricCard", "", listOf("title", "value", "subtitle", "trend")), Triple("sectionHeader", "", listOf("title", "subtitle", "value")), diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index 5ac98bcec..864680954 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -142,3 +142,13 @@ workloads without an unbounded self-sizing pass. The native list runs in the main UI runtime. OneKey's background and main JavaScript heaps are not shared, so this API accepts normalized serializable data and never depends on background-runtime object identity. + +### Template versus style + +A row template (`type` and declared variant/presentation) defines structure, +semantic fields and interactions. `row.style` only changes allowlisted local +properties of those fields; it does not add children or rearrange the template. +The [shared style matrix](STYLE_SPEC.md#41-shared-configurable-properties) defines +the same configurable properties and semantics for Web, iOS and Android. +Header/footer placement, sections, scrolling and indexed bar belong to the +container. The integrating developer owns content fitting and row-height choices. diff --git a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md index f4a6401b1..5beefb546 100644 --- a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md +++ b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md @@ -33,12 +33,14 @@ template the owner of sections or the indexed bar. | [action](#action) | A row-level command | Label + optional icon/value/controls | | [system](#system) | Loading, retry, warning, empty/end or spacer | Variant-specific bounded status content | -The style roles below describe the **declared surface**, not completion on all -platforms. All styles extend `RowBoxStyle`: `horizontalPadding`, `verticalPadding`, -`leadingGap`, `lineGap`, `titleBadgeGap`, `trailingGap`, `image`. Only supported -parameters for visible slots may be relied on. In this stack, gaps other than -`lineGap` and image overrides are applied only by Market; other coverage gaps are -listed in STYLE_SPEC §6.5. An absent optional field is not created by styling it. +The style roles below form the shared configurable contract on Web, iOS and +Android. **Template** means structure and content fields; **row style** means +allowlisted parameters applied to those existing fields. Each template exposes +only applicable box/image parameters, listed in [STYLE_SPEC §4.1](STYLE_SPEC.md#41-shared-configurable-properties). +All text roles share the same typography/color/alignment properties; supported +image roles share the same dimensions/shape/fit properties. An absent optional +field is not created by styling it. Native rendered acceptance is tracked +separately from source implementation in STYLE_SPEC §9. ## identity @@ -52,7 +54,9 @@ listed in STYLE_SPEC §6.5. An absent optional field is not created by styling i wallet presentation. A variant is explicit model data, never inferred from font size. - **Text style roles:** `title`, `subtitle`, `tertiary`, `badge`, `value`, `valueSecondary`. The last two refer to value accessories in `trailing`; `badge` - refers to badge text. They are semantic roles, not extra top-level data fields. + refers to badge text. `value`/`valueSecondary` select the first/second value + accessory, ignoring intervening controls. A `valuePair` is one accessory, so + its style applies to both text runs. They are semantic roles, not extra top-level data fields. - **Layout boundary:** reserve visual and accessory slots; text compresses within its own column. Styling cannot move a checkbox/menu into the title column, reorder accessories or convert a horizontal row into the sidebar presentation. @@ -67,7 +71,7 @@ listed in STYLE_SPEC §6.5. An absent optional field is not created by styling i - **Required data:** `parent: IdentityRow`, nonempty `children: IdentityRow[]`. Every member must use `presentation: 'walletSidebar'`; `parent.key` must equal the group row's `key`, and member keys must be unique within the group. -- **Style surface:** group `style` is box-only. Text styles belong to +- **Style surface:** group `style` exposes horizontal/vertical padding only. Text styles belong to `parent.style` or the individual `children[i].style`; there is no inherited group `title`/`value` text style. - **Layout boundary:** preserve parent-first member order and wallet member @@ -138,8 +142,8 @@ listed in STYLE_SPEC §6.5. An absent optional field is not created by styling i - **Layout boundary:** column order/count/weights are data, shared table column alignment is layout. Text styles cannot change those or move favorite/checkbox controls into a data column. -- **Current gap:** native secondary styles and native table-column styles are - incomplete; Web slot coverage does not establish native parity. +- **Style isolation:** primary and secondary text use independent style targets in + linear and table layouts on all three platforms. Column badges keep their own typography. ## market @@ -191,7 +195,7 @@ listed in STYLE_SPEC §6.5. An absent optional field is not created by styling i - **Layout boundary:** styles cannot change the number/order of metric cells or turn the standard card into a composite card. Composite subfield mapping needs explicit coverage; do not assume `style.value` reaches every nested metric. -- **Current gap:** nested metric fields have no declared local text-style roles. +- **Style boundary:** nested metric fields have no declared local text-style roles on any platform. The standard card example alone cannot validate composite heading styling. ## sectionHeader @@ -210,8 +214,8 @@ listed in STYLE_SPEC §6.5. An absent optional field is not created by styling i - **Common-capability boundary:** `sectionHeader` is the current serialized heading descriptor, not a header feature attached to `identity` or another content template. `indexTitle` supplies the shared indexed bar's label. -- **Current gap:** Android draws sticky headers separately and excludes complex - headers with a value or checkbox from that path; STYLE_SPEC §6.3 records it. +- **Pinned rendering:** the container reuses the ordinary header renderer on all + platforms, including headers with a value or checkbox. Summary is non-sticky. ## action @@ -287,5 +291,5 @@ const snapshot: NativeListSnapshot = { stable row key through the shared list API. Changing the quote's content template while preserving a valid key/section does not introduce a new scroll API. -For completion criteria and known source-level gaps, return to -[STYLE_SPEC §6–§9](STYLE_SPEC.md#65-consolidated-implementation-gaps). +For completion criteria and the source audit, return to +[STYLE_SPEC §6–§9](STYLE_SPEC.md#65-consolidated-gap-audit). diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index 48e23d195..12ec7d0f8 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -1,6 +1,6 @@ # NativeList Style Spec -Status: **draft contract; implementation is partial**. Consolidated on 2026-09-22 +Status: **shared style contract implemented in source; native rendered acceptance pending**. Consolidated on 2026-09-22 from PRs #107, #109, #110, #111 and #112, on main `feac35eb6`. Use symbol names as source anchors. Tables of legacy defaults below are an inventory, not evidence of three-platform visual acceptance. @@ -53,22 +53,31 @@ example the indexed bar requires vertical sections), not a particular business row template. This requirement does not claim every current renderer satisfies it. Current API limits and implementation gaps are explicit in §5 and §6.5. -### 1.2 Design audit — 2026-09-22 - -The ownership model matches the intended design. The consolidated documents are -not yet a fully implemented contract: - -| Design intent | Audit conclusion / remaining decision | -| --- | --- | -| Each row template has a clear definition and illustration | All 12 types have a catalog entry and structural diagram. Variant-specific style applicability still needs coverage: composite metric-card `title` means its heading; the standard card's `value`, `subtitle` and `trend` do not name nested metric fields. | -| Local styles cannot change the template structure | The component owns the slot structure and style isolation. By the 2026-09-22 scope decision, developers integrating the list own overflow prevention: they choose fitting text/image/padding values and row heights. Automatic measurement, combination-fit rejection and style-driven auto-height are not part of this work. | -| Header/footer/sections/scroll/indexed bar are common capabilities | Ownership is correctly assigned to the container. Existing section headers and fixed action/system footers are independent of business templates. A generic header/footer slot needs an explicit interface decision covering placement, measurement, scroll indexing and event routing. | - -The five source PRs are closed; implementation continues on the consolidated -branch. The first refactor pass fixes existing field targeting, nested style -validation, text-state restoration and list-chrome scope. It does not settle the -header/footer interface questions or provide layout-fit enforcement. Overflow -handling belongs to the developer integrating the list, not a pending component feature. +### 1.2 Row template and row style are different contracts + +- **Row template** (`row.type` plus its declared `variant`/`presentation`) defines + the content model and structure: slot order, hierarchy, optional parts, and + interactions. `identity`, `market` and `dataRow` are templates. Changing a + template is a model change, not a style change. +- **Row style** (`row.style`) customizes existing semantic fields and local + metrics within that template. For example, `identity.style.title.fontSize` + changes title typography; `image.width` changes the primary visual slot. + A style cannot add a field, reorder slots or change a column's weight. +- **List capabilities** belong to the container. Header/footer placement, + sections, scrolling, indexed bar, selection and list chrome do not belong to + either a content template or its row style. + +The configurable property names, valid values, semantic targets and precedence +are the same on Web, iOS and Android. A template exposes only parameters for parts +it owns. Unsupported keys are rejected by the shared validator before +serialization; optional or variant-specific parts that are absent are not created +by styling them. See the complete applicability matrix in §4.1. + +The 12 templates each have a definition and schematic in ROW_TEMPLATES.md. +Overflow fitting remains the integrating developer's responsibility: choose +fitting text/image/padding values and row heights. Automatic measurement, +combination-fit rejection and style-driven auto-height are outside this contract. +The five source PRs are closed; implementation continues in the consolidated PR. ## 2. How this spec is enforced @@ -95,13 +104,14 @@ object identity. | Layer | State | | --- | --- | -| Key validation, token resolution, patch support | Implemented; combination fitting is the integrator's responsibility | -| Text slots, `horizontalPadding`, `verticalPadding`, `lineGap` | Style passes exist on Web, iOS and Android; coverage and reset gaps remain (§6.5) | -| `leadingGap`, `trailingGap`, `titleBadgeGap`, `image` | Validated but applied only on `market`. The others need a per-template default gap, which is not readable from the DOM on Web and is spread across the binders on the native sides | -| `listStyle.separator` and `listStyle.groupCornerRadius` | Android initial snapshot propagation and radius scope corrected; rendered verification pending (§6.5) | -| `dataRow` column styles | Web has primary/secondary slots. Native linear primary styling exists; native secondary and table column styling are incomplete (§6.5) | -| Row heights | Unchanged. A styled row that grows still needs an explicit `row.height` (§6.1) | -| Android list-wide source scale (§6.3) | Unchanged. Making it per-row would move every selector list's metrics and needs device verification | +| Per-template key validation, token resolution, patch support | Shared TypeScript/runtime contract; identical for all three renderers | +| Text roles and local box/image parameters | Implemented for the applicable template slots in §4.1; absent optional slots remain absent | +| `dataRow` column styles | Independent primary/secondary text on all platforms, in linear and table layouts | +| Sticky header styles | Android uses the same row view/binder as normal headers; iOS and Web also reuse their row renderers | +| `listStyle.separator` / `groupCornerRadius` | Initial/update propagation and actual-group scope aligned | +| Row heights and overflow | Caller owns row allocation and fitting; style does not introduce auto-height | +| Legacy baseline dimensions | Inventory in §4/§6; explicit style values use logical units independently of Android's legacy list scale | +| Rendered acceptance | The source contract and build checks do not establish full native interaction/pixel acceptance (§9) | The field-to-view mapping exists twice, once per native language — `nativeListStyleSlot` in `NativeListModels.kt` and `styleSlot` in `NativeListCell.swift`. §4 is the source of @@ -201,7 +211,67 @@ the same semantic mapping in native code. Singular style roles such as `badge` and `value` can refer to repeated badges or values in `trailing`; their precise meaning is defined in the [catalog](ROW_TEMPLATES.md). -Legend: **=** all three platforms agree; **≠** registered divergence, see §6. +### 4.1 Shared configurable properties + +Every text role listed below accepts the same `NativeListTextStyle`: +`token`, `fontSize`, `fontWeight`, `color`, `lineHeight`, `lines`, `alignment`. +Weights are `regular | medium | semibold | bold`; line counts are `1 | 2`; +`start | center | end` alignment follows layout direction. Colors must be +`#RRGGBB` or `#RRGGBBAA`, the common native/Web color format. Omitted properties +preserve the template's values. Explicit properties override token values, +presentation defaults and rich-text runs only for that property. + +Every supported image role accepts `width`, `height`, `shape`, `cornerRadius`, +`contentFit`. Shape is `circle | rounded | square`; explicit corner radius wins. +Fit is `cover | contain | fill | center`. These parameters target the primary +visual, not network badges, corner decorations, message thumbnails or nested +metric images. A media tile keeps its square default when only width is set; +an explicit height overrides that aspect. Width/height do not change the outer row allocation. + +In the matrix, **padding** means both `horizontalPadding` and `verticalPadding`. +Each cell describes the same public contract on **Web + iOS + Android**. + +| Template | Semantic text roles | Local box/image properties | +| --- | --- | --- | +| `identity` | title, subtitle, tertiary, badge, value, valueSecondary | padding, leadingGap, lineGap, titleBadgeGap, trailingGap, image | +| `walletGroup` | None; members own their identity styles | padding | +| `rail` | title, badge, status | padding, leadingGap, titleBadgeGap, trailingGap, image | +| `activity` | title, description, status, primaryAmount, secondaryAmount | padding, leadingGap, lineGap, trailingGap, image | +| `message` | title, body, time | padding, leadingGap, lineGap, image | +| `dataRow` | columns, columnSecondary, index | padding, leadingGap, lineGap, titleBadgeGap, image | +| `market` | title, subtitle, price, change | padding, leadingGap, lineGap, titleBadgeGap, trailingGap, image; Market-specific properties below | +| `mediaTile` | title, subtitle, badge | padding, leadingGap, lineGap, image | +| `metricCard` | title, value, subtitle, trend | padding, leadingGap, lineGap, image | +| `sectionHeader` | title, subtitle, value | padding, lineGap, trailingGap | +| `action` | title, value | padding, leadingGap, trailingGap, image | +| `system` | title, message, actionText | padding, lineGap | + +Market additionally exposes `titleBadgeLayout: inline`, `contentTrailingGap`, +`subtitleTrailingPadding`, `changeWidth`, `changeHeight`, `changeCornerRadius`, preserving +its existing template-specific style surface on every platform. + +Gap meanings are structural: `leadingGap` separates the primary visual from +content; `lineGap` separates content text rows (primary/secondary per data column); +`titleBadgeGap` separates a title and its badges (vertical for walletSidebar); +`trailingGap` separates trailing values/controls (title/badge to status for rail). +`walletGroup` padding applies only to the group; it never cascades into members. +Composite metric-card `lineGap` separates heading/metric blocks and dividers; it +does not change the typography or inner label/value gap of each metric. + +Variant applicability is also shared: composite metric-card `title` styles its +heading, while standard-card `value`/`subtitle`/`trend` do not style nested metrics; +`system.title` applies to warning, `actionText` to retry. A missing leading visual, +badge, subtitle or trailing value remains absent. New nested-metric/thumbnail +style roles require a separately declared cross-platform contract. + +All numeric overrides use logical units (CSS px / iOS pt / Android dp), including +explicit typography. Legacy Android scaling may affect omitted defaults, but must +not rescale an explicit style value. Physical-pixel/font-rasterization differences +remain platform-specific. See §3.3 for bounds and §7 for fitting responsibility. + +### 4.2 Existing defaults (inventory, not a pixel parity claim) + +Legend: **=** inventoried defaults agree; **≠** legacy divergence, see §6. ### identity @@ -287,7 +357,7 @@ Legend: **=** all three platforms agree; **≠** registered divergence, see §6. | Key | Default | iOS | Android | Web | | --- | --- | --- | --- | --- | -| `columns` (linear) | `$bodyMd` | 14 medium | sp(16) medium | 16px / 500 **≠** | +| `columns` (linear) | `$bodyMd` | 14 medium / 20 | sp(16) medium / dp(20) | 16px / 500 **≠** | | `columns` (table) | `$bodyMd` | 14 medium / 20 | sp(14) medium / dp(20) | 16px / 500 **≠** | | column secondary | `$bodySm` | 12 regular / 16 | sp(12) / dp(16) | — | | `index` | `$bodySm` | — | dp(32) slot | 13px, 28px slot **≠** | @@ -372,8 +442,8 @@ Rows in `snapshot.rows`, `snapshot.fixedFooter`, and `snapshot.emptyState` reuse the row binders. The two standalone descriptors currently accept only `action` or `system` in the public TypeScript API. `sectionHeader` is a structural row in `rows`. Reusing a binder does not transfer ownership of footer positioning, -empty-state placement or header pinning to the row template. Android's separate -sticky-header renderer has additional limitations (§6.3). +empty-state placement or header pinning to the row template. Android's pinned +header reuses the ordinary header row view and callbacks (§6.3). ## 5. T3 — List chrome @@ -385,9 +455,9 @@ container APIs; being public does not require placing everything in `listStyle`. | Capability | Existing entry point | Contract and current limit | | --- | --- | --- | -| List header | A leading structural row can carry summary content; native pager integration can host external header views | Header placement belongs to the container. There is currently **no generic public `listHeader`/`ListHeaderComponent` prop**; a full shared header slot remains a design/implementation gap | +| List header | Leading structural/summary rows; `CollapsiblePagerView.header` and `stickyHeader` for composed React content | Header placement belongs to the container. There is currently **no generic public `listHeader`/`ListHeaderComponent` prop**; arbitrary React children inside NativeList are not part of the current template API | | List footer | A trailing `action`/`system` row scrolls with content; `snapshot.fixedFooter` stays outside the scrolling rows | Available independently of the content row type. `fixedFooter` currently accepts only `ActionRow \| SystemRow`; row style controls its content, not its fixed placement | -| Sections / sticky headers | `layout.kind: 'sectioned'`, `sectionHeader.sectionKey`, member `sectionKey`, `layout.stickyHeaders`, header `sticky` | A section can contain different content templates. `sectionHeader` describes the heading; the container owns membership, positioning and pinning. Android complex-header pinning remains limited (§6.3) | +| Sections / sticky headers | `layout.kind: 'sectioned'`, `sectionHeader.sectionKey`, member `sectionKey`, `layout.stickyHeaders`, header `sticky` | A section can contain different content templates. `sectionHeader` describes the heading; the container owns membership, positioning and pinning. Complex headers share the normal row renderer when pinned; summary headers remain non-sticky on all three platforms (§6.3) | | Scroll and restoration | `NativeListRef.scrollToKey/Index/Item/Offset/End/Location`, `initialScrollKey/Index` and view position/offset props | Shared viewport behavior, independent of row template. Style must not silently change measured offsets or invalidate stable row keys | | Indexed bar | `capabilities.sectionIndex`, `sectionHeader.indexTitle`; Web has `webSectionIndexContainerRef` | Requires vertical `sectioned` layout; entries follow indexed header order and target header keys. No dependency on `identity`, `market`, or another content template | | Refresh / pagination | `capabilities.pullToRefresh/refreshing/loadMore/endReachedThreshold`, `onRefresh`, `onEndReached`, `setRefreshing` | Container gestures/state; indicator implementation may differ by platform | @@ -490,22 +560,18 @@ exposed — a hairline is correct on iOS and a whole pixel is correct elsewhere. ### 6.3 Behavioral -- **Android sticky headers are a second renderer.** - `StickySectionHeaderDecoration` draws the pinned header with `canvas.drawText`, - carrying its own `Paint`, font size (14 / 12), hard-coded semibold typeface, - height (36 / 16), horizontal inset (20 / 8), manual letter spacing (× 0.8), and - baseline math. iOS pins the real cell through layout attributes and Web reuses - `renderElement`, so both inherit row styling for free; Android will need the - style applied in two places. -- **Android silently drops stickiness for complex headers.** - `isSimpleStickySectionHeader()` requires no `value`, no `checkbox`, and - `variant !== 'summary'`. A section header carrying a total or a select-all box - does not stick on Android, but does on iOS and Web. Needs a product decision. +- **Android pinned headers reuse normal row rendering.** The old Canvas-only + `StickySectionHeaderDecoration` is replaced by an overlay `NativeListRowView`. + Typography, local metrics, values, checkbox/action callbacks and theme now follow + the same binding path. The overlay pushes away for the next sticky header and + forwards drag gestures to the RecyclerView. Summary and `sticky: false` headers + are excluded consistently. Device touch/accessibility acceptance remains required. - **Source scale is list-wide on Android.** `usesSelectorSourceScale` is computed with `items.any { … }`, so one selector row switches the metric system (the sub-400dp 0.9 factor) for the entire list. `NativeListTableColumnView`, the market skeleton, and the section index never - follow it. + follow it. Explicit non-Market row styles now use density directly, so this + legacy baseline policy cannot scale an explicitly supplied style value. - **`accent` is not an accent.** The consumer maps the application's `iconActive` onto the theme key `accent`. The name should be retired in favour of an alias. @@ -518,29 +584,24 @@ exposed — a hairline is correct on iOS and a whole pixel is correct elsewhere. tokens, because `--nl-inverse-text` defaults to `#fcfcfc` and `--nl-secondary` to `#6b7280`. Swapping them changes untinted rendering. -### 6.5 Consolidated implementation gaps - -These items were checked against the consolidated source on 2026-09-22. Source -fixes and regression tests below do not establish native rendered acceptance. +### 6.5 Consolidated gap audit -| Gap | Source anchor / context | Acceptance needed | +| Former gap | Resolution in source | Remaining verification | | --- | --- | --- | -| Web selector style order | Corrected: `renderElement` now applies styles after presentation defaults. DOM regressions cover account/network selectors and style removal | Rendered geometry and interactions still need verification | -| Composite metric-card heading | `style.title` now targets the heading on Web/iOS/Android. Standard-card value/subtitle/trend roles must not target the heading or nested metrics | Verify both variants; nested metric styling needs a separate declared role before implementation | -| Native data secondary/table styles are missing | `nativeListStyleSlot` / `styleSlot` omit `columnSecondary`; native table labels have their own renderer ([#110](https://github.com/OneKeyHQ/app-modules/pull/110)) | Verify primary and secondary text in both linear and table layouts | -| Native text state on reuse | The style pass now saves the bound view's actual text defaults and restores them before reuse, including alignment. iOS retains attributed-string properties not overridden by a style | Styled → unstyled → different-template reuse still needs device verification | -| Android initial list chrome | Normal snapshots now assign `adapter.listStyle`, matching the stable-content path | Initial mount, normal update, chrome-only update and footer must use the same list style | -| Android grouped-radius scope | Custom radius now requires actual `groupId` membership, not a synthetic `single` position | Grouped rows change; standalone selectors and metric cards retain template geometry | -| Wallet-group member scope on Web | Members now apply their own styles; group `lineGap` no longer traverses member text columns | Parent and child styles must remain independent on every platform | -| Example inventory | `NativeListRowStylePage.buildRows` now covers all 12 types | Complete relevant variants and execute §9 | -| Layout fitting is an integration responsibility | Text/image nested object shape, unknown keys and numeric bounds are checked; available space is not | Integrators choose fitting parameters and verify their layouts (§7.1); no component-level overflow prevention is planned | -| General header slot is absent | `NativeListProps`, `NativeListSnapshot` | Define one container-level header contract before expanding the public API | - -`leadingGap`, `trailingGap`, `titleBadgeGap` and `image` remain accepted but unused -outside Market in this stack. Android sticky-header styling remains separate -(§6.3). Unsupported paths must be completed or explicitly rejected before being -advertised as cross-platform style support. This consolidation does not change -the runtime API to resolve those gaps. +| Non-Market box/image parameters accepted without effect | Per-template allowlists and native/Web geometry/image mappings; properties for nonexistent template parts rejected | Native image loading, decoration clipping and variant geometry | +| Native table and secondary-column styling | Separate primary/secondary labels in linear and table layouts; styles target only their semantic text | Rendered alignment and reuse in both native layouts | +| Missing semantic text targets | Activity status, rich identity subtitles, wallet badges, retry action and composite heading routed to actual fields | Device variant coverage | +| Selector defaults overwrite styles | Explicit style applied after defaults; summary/selection updates restore and reapply styles | Rapid selection/summary updates on device | +| Native reuse leaks | Restore the actual bound text and local geometry before rebinding; preserve unspecified rich-text attributes | Styled → unstyled → another template during scrolling | +| Android sticky header uses a separate partial renderer | Normal row view renders pinned text, values and controls | Pin/push-off, checkbox/action taps, scrolling and accessibility | +| List chrome propagation and group scope | Initial/update paths receive listStyle; custom radius only applies to actual groups | Initial and chrome-only snapshot interactions | +| Parent style cascades to wallet members | Group exposes padding only; members bind their own styles | Independent group/member styling during reorder | + +Common headers/footers already exist through structural rows, `fixedFooter` and +pager composition (§5.1). This work does not add an arbitrary React header slot. +Layout fitting is caller-owned, not an unresolved component feature. Legacy +unstyled defaults in §6 remain distinct from the unified configurable surface; +this change is not a wholesale redesign of those defaults. ## 7. Isolation rules @@ -644,7 +705,7 @@ For any PR that touches list typography or geometry: (§7 rule 2)? - [ ] Is any decision derived from the whole row list rather than one row (§7 rule 3)? -- [ ] For a section header: does the Android sticky decoration need the same change +- [ ] For a section header: does the shared pinned row retain styles and interaction (§6.3)? - [ ] Does each style-only update preserve the allocated row frame and slot order? Has the integrator verified fitting content and control hit areas (§7.1)? @@ -667,21 +728,26 @@ rendered interaction checks. | Scroll / chrome | Initial scroll, scroll to key/index/location/end, resize, refresh, pagination, initial/chrome-only snapshots | Correct target/offset and consistent separator/group style without row-size changes | | Independent scopes | Two list instances, grouped and non-grouped rows, nested wallet members | No style or index-host effect outside the intended list/group/row | -Next implementation work should close §6.5, complete the example coverage, and -record platform-specific build and interaction evidence. The shared document is +Next acceptance work should execute the device cases in §6.5 and record +platform-specific build and interaction evidence. The shared document is the contract; no shared cross-language renderer or code generator is required. -### First-pass verification — 2026-09-22 - -- JavaScript: 95 tests across five suites pass, including selector style removal, - composite heading targeting, wallet-member isolation and nested style validation. -- TypeScript: package, example page and documented composition sample pass. -- Lint: package has no errors (21 existing warnings); example page passes. -- Example data: both toggle states validate, each with 42 rows covering all 12 types. -- Android: `compileDebugKotlin` and `testDebugUnitTest` pass; seven tests in three - suites. Dependency Nitrogen outputs were generated locally for the build. -- iOS: `NativeListCell.swift` passes Swift syntax parsing only. -- No device or browser rendering/interaction acceptance has been completed for - this refactor. Native data secondary/table style coverage, non-Market image/gap - support and the generic header/footer question remain open. Geometry fitting - is assigned to integrators and is not a remaining implementation gap. +### Verification — 2026-09-22 + +- Package TypeScript and JavaScript: 109 tests in five suites pass. Added cases + cover per-template keys/colors, rich text and badge isolation, independent data + columns, media dimensions, local gaps, status/retry targets and value accessories. +- Package lint: zero errors, 21 existing warnings. Example and documented + composition TypeScript pass; the example covers 12 templates with 42 rows. +- Android: `:onekeyfe_react-native-native-list:compileDebugKotlin` and + `:onekeyfe_react-native-native-list:testDebugUnitTest` pass (seven tests in three + suites), using the example Gradle project with `--configure-on-demand`. +- iOS: the modified models/assets/cell typecheck with the real simulator UIKit + SDK and a test double matching the public OneKeyImage view interface. This does + not verify the image implementation, linking or a full application build. +- An isolated iOS simulator check could not start: CoreSimulator was denied + permission to create the external-drive device data (Cocoa 513 / EPERM). + No native rendered interaction acceptance is claimed. Execute the matrix above + before treating the refactor as device-accepted. + +Overflow fitting is the integrating developer's responsibility. diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index fec245310..32617e5c6 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -334,6 +334,11 @@ private final class NativeListTableColumnView: UIStackView { } func reset() { + spacing = 4 + primaryLine.spacing = 6 + primaryLabel.numberOfLines = 1 + secondaryLeadingLabel.numberOfLines = 1 + secondaryLabel.numberOfLines = 1 primaryLabel.attributedText = nil secondaryLeadingLabel.attributedText = nil secondaryLabel.attributedText = nil @@ -367,6 +372,9 @@ private final class NativeListTableColumnView: UIStackView { primaryLabel.textAlignment = textAlignment secondaryLeadingLabel.textAlignment = textAlignment secondaryLabel.textAlignment = textAlignment + primaryLabel.font = nativeListFont(ofSize: 14, weight: .medium) + secondaryLeadingLabel.font = nativeListFont(ofSize: 12) + secondaryLabel.font = nativeListFont(ofSize: 12) primaryLabel.attributedText = line( column.string("text"), font: nativeListFont(ofSize: 14, weight: .medium), @@ -417,6 +425,20 @@ private final class NativeListTableColumnView: UIStackView { } } + func applyStyle(_ style: [String: Any], text: (UILabel, [String: Any]) -> Void) { + if let primary = style.dictionary("columns") { + if primary["alignment"] != nil { alignment = .fill } + text(primaryLabel, primary) + } + if let secondary = style.dictionary("columnSecondary") { + if secondary["alignment"] != nil { alignment = .fill } + text(secondaryLeadingLabel, secondary) + text(secondaryLabel, secondary) + } + if style["lineGap"] != nil { spacing = CGFloat(style.double("lineGap")) } + if style["titleBadgeGap"] != nil { primaryLine.spacing = CGFloat(style.double("titleBadgeGap")) } + } + private func line(_ text: String, font: UIFont, color: UIColor, height: CGFloat) -> NSAttributedString { let paragraph = NSMutableParagraphStyle() paragraph.minimumLineHeight = height @@ -448,6 +470,8 @@ final class NativeListCell: UICollectionViewCell { static let reuseIdentifier = "NativeListCell" private let rootStack = UIStackView() + private let mediaVisualWrapper = UIView() + private var styledCircleViews: [UIView] = [] private let leadingImages = (0..<3).map { _ in OneKeyImageReusableView(frame: .zero) } private let leadingOverlayBackground = UIView() private let leadingCornerIconBackground = UIView() @@ -494,8 +518,6 @@ final class NativeListCell: UICollectionViewCell { private let accessoryButtons = (0..<2).map { _ in NativeListAccessoryButton(type: .system) } private let checkboxButton = UIButton(type: .system) private let spinner = UIActivityIndicatorView(style: .medium) - private let dataStack = UIStackView() - private let dataLabels = (0..<4).map { _ in UILabel() } private let tableDataStack = UIStackView() private let tableDataColumns = (0..<4).map { _ in NativeListTableColumnView() } private let mediaBadgeLabel = UILabel() @@ -551,6 +573,10 @@ final class NativeListCell: UICollectionViewCell { // OneKey patch: a row style wrote view properties that reset() does not restore. private var styledViewsDirty = false private var styledTextRestorations: [() -> Void] = [] + private var semanticBadgeLabels: [UILabel] = [] + private var semanticSubtitleLabels: [UILabel] = [] + private var semanticValueButtons: [UIButton] = [] + private weak var walletBadgeLine: UIStackView? private var currentTheme: [String: Any]? private var currentItemIndex: Int? // OneKey patch: delayed image retries belong to the current reusable cell binding. @@ -818,15 +844,6 @@ final class NativeListCell: UICollectionViewCell { trailingStack.addArrangedSubview(checkboxButton) trailingStack.addArrangedSubview(spinner) - dataStack.axis = .horizontal - dataStack.alignment = .center - dataStack.distribution = .fillProportionally - dataStack.spacing = 8 - dataLabels.forEach { label in - label.font = nativeListFont(ofSize: 12) - label.lineBreakMode = .byTruncatingTail - dataStack.addArrangedSubview(label) - } tableDataStack.axis = .horizontal tableDataStack.alignment = .center tableDataStack.distribution = .fill @@ -861,7 +878,13 @@ final class NativeListCell: UICollectionViewCell { selectorFullWidthBackground.frame = CGRect(x: -frame.minX, y: 0, width: superview?.bounds.width ?? bounds.width, height: bounds.height) } if currentItem?.type == "mediaTile" { - mediaHeight.constant = max(0, contentView.bounds.width - 20) + let image = currentItem?.data.dictionary("style")?.dictionary("image") + if let image, image["height"] != nil { mediaHeight.constant = CGFloat(image.double("height")) } + else if let image, image["width"] != nil { mediaHeight.constant = CGFloat(image.double("width")) } + else { mediaHeight.constant = max(0, contentView.bounds.width - rootLeadingConstraint.constant + rootTrailingConstraint.constant) } + } + for view in styledCircleViews { + (view.layer.mask as? CAShapeLayer)?.path = UIBezierPath(ovalIn: view.bounds).cgPath } if currentItem?.type == "identity", currentItem?.data["height"] != nil, currentItem?.data.string("presentation") == "accountSelector", @@ -1043,7 +1066,11 @@ final class NativeListCell: UICollectionViewCell { ) { guard currentItem?.key == item.key else { return } restoreSelectorTypography() - defer { applySelectorTypography(item) } + resetRowStyle() + defer { + applyRowStyle(item) + applySelectorTypography(item) + } if item.type == "walletGroup" { currentItem = item let memberData = [item.data.dictionary("parent")].compactMap { $0 } @@ -1214,14 +1241,19 @@ final class NativeListCell: UICollectionViewCell { private func resetRowStyle() { guard styledViewsDirty else { return } styledViewsDirty = false + styledCircleViews.removeAll() styledTextRestorations.reversed().forEach { $0() } styledTextRestorations.removeAll(keepingCapacity: true) } private func reset() { + restoreSelectorTypography() resetRowStyle() + semanticBadgeLabels.removeAll(keepingCapacity: true) + semanticSubtitleLabels.removeAll(keepingCapacity: true) + semanticValueButtons.removeAll(keepingCapacity: true) + walletBadgeLine = nil titleLabel.transform = .identity - restoreSelectorTypography() // OneKey patch: remove selector decorations before rebinding recycled cells. selectorViews.forEach { $0.removeFromSuperview() } selectorViews.removeAll() @@ -1317,7 +1349,6 @@ final class NativeListCell: UICollectionViewCell { dataWeightConstraints.removeAll() NSLayoutConstraint.deactivate(accessorySizeConstraints) accessorySizeConstraints.removeAll() - dataStack.distribution = .fill leadingImages.forEach { $0.isHidden = true $0.alpha = 1 @@ -1449,12 +1480,6 @@ final class NativeListCell: UICollectionViewCell { checkboxButton.setImage(nil, for: .normal) spinner.stopAnimating() spinner.alpha = 1 - dataLabels.forEach { - $0.isHidden = true - $0.text = nil - $0.attributedText = nil - $0.numberOfLines = 1 - } tableDataColumns.forEach { $0.reset() $0.isHidden = true @@ -1820,8 +1845,10 @@ final class NativeListCell: UICollectionViewCell { label.layer.cornerRadius = 4 label.clipsToBounds = true setLineHeight(label, text: badge.string("text"), lineHeight: isSelector ? 14 : 16) + semanticBadgeLabels.append(label) line.addArrangedSubview(label) } + walletBadgeLine = line mainStack.spacing = 4 mainStack.addArrangedSubview(line) selectorViews.append(line) @@ -1936,6 +1963,7 @@ final class NativeListCell: UICollectionViewCell { } label.attributedText = value } + semanticSubtitleLabels.append(label) line.addArrangedSubview(label) } let filler = UIView() @@ -2195,81 +2223,22 @@ final class NativeListCell: UICollectionViewCell { if hasLeadingAccessory { rootStack.addArrangedSubview(trailingStack) } let columns = Array(item.data.dictionaries("columns").prefix(4)) let rowBadges = item.data.dictionaries("badges") - if currentLayout == "table" { - rootStack.addArrangedSubview(tableDataStack) - for (index, column) in columns.enumerated() { - tableDataColumns[index].isHidden = false - tableDataColumns[index].bind( - column: column, - badges: index == 0 ? rowBadges : [], - theme: theme - ) - } - if let firstColumn = columns.first { - let firstWeight = CGFloat(max(1, firstColumn.int("weight", default: 1))) - for index in 1.. { return body; }; + it('styles primary and secondary data independently without restyling badges', () => { + const body = render({ + type: 'dataRow', + key: 'data', + columns: [ + { + key: 'asset', + text: 'BTC', + secondaryLeadingText: '1', + secondaryText: 'Bitcoin', + }, + { key: 'price', text: '$1' }, + ], + badges: [{ key: 'tag', text: 'Tag' }], + style: { + columns: { fontSize: 22, color: '#112233', alignment: 'end' }, + columnSecondary: { fontSize: 11, color: '#445566' }, + lineGap: 9, + titleBadgeGap: 12, + }, + }); + const primary = body.querySelector( + '[data-nl-slot="columns"]' + )!; + const secondary = Array.from( + body.querySelectorAll('[data-nl-slot="columnSecondary"]') + ); + expect(primary.style.fontSize).toBe('22px'); + expect(primary.textContent).toBe('BTC'); + expect(primary.style.textAlign).toBe('end'); + expect(secondary.map((node) => node.textContent)).toEqual(['1', 'Bitcoin']); + expect(secondary.every((node) => node.style.fontSize === '11px')).toBe( + true + ); + expect( + body.querySelector('[data-nl-slot="badge"]')!.style.fontSize + ).toBe(''); + expect( + body.querySelector('.ok-native-list-data-cell')!.style.rowGap + ).toBe('9px'); + }); + + it('keeps explicit media dimensions and independent caption spacing', () => { + const body = render({ + type: 'mediaTile', + variant: 'gallery', + key: 'media', + title: 'Title', + subtitle: 'Subtitle', + image, + style: { + image: { + width: 80, + height: 60, + shape: 'square', + contentFit: 'contain', + }, + leadingGap: 13, + lineGap: 5, + }, + }); + const bitmap = body.querySelector( + '.ok-native-list-media-image' + )!; + expect(bitmap.style.width).toBe('80px'); + expect(bitmap.style.height).toBe('60px'); + expect(bitmap.style.objectFit).toBe('contain'); + expect(bitmap.style.borderRadius).toBe('0px'); + expect( + body.querySelector('.ok-native-list-media-meta')!.style + .rowGap + ).toBe('5px'); + }); + + it('keeps title and badge styling independent even with search highlights', () => { + const body = render({ + type: 'identity', + leading: { kind: 'icon', name: 'coin' }, + key: 'badge', + title: 'Bitcoin', + titleMatch: [{ start: 0, end: 3 }], + badges: [{ key: 'tag', text: 'Tag' }], + style: { + title: { fontSize: 24, color: '#112233' }, + badge: { fontSize: 10 }, + titleBadgeGap: 7, + }, + }); + const title = body.querySelector('[data-nl-slot="title"]')!; + const badge = body.querySelector('[data-nl-slot="badge"]')!; + expect(title.textContent).toBe('Bitcoin'); + expect(title.contains(badge)).toBe(false); + expect( + title.querySelector('.ok-native-list-info')!.style.color + ).toBe('rgb(17, 34, 51)'); + expect(badge.style.fontSize).toBe('10px'); + }); + + it('applies missing status, rich subtitle and retry action roles', () => { + const activity = render({ + type: 'activity', + leading: { kind: 'icon', name: 'coin' }, + key: 'a', + title: 'Sent', + status: 'Pending', + style: { status: { fontSize: 19 } }, + }); + expect( + activity.querySelector('[data-nl-slot="status"]')!.style + .fontSize + ).toBe('19px'); + const identity = render({ + type: 'identity', + leading: { kind: 'icon', name: 'coin' }, + key: 'i', + title: 'Wallet', + subtitleSegments: [{ text: 'Balance' }], + style: { subtitle: { fontSize: 18 } }, + }); + expect( + identity.querySelector('[data-nl-slot="subtitle"]')!.style + .fontSize + ).toBe('18px'); + const retry = render({ + type: 'system', + key: 's', + variant: 'retry', + message: 'Failed', + actionText: 'Try again', + actionKey: 'retry', + style: { actionText: { fontSize: 20 } }, + }); + const action = retry.querySelector( + '[data-nl-slot="actionText"]' + )!; + expect(action.textContent).toBe('Try again'); + expect(action.style.fontSize).toBe('20px'); + expect(action.dataset.nativeListAction).toBe('retry'); + }); + + it('applies header accessory spacing and keeps rail badge before status', () => { + const header = render({ + type: 'sectionHeader', + key: 'h', + sectionKey: 'assets', + title: 'Assets', + value: '$1', + checkbox: { + kind: 'checkbox', + state: 'unchecked', + target: { scope: 'list' }, + }, + style: { trailingGap: 17 }, + }); + expect( + (header.lastElementChild as HTMLElement).style.marginInlineStart + ).toContain('17px'); + const rail = render({ + type: 'rail', + visual: { kind: 'icon', name: 'coin' }, + key: 'r', + title: 'Wallet', + badge: { key: 'tag', text: 'Tag' }, + status: 'online', + }); + expect( + Array.from(rail.querySelectorAll('[data-nl-slot]')).map( + (node) => node.dataset.nlSlot + ) + ).toEqual(['title', 'badge', 'status']); + }); + + it('styles the first value accessory independently of preceding controls', () => { + const body = render({ + type: 'identity', + key: 'value', + title: 'Wallet', + leading: { kind: 'icon', name: 'coin' }, + trailing: [ + { kind: 'checkbox', state: 'unchecked' }, + { kind: 'valuePair', primary: '$10', secondary: '2 BTC' }, + ], + style: { value: { fontSize: 19, color: '#123456' } }, + }); + const value = body.querySelector('[data-nl-slot="value"]')!; + expect(value.textContent).toBe('$102 BTC'); + expect(value.style.fontSize).toBe('19px'); + expect( + Array.from(value.children).every( + (run) => (run as HTMLElement).style.fontSize === '19px' + ) + ).toBe(true); + expect(body.querySelector('[data-nl-slot="valueSecondary"]')).toBeNull(); + }); + it('tags each slot with the model field it renders', () => { const body = render({ type: 'message', @@ -673,7 +868,7 @@ describe('web row style', () => { children: [ { ...parent, key: 'child', title: 'Child wallet', style: undefined }, ], - style: { lineGap: 13 }, + style: { horizontalPadding: 13 }, }); const titles = body.querySelectorAll('[data-nl-slot="title"]'); expect(titles).toHaveLength(2); diff --git a/native-views/react-native-native-list/src/__tests__/validation.test.ts b/native-views/react-native-native-list/src/__tests__/validation.test.ts index e5766b2ba..38bb23112 100644 --- a/native-views/react-native-native-list/src/__tests__/validation.test.ts +++ b/native-views/react-native-native-list/src/__tests__/validation.test.ts @@ -1307,6 +1307,57 @@ describe('NativeList style contract', () => { expect(() => validateSnapshot(styled({ lineGap: 17 }))).toThrow('lineGap'); }); + it.each(['red', '#abc', 'rgb(1, 2, 3)', '$text', '#123456789'])( + 'rejects nonportable style color %s', + (color) => { + expect(() => validateSnapshot(styled({ title: { color } }))).toThrow( + 'must be #RRGGBB or #RRGGBBAA' + ); + } + ); + + it.each(['#123456', '#aAbBcC80'])( + 'accepts shared style color %s', + (color) => { + expect(() => + validateSnapshot(styled({ title: { color } })) + ).not.toThrow(); + } + ); + + it('rejects box parameters for template parts that do not exist, including patches', () => { + expect(() => + validateSnapshot( + snapshot([ + { + type: 'rail', + key: 'r', + title: 'Rail', + style: { lineGap: 3 }, + } as unknown as RowModel, + ]) + ) + ).toThrow('style.lineGap'); + expect(() => + validatePatches([ + { + type: 'sectionHeader', + key: 'h', + changes: { style: { image: { width: 20 } } }, + } as unknown as RowPatch, + ]) + ).toThrow('style.image'); + expect(() => + validatePatches([ + { + type: 'dataRow', + key: 'd', + changes: { style: { trailingGap: 4 } }, + } as unknown as RowPatch, + ]) + ).toThrow('style.trailingGap'); + }); + it('validates and resolves a style carried by a patch', () => { const patches = validatePatches([ { diff --git a/native-views/react-native-native-list/src/models.ts b/native-views/react-native-native-list/src/models.ts index 10de9daaf..0554439a7 100644 --- a/native-views/react-native-native-list/src/models.ts +++ b/native-views/react-native-native-list/src/models.ts @@ -74,18 +74,105 @@ export type NativeListImageStyle = Readonly<{ export type MarketImageStyle = NativeListImageStyle; -/** Box metrics every template shares. Bounds live in docs/STYLE_SPEC.md §3.3. */ +/** Local box vocabulary. Each template exposes only parameters for its own slots. */ export type RowBoxStyle = Readonly<{ horizontalPadding?: number; verticalPadding?: number; leadingGap?: number; - /** Space between title and subtitle; 0 by default, bounded to 0..16. */ + /** Gap between the template's text rows. Omission preserves its default. */ lineGap?: number; titleBadgeGap?: number; trailingGap?: number; image?: NativeListImageStyle; }>; +/** Template-owned slots determine which local box parameters exist, on every platform. */ +export const ROW_BOX_STYLE_KEYS_BY_TYPE = { + identity: [ + 'horizontalPadding', + 'verticalPadding', + 'leadingGap', + 'lineGap', + 'titleBadgeGap', + 'trailingGap', + 'image', + ], + walletGroup: ['horizontalPadding', 'verticalPadding'], + rail: [ + 'horizontalPadding', + 'verticalPadding', + 'leadingGap', + 'titleBadgeGap', + 'trailingGap', + 'image', + ], + activity: [ + 'horizontalPadding', + 'verticalPadding', + 'leadingGap', + 'lineGap', + 'trailingGap', + 'image', + ], + message: [ + 'horizontalPadding', + 'verticalPadding', + 'leadingGap', + 'lineGap', + 'image', + ], + dataRow: [ + 'horizontalPadding', + 'verticalPadding', + 'leadingGap', + 'lineGap', + 'titleBadgeGap', + 'image', + ], + market: [ + 'horizontalPadding', + 'verticalPadding', + 'leadingGap', + 'lineGap', + 'titleBadgeGap', + 'trailingGap', + 'image', + ], + mediaTile: [ + 'horizontalPadding', + 'verticalPadding', + 'leadingGap', + 'lineGap', + 'image', + ], + metricCard: [ + 'horizontalPadding', + 'verticalPadding', + 'leadingGap', + 'lineGap', + 'image', + ], + sectionHeader: [ + 'horizontalPadding', + 'verticalPadding', + 'lineGap', + 'trailingGap', + ], + action: [ + 'horizontalPadding', + 'verticalPadding', + 'leadingGap', + 'trailingGap', + 'image', + ], + system: ['horizontalPadding', 'verticalPadding', 'lineGap'], +} as const; + +type TemplateBoxStyle = Pick< + RowBoxStyle, + (typeof ROW_BOX_STYLE_KEYS_BY_TYPE)[T][number] +>; + /** * A style key names the model field it modifies, never the view that carries * it. The view pool is shared and the mapping is not one to one: metricCard @@ -93,7 +180,7 @@ export type RowBoxStyle = Readonly<{ * status view carries rail.status, activity.status, message.time and * metricCard.trend. See docs/STYLE_SPEC.md §4. */ -export type IdentityRowStyle = RowBoxStyle & +export type IdentityRowStyle = TemplateBoxStyle<'identity'> & Readonly<{ title?: NativeListTextStyle; subtitle?: NativeListTextStyle; @@ -103,16 +190,16 @@ export type IdentityRowStyle = RowBoxStyle & valueSecondary?: NativeListTextStyle; }>; -export type WalletGroupRowStyle = RowBoxStyle; +export type WalletGroupRowStyle = TemplateBoxStyle<'walletGroup'>; -export type RailRowStyle = RowBoxStyle & +export type RailRowStyle = TemplateBoxStyle<'rail'> & Readonly<{ title?: NativeListTextStyle; badge?: NativeListTextStyle; status?: NativeListTextStyle; }>; -export type ActivityRowStyle = RowBoxStyle & +export type ActivityRowStyle = TemplateBoxStyle<'activity'> & Readonly<{ title?: NativeListTextStyle; description?: NativeListTextStyle; @@ -121,21 +208,21 @@ export type ActivityRowStyle = RowBoxStyle & secondaryAmount?: NativeListTextStyle; }>; -export type MessageRowStyle = RowBoxStyle & +export type MessageRowStyle = TemplateBoxStyle<'message'> & Readonly<{ title?: NativeListTextStyle; body?: NativeListTextStyle; time?: NativeListTextStyle; }>; -export type DataRowStyle = RowBoxStyle & +export type DataRowStyle = TemplateBoxStyle<'dataRow'> & Readonly<{ columns?: NativeListTextStyle; columnSecondary?: NativeListTextStyle; index?: NativeListTextStyle; }>; -export type MarketRowStyle = RowBoxStyle & +export type MarketRowStyle = TemplateBoxStyle<'market'> & Readonly<{ /** OneKey patch: keep badges next to the intrinsic title width. */ titleBadgeLayout?: 'inline'; @@ -151,37 +238,37 @@ export type MarketRowStyle = RowBoxStyle & changeCornerRadius?: number; }>; -export type MediaTileRowStyle = RowBoxStyle & +export type MediaTileRowStyle = TemplateBoxStyle<'mediaTile'> & Readonly<{ title?: NativeListTextStyle; subtitle?: NativeListTextStyle; badge?: NativeListTextStyle; }>; -export type MetricCardRowStyle = RowBoxStyle & +export type MetricCardRowStyle = TemplateBoxStyle<'metricCard'> & Readonly<{ - /** The small label. Carried by the subtitle view on every platform. */ + /** Standard-card label or composite-card heading. */ title?: NativeListTextStyle; - /** The large number. Carried by the title view on every platform. */ + /** Large number in the standard template; absent in composite variants. */ value?: NativeListTextStyle; subtitle?: NativeListTextStyle; trend?: NativeListTextStyle; }>; -export type SectionHeaderRowStyle = RowBoxStyle & +export type SectionHeaderRowStyle = TemplateBoxStyle<'sectionHeader'> & Readonly<{ title?: NativeListTextStyle; subtitle?: NativeListTextStyle; value?: NativeListTextStyle; }>; -export type ActionRowStyle = RowBoxStyle & +export type ActionRowStyle = TemplateBoxStyle<'action'> & Readonly<{ title?: NativeListTextStyle; value?: NativeListTextStyle; }>; -export type SystemRowStyle = RowBoxStyle & +export type SystemRowStyle = TemplateBoxStyle<'system'> & Readonly<{ title?: NativeListTextStyle; message?: NativeListTextStyle; diff --git a/native-views/react-native-native-list/src/validation.ts b/native-views/react-native-native-list/src/validation.ts index ec3dbac6b..821c377e0 100644 --- a/native-views/react-native-native-list/src/validation.ts +++ b/native-views/react-native-native-list/src/validation.ts @@ -1,3 +1,4 @@ +import { ROW_BOX_STYLE_KEYS_BY_TYPE } from './models'; import type { ActivityRow, IdentityRow, @@ -156,16 +157,6 @@ const TYPOGRAPHY_TOKENS: Readonly< $bodyXs: { fontSize: 11, lineHeight: 16, fontWeight: 'regular' }, }; -const BOX_STYLE_KEYS: readonly string[] = [ - 'horizontalPadding', - 'verticalPadding', - 'leadingGap', - 'lineGap', - 'titleBadgeGap', - 'trailingGap', - 'image', -]; - /** Style keys name model fields, never views. See docs/STYLE_SPEC.md §4. */ const TEXT_STYLE_KEYS_BY_ROW_TYPE: Readonly< Record @@ -234,8 +225,12 @@ function assertTextStyle( path, 'text style key' ); - if (style.color !== undefined && typeof style.color !== 'string') { - fail(`${path}.color`, 'must be a color string'); + if ( + style.color !== undefined && + (typeof style.color !== 'string' || + !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(style.color)) + ) { + fail(`${path}.color`, 'must be #RRGGBB or #RRGGBBAA'); } if ( style.token !== undefined && @@ -362,7 +357,7 @@ function assertRowStyle(row: RowModel, path: string): void { } const textKeys = TEXT_STYLE_KEYS_BY_ROW_TYPE[row.type] ?? []; const allowed = new Set([ - ...BOX_STYLE_KEYS, + ...ROW_BOX_STYLE_KEYS_BY_TYPE[row.type], ...textKeys, ...(EXTRA_STYLE_KEYS_BY_ROW_TYPE[row.type] ?? []), ]); diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index deebfc295..9fc176e33 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -2146,10 +2146,11 @@ function appendAccessories( ) { setData(container, 'nativeListAccountControl', 'createAddress'); } + let valueIndex = 0; accessories.forEach((accessory, slot) => { const element = createAccessory(context, rowKey, accessory, slot); if (accessory.kind === 'value' || accessory.kind === 'valuePair') { - tagSlot(element, slot === 0 ? 'value' : 'valueSecondary'); + tagSlot(element, valueIndex++ === 0 ? 'value' : 'valueSecondary'); } container.appendChild(element); }); @@ -2184,6 +2185,13 @@ function createTextColumn( slots.title ); if (badges?.length) { + titleLine.removeAttribute('data-nl-slot'); + titleLine.replaceChildren( + tagSlot( + createElement(context.document, 'span', undefined, title), + slots.title + ) + ); const badgeLine = createElement( context.document, 'span', @@ -2564,6 +2572,21 @@ function createSystemRow( 'message' ) ); + if (row.variant === 'retry') { + const action = tagSlot( + createElement( + context.document, + 'button', + 'ok-native-list-action-button', + row.actionText ?? 'Retry' + ), + 'actionText' + ); + action.setAttribute('type', 'button'); + setData(action, 'nativeListAction', row.actionKey); + markActionAnchorSource(action, 'trailingAccessory', 0); + body.appendChild(action); + } return body; } @@ -2589,6 +2612,7 @@ function createRailRow( 'title' ) ); + if (row.badge) body.appendChild(createBadge(context, row.badge)); if (row.status && row.status !== 'none') body.appendChild( tagSlot( @@ -2601,7 +2625,6 @@ function createRailRow( 'status' ) ); - if (row.badge) body.appendChild(createBadge(context, row.badge)); return body; } @@ -2911,22 +2934,17 @@ function createDataRow( ); cell.style.flex = String(column.weight ?? 1); setData(cell, 'align', column.alignment ?? 'start'); - const primary = tagSlot( - createElement(context.document, 'span', 'ok-native-list-data-primary'), - 'columns' + const primary = createElement( + context.document, + 'span', + 'ok-native-list-data-primary' ); primary.style.color = toneColor(column.tone, 'primary'); - if (column.secondaryLeadingText) - primary.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - column.secondaryLeadingText - ) - ); primary.appendChild( - createElement(context.document, 'span', undefined, column.text) + tagSlot( + createElement(context.document, 'span', undefined, column.text), + 'columns' + ) ); if (column.key === 'asset' && row.badges?.length) { row.badges.forEach((badge) => @@ -2934,18 +2952,29 @@ function createDataRow( ); } cell.appendChild(primary); - if (column.secondaryText) { - const secondary = tagSlot( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - column.secondaryText - ), - 'columnSecondary' + if (column.secondaryLeadingText || column.secondaryText) { + const secondaryLine = createElement( + context.document, + 'span', + 'ok-native-list-data-secondary-line' ); - secondary.style.color = toneColor(column.secondaryTone, 'secondary'); - cell.appendChild(secondary); + secondaryLine.style.display = 'flex'; + secondaryLine.style.gap = '4px'; + for (const text of [column.secondaryLeadingText, column.secondaryText]) { + if (!text) continue; + const secondary = tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-secondary', + text + ), + 'columnSecondary' + ); + secondary.style.color = toneColor(column.secondaryTone, 'secondary'); + secondaryLine.appendChild(secondary); + } + cell.appendChild(secondaryLine); } body.appendChild(cell); }); @@ -3071,7 +3100,10 @@ function createIdentityActivityOrMessageRow( if (row.type === 'identity') { const titleElement = column.firstElementChild as HTMLElement; if (row.titleMatch?.length) { - const firstText = titleElement.firstChild; + const textTarget = + titleElement.querySelector('[data-nl-slot="title"]') ?? + titleElement; + const firstText = textTarget.firstChild; if (firstText) firstText.remove(); const fragment = context.document.createDocumentFragment(); let offset = 0; @@ -3091,7 +3123,7 @@ function createIdentityActivityOrMessageRow( fragment.appendChild( context.document.createTextNode(row.title.slice(offset)) ); - titleElement.prepend(fragment); + textTarget.prepend(fragment); } if (row.subtitleSegments?.length) { column.querySelector('.ok-native-list-secondary')?.remove(); @@ -3115,6 +3147,7 @@ function createIdentityActivityOrMessageRow( 'ok-native-list-secondary', segment.text ); + tagSlot(text, 'subtitle'); applyValueSegments(text, segment.textSegments, 14, 20, 400); setData(text, 'tone', segment.tone); text.style.color = @@ -3141,11 +3174,14 @@ function createIdentityActivityOrMessageRow( } if (row.type === 'activity' && row.status) column.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - row.status + tagSlot( + createElement( + context.document, + 'span', + 'ok-native-list-secondary', + row.status + ), + 'status' ) ); if (row.type === 'activity' && row.footerActions?.length) { @@ -3619,6 +3655,16 @@ function applyTextStyleToSlot( if (style.fontWeight !== undefined) element.style.fontWeight = String(marketFontWeight(style.fontWeight, 400)); if (style.color !== undefined) element.style.color = style.color; + // Explicit overrides also apply to rich text runs, but never to sibling badges. + element.querySelectorAll('span').forEach((run) => { + if (style.color !== undefined) run.style.color = style.color; + if (style.fontSize !== undefined) + run.style.fontSize = String(style.fontSize) + 'px'; + if (style.fontWeight !== undefined) + run.style.fontWeight = String(marketFontWeight(style.fontWeight, 400)); + if (style.lineHeight !== undefined) + run.style.lineHeight = String(style.lineHeight) + 'px'; + }); if (style.alignment !== undefined) element.style.textAlign = style.alignment; if (style.lines !== undefined) { element.style.removeProperty('-webkit-line-clamp'); @@ -3648,13 +3694,151 @@ export function applyRowStyle(body: HTMLElement, row: RowModel): void { body.style.paddingInline = String(box.horizontalPadding) + 'px'; if (box.verticalPadding !== undefined) body.style.paddingBlock = String(box.verticalPadding) + 'px'; - if (box.lineGap !== undefined && row.type !== 'walletGroup') { - const lineGap = String(box.lineGap) + 'px'; + const compositeMetric = + row.type === 'metricCard' && + (row.variant === 'activity' || row.variant === 'performance'); + const vertical = + row.type === 'mediaTile' || + row.type === 'metricCard' || + (row.type === 'identity' && row.presentation === 'walletSidebar'); + const leading = body.querySelector( + ':scope > .ok-native-list-visual, :scope > .ok-native-list-stacked, :scope > .ok-native-list-media-image' + ); + const defaultGap = + body.style.gap || + (row.type === 'mediaTile' + ? '7px' + : row.type === 'rail' + ? '6px' + : row.type === 'metricCard' + ? '5px' + : row.type === 'identity' && row.presentation === 'walletSidebar' + ? '4px' + : row.type === 'identity' && row.presentation === 'accountSelector' + ? '8px' + : '12px'); + if (box.lineGap !== undefined) { + const targets = + row.type === 'dataRow' + ? body.querySelectorAll('.ok-native-list-data-cell') + : row.type === 'mediaTile' + ? body.querySelectorAll('.ok-native-list-media-meta') + : row.type === 'metricCard' || + (row.type === 'system' && row.variant === 'warning') + ? [body] + : body.querySelectorAll(':scope > .ok-native-list-flex'); + targets.forEach((column) => { + if (row.type === 'mediaTile') { + column.style.display = 'flex'; + column.style.flexDirection = 'column'; + } + column.style.rowGap = String(box.lineGap) + 'px'; + }); + } + if (leading && box.leadingGap !== undefined) { + const gap = + 'calc(' + + String(box.leadingGap) + + 'px - ' + + (row.type === 'metricCard' && box.lineGap !== undefined + ? String(box.lineGap) + 'px' + : defaultGap) + + ')'; + if (vertical) leading.style.marginBottom = gap; + else leading.style.marginInlineEnd = gap; + } + if (box.titleBadgeGap !== undefined) { + const gap = String(box.titleBadgeGap) + 'px'; + if (row.type === 'dataRow') + body + .querySelectorAll('.ok-native-list-data-primary') + .forEach((line) => { + line.style.gap = gap; + }); + else + body + .querySelectorAll( + '.ok-native-list-badges, .ok-native-list-wallet-badges' + ) + .forEach((badges) => { + if (badges.classList.contains('ok-native-list-wallet-badges')) + badges.style.marginTop = + box.lineGap === undefined + ? gap + : 'calc(' + gap + ' - ' + String(box.lineGap) + 'px)'; + else badges.style.marginInlineStart = gap; + }); + if (row.type === 'rail') { + const badge = body.querySelector('[data-nl-slot="badge"]'); + if (badge) + badge.style.marginInlineStart = + 'calc(' + gap + ' - ' + defaultGap + ')'; + } + } + if (box.trailingGap !== undefined) { + const gap = String(box.trailingGap) + 'px'; body - .querySelectorAll('.ok-native-list-flex') - .forEach((column) => { - column.style.rowGap = lineGap; + .querySelectorAll( + '.ok-native-list-accessories, .ok-native-list-amounts' + ) + .forEach((trailing) => { + trailing.style.gap = gap; }); + if (row.type === 'sectionHeader') { + const children = Array.from(body.children) as HTMLElement[]; + const columnIndex = children.findIndex((child) => + child.classList.contains('ok-native-list-flex') + ); + children.slice(columnIndex + 2).forEach((child) => { + child.style.marginInlineStart = + 'calc(' + gap + ' - ' + defaultGap + ')'; + }); + } + if (row.type === 'rail') { + const status = body.querySelector('[data-nl-slot="status"]'); + if (status) + status.style.marginInlineStart = + 'calc(' + gap + ' - ' + defaultGap + ')'; + } + } + if (leading && box.image && !compositeMetric) { + const image = box.image; + if (image.width !== undefined) { + leading.style.width = String(image.width) + 'px'; + if (!vertical) leading.style.flexBasis = String(image.width) + 'px'; + } + if (image.height !== undefined) + leading.style.height = String(image.height) + 'px'; + const radius = + image.cornerRadius !== undefined + ? String(image.cornerRadius) + 'px' + : image.shape === 'circle' + ? '50%' + : image.shape === 'square' + ? '0px' + : image.shape === 'rounded' + ? '10px' + : undefined; + if (radius !== undefined) { + leading.style.setProperty('border-radius', radius, 'important'); + // Clip the bitmap itself; decorations may extend beyond the visual slot. + leading.style.overflow = leading.matches('img') ? 'hidden' : 'visible'; + } + const images = leading.matches('img') + ? [leading] + : leading.querySelectorAll( + ':scope > img, :scope > .ok-native-list-visual-fallback' + ); + images.forEach((bitmap) => { + if (bitmap !== leading) { + if (image.width !== undefined) bitmap.style.width = '100%'; + if (image.height !== undefined) bitmap.style.height = '100%'; + } + if (radius !== undefined) bitmap.style.borderRadius = radius; + if (image.contentFit !== undefined) + bitmap.style.objectFit = + image.contentFit === 'center' ? 'none' : image.contentFit; + }); } Object.keys(style).forEach((key) => { if (ROW_BOX_STYLE_KEYS.has(key)) return; @@ -3662,7 +3846,13 @@ export function applyRowStyle(body: HTMLElement, row: RowModel): void { if (!slotStyle) return; body .querySelectorAll('[data-nl-slot="' + key + '"]') - .forEach((element) => applyTextStyleToSlot(element, slotStyle)); + .forEach((element) => { + applyTextStyleToSlot(element, slotStyle); + if (row.type === 'dataRow' && slotStyle.alignment) { + element.style.flex = '1'; + if (element.parentElement) element.parentElement.style.width = '100%'; + } + }); }); } From 8d9260a6d16723297006cd011329cb1962766dc5 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 18:12:59 +0800 Subject: [PATCH 10/71] feat(native-list): define container and text layout styles --- .../pages/NativeListRowStylePage.tsx | 68 +++- .../onekey/nativelist/NativeListRowView.kt | 117 ++++++- .../com/onekey/nativelist/NativeListView.kt | 4 +- .../docs/ROW_TEMPLATES.md | 15 +- .../docs/STYLE_SPEC.md | 128 ++++++- .../docs/images/row-style-regions.svg | 30 ++ .../ios/NativeListCell.swift | 220 ++++++++++-- .../ios/RNCNativeListView.swift | 62 ++-- .../src/__tests__/NativeList.web.test.ts | 224 ++++++++++++ .../src/__tests__/validation.test.ts | 72 ++++ .../react-native-native-list/src/models.ts | 19 +- .../src/validation.ts | 82 ++++- .../src/web/NativeListWebEngine.ts | 329 +++++++++++++++--- 13 files changed, 1223 insertions(+), 147 deletions(-) create mode 100644 native-views/react-native-native-list/docs/images/row-style-regions.svg diff --git a/example/react-native/pages/NativeListRowStylePage.tsx b/example/react-native/pages/NativeListRowStylePage.tsx index e0d1d587a..bbab88fa7 100644 --- a/example/react-native/pages/NativeListRowStylePage.tsx +++ b/example/react-native/pages/NativeListRowStylePage.tsx @@ -64,7 +64,19 @@ function buildRows(styled: boolean): RowModel[] { style: { // A named step from the application's scale, resolved to numbers in // JavaScript before the snapshot is serialized. - title: { token: '$bodyMd' as const }, + container: { + backgroundColor: '#EDF6FF', + borderWidth: 1, + borderColor: '#8DB7E4', + cornerRadius: 12, + contentVerticalAlignment: 'top' as const, + }, + title: { + token: '$bodyMd' as const, + lines: 1 as const, + truncate: 'tail' as const, + offsetY: -1, + }, subtitle: { fontSize: 12, lineHeight: 16, color: '#8D8D8D' }, badge: { fontSize: 10 }, value: { token: '$bodySm' as const }, @@ -122,7 +134,14 @@ function buildRows(styled: boolean): RowModel[] { ? { style: { title: { token: '$bodyMd' as const }, - body: { fontSize: 12, lineHeight: 16 }, + body: { + fontSize: 12, + lineHeight: 16, + lines: 3 as const, + truncate: 'clip' as const, + verticalAlignment: 'top' as const, + }, + container: { backgroundColor: '#FFF8E7', cornerRadius: 8 }, time: { fontSize: 10, color: '#8D8D8D' }, }, } @@ -340,6 +359,51 @@ function buildRows(styled: boolean): RowModel[] { : {}), }); + rows.push( + header('text-layout', 'Text layout / row container', 'same allocation'), + ); + for (const alignment of ['top', 'center', 'bottom'] as const) { + rows.push({ + type: 'message', + key: `text-layout-${alignment}`, + sectionKey: 'text-layout', + height: 180, + title: `${alignment}: single line\nwith an explicit break`, + body: 'First line\nSecond line\nThird line\nFourth line is truncated', + time: '12:00', + ...(styled + ? { + style: { + horizontalPadding: 18, + verticalPadding: 12, + lineGap: 6, + container: { + backgroundColor: '#EDF6FF', + opacity: 0.9, + cornerRadius: 14, + borderWidth: 2, + borderColor: '#8DB7E4', + contentVerticalAlignment: alignment, + }, + title: { + lines: 1 as const, + truncate: 'tail' as const, + alignment: 'center' as const, + }, + body: { + lines: 3 as const, + truncate: + alignment === 'bottom' + ? ('clip' as const) + : ('tail' as const), + lineHeight: 20, + }, + }, + } + : {}), + }); + } + return rows; } diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index 52d3c5679..e1b2cf562 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -187,7 +187,28 @@ private class SelectorLineHeightSpan(private val lineHeight: Int) : android.text } } -private class DottedUnderlineTextView(context: android.content.Context) : TextView(context) { +private open class NativeListTextView(context: android.content.Context) : TextView(context) { + var rowHorizontallyScrolling = false + private set + + override fun setHorizontallyScrolling(whether: Boolean) { + rowHorizontallyScrolling = whether + super.setHorizontallyScrolling(whether) + } + + var opticalOffsetY = 0f + set(value) { field = value; invalidate() } + + override fun onDraw(canvas: Canvas) { + if (opticalOffsetY == 0f) { super.onDraw(canvas); return } + val checkpoint = canvas.save() + canvas.translate(0f, opticalOffsetY) + super.onDraw(canvas) + canvas.restoreToCount(checkpoint) + } +} + +private class DottedUnderlineTextView(context: android.content.Context) : NativeListTextView(context) { var useSourceScale = false private fun scaledDp(value: Float) = if (useSourceScale) value * resources.displayMetrics.density else NativeListScale.dp(resources, value) var showsDottedUnderline = false @@ -279,11 +300,11 @@ private class SelectorSubtitleLayout(context: android.content.Context) : LinearL private class NativeListTableColumnView(context: android.content.Context) : LinearLayout(context) { private val primaryLine = LinearLayout(context) - private val primary = TextView(context) + private val primary = NativeListTextView(context) private val badges = LinearLayout(context) private val secondaryLine = LinearLayout(context) - private val secondaryLeading = TextView(context) - private val secondary = TextView(context) + private val secondaryLeading = NativeListTextView(context) + private val secondary = NativeListTextView(context) init { orientation = VERTICAL @@ -489,13 +510,13 @@ internal class NativeListRowView( private val mediaMetadataRow = LinearLayout(context) private val titleLine = PackedTitleLineLayout(context) private val title = DottedUnderlineTextView(context) - private val subtitle = TextView(context) + private val subtitle = NativeListTextView(context) // OneKey patch: the first text shrinks while the volume retains its width. private val marketSubtitleLine = PackedTitleLineLayout(context) - private val tertiary = TextView(context) - private val status = TextView(context) - private val metricSubtitle = TextView(context) - private val badgeLine = TextView(context) + private val tertiary = NativeListTextView(context) + private val status = NativeListTextView(context) + private val metricSubtitle = NativeListTextView(context) + private val badgeLine = NativeListTextView(context) private val marketBadgeViews = List(3) { LinearLayout(context) } private val marketBadgeLabels = List(3) { TextView(context) } private val marketBadgeImages = List(3) { OneKeyImageReusableView(reactContext) } @@ -504,14 +525,14 @@ internal class NativeListRowView( private val actionLine = LinearLayout(context) private val actionViews = List(3) { TextView(context) } private val trailingColumn = LinearLayout(context) - private val trailingViews = List(2) { TextView(context) } + private val trailingViews = List(2) { NativeListTextView(context) } private val trailingIcons = List(2) { OneKeyIconView(context) } private val checkbox = OneKeyCheckboxView(context) private val spinner = ProgressBar(context) private val tableDataContainer = LinearLayout(context) private val tableDataColumns = List(4) { NativeListTableColumnView(context) } private val unreadDot = View(context) - private val mediaBadge = TextView(context) + private val mediaBadge = NativeListTextView(context) private val skeletonPrimary = View(context) private val skeletonSecondary = View(context) private val walletGroupRows = mutableListOf() @@ -1086,11 +1107,42 @@ internal class NativeListRowView( * A style key names a model field; nativeListStyleSlot maps it to the view that * renders that field, because the view pool is shared across templates. */ + private fun applyContainerStyle(item: NativeListItem, style: JSONObject?) { + if (style == null) return + alpha = style.optDouble("opacity", item.json.optDouble("opacity", 1.0)).toFloat() * (if (isEnabled) 1f else 0.5f) + fun decorate(drawable: android.graphics.drawable.Drawable?, resting: Boolean): android.graphics.drawable.Drawable? { + val surface = (drawable?.constantState?.newDrawable()?.mutate() as? GradientDrawable) ?: return drawable + if (resting && style.has("backgroundColor")) surface.setColor(safeColor(style.optString("backgroundColor"), Color.TRANSPARENT)) + if (style.has("cornerRadius")) { + val radius = styleDp(style.optDouble("cornerRadius")).toFloat() + surface.cornerRadii = FloatArray(8) { radius } + } + if (style.has("borderWidth")) surface.setStroke(styleDp(style.optDouble("borderWidth")), safeColor(style.optString("borderColor"), Color.TRANSPARENT)) + else if (style.has("borderColor")) surface.setStroke(if (item.type == "walletGroup") dp(1) else 0, safeColor(style.optString("borderColor"), Color.TRANSPARENT)) + return surface + } + restingRowBackground = decorate(restingRowBackground, true) + pressedRowBackground = decorate(pressedRowBackground, false) + background = if (touchPressed || reorderActive) pressedRowBackground else restingRowBackground + val oldClip = clipToOutline + val oldGravity = gravity + styledBoxRestorations.add { clipToOutline = oldClip; gravity = oldGravity } + if (style.has("cornerRadius")) clipToOutline = true + if (style.has("contentVerticalAlignment")) { + gravity = (gravity and Gravity.VERTICAL_GRAVITY_MASK.inv()) or when (style.optString("contentVerticalAlignment")) { + "top" -> Gravity.TOP + "bottom" -> Gravity.BOTTOM + else -> Gravity.CENTER_VERTICAL + } + } + } + private fun applyRowStyle(item: NativeListItem) { - // Market owns its own richer style path, applied inside bindMarket. - if (item.type == "market") return val style = item.json.optJSONObject("style") ?: return styledViewsDirty = true + applyContainerStyle(item, style.optJSONObject("container")) + // Market text/box styles are applied by its quote-aware binder. + if (item.type == "market") return val padding = intArrayOf(paddingLeft, paddingTop, paddingRight, paddingBottom) styledBoxRestorations.add { setPadding(padding[0], padding[1], padding[2], padding[3]) } if (style.has("horizontalPadding")) { @@ -1248,6 +1300,8 @@ internal class NativeListRowView( val maxLines = view.maxLines val ellipsize = view.ellipsize val gravity = view.gravity + val offset = (view as? NativeListTextView)?.opticalOffsetY ?: 0f + val scrolling = (view as? NativeListTextView)?.rowHorizontallyScrolling ?: false styledTextRestorations[view] = { view.setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, textSize) view.typeface = typeface @@ -1257,6 +1311,8 @@ internal class NativeListRowView( view.maxLines = maxLines view.ellipsize = ellipsize view.gravity = gravity + (view as? NativeListTextView)?.opticalOffsetY = offset + view.setHorizontallyScrolling(scrolling) } } if (style.has("fontSize")) { @@ -1282,9 +1338,26 @@ internal class NativeListRowView( TextViewCompat.setLineHeight(view, styleDp(style.optDouble("lineHeight"))) } if (style.has("lines")) { - view.maxLines = style.optInt("lines").coerceIn(1, 2) + view.maxLines = style.optInt("lines").coerceIn(1, 3) view.ellipsize = TextUtils.TruncateAt.END } + if (style.has("truncate") || style.has("lines")) { + view.setHorizontallyScrolling(view.maxLines == 1) + view.ellipsize = if (style.optString("truncate", "tail") == "clip") null else TextUtils.TruncateAt.END + if (view.maxLines == 1) { + val text = SpannableStringBuilder(view.text) + Regex("\\r\\n|[\\r\\n]").findAll(text).toList().asReversed().forEach { match -> text.replace(match.range.first, match.range.last + 1, " ") } + view.text = text + } + } + if (style.has("verticalAlignment")) { + view.gravity = (view.gravity and Gravity.VERTICAL_GRAVITY_MASK.inv()) or when (style.optString("verticalAlignment")) { + "top" -> Gravity.TOP + "bottom" -> Gravity.BOTTOM + else -> Gravity.CENTER_VERTICAL + } + } + if (style.has("offsetY")) (view as? NativeListTextView)?.opticalOffsetY = (style.optDouble("offsetY") * resources.displayMetrics.density).toFloat() style.optString("alignment").takeIf(String::isNotEmpty)?.let { view.gravity = (view.gravity and Gravity.VERTICAL_GRAVITY_MASK) or when (it) { "center" -> Gravity.CENTER_HORIZONTAL @@ -1386,6 +1459,7 @@ internal class NativeListRowView( boundCheckboxData = latestCheckbox ?: boundCheckboxData boundCheckboxData?.let { bindCheckbox(item, it, checkboxState) } } + if (item.type == "market") bindMarketQuote(item, theme) applyRowStyle(item) } @@ -1985,7 +2059,7 @@ internal class NativeListRowView( val badgeHeight = badgeLineHeight + 4 val line = LinearLayout(context).apply { orientation = HORIZONTAL; gravity = Gravity.CENTER } for (index in 0 until badges.length()) { - val badge = TextView(context).apply { + val badge = NativeListTextView(context).apply { text = badges.getJSONObject(index).optString("text") textSize = sp(if (isSelector) 11f else 12f) typeface = NativeListFonts.regular(context) @@ -2070,7 +2144,7 @@ internal class NativeListRowView( val dot = View(context).apply { background = roundedFill(color(theme, "disabledText", "#00000072"), 2f) } line.addView(dot, LayoutParams(dp(4), dp(4)).apply { marginStart = dp(6); marginEnd = dp(6) }) } - val label = TextView(context).apply { + val label = NativeListTextView(context).apply { text = segment.optString("text") typeface = NativeListFonts.regular(context) textSize = sp(14f) @@ -2361,7 +2435,7 @@ internal class NativeListRowView( "end" -> Gravity.END else -> Gravity.START } - view.maxLines = style?.optInt("lines", 1)?.coerceIn(1, 2) ?: 1 + view.maxLines = style?.optInt("lines", 1)?.coerceIn(1, 3) ?: 1 view.ellipsize = TextUtils.TruncateAt.END TextViewCompat.setLineHeight( view, @@ -2386,6 +2460,12 @@ internal class NativeListRowView( view.setLineSpacing(0f, 1f) } view.text = text + val layout = JSONObject() + listOf("lines", "truncate", "verticalAlignment", "offsetY").forEach { key -> if (style.has(key)) layout.put(key, style.get(key)) } + if (layout.length() > 0) { + styledViewsDirty = true + applyStyledText(view, layout) + } } private fun marketText(value: String, segments: JSONArray?, fontSize: Float): CharSequence { @@ -3080,7 +3160,7 @@ internal class NativeListRowView( textColor: Int, letterSpacingDp: Float = 0f, gravity: Int = Gravity.START, - ) = TextView(context).apply { + ) = NativeListTextView(context).apply { includeFontPadding = false text = value textSize = sp(size) @@ -3958,6 +4038,7 @@ internal class NativeListRowView( } // OneKey patch: section heading backgrounds are independent of list rows. if (item.json.has("backgroundColor")) rowBackground = safeColor(item.json.optString("backgroundColor"), rowBackground) + item.json.optJSONObject("style")?.optJSONObject("container")?.takeIf { it.has("backgroundColor") }?.let { rowBackground = safeColor(it.optString("backgroundColor"), rowBackground) } restingRowBackground = groupedBackground(backgroundGroupPosition, rowBackground) background = restingRowBackground } diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt index 4c3957220..d388e2e75 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt @@ -2714,9 +2714,11 @@ private class SelectorBackgroundDecoration(private val adapter: NativeListAdapte val child = parent.getChildAt(index) val item = adapter.itemAt(parent.getChildAdapterPosition(child)) ?: continue if (!item.json.optBoolean("backgroundFullWidth", false)) continue - val color = item.json.optString("backgroundColor") + val color = item.json.optJSONObject("style")?.optJSONObject("container")?.optString("backgroundColor")?.takeIf { it.isNotEmpty() } ?: item.json.optString("backgroundColor") if (color.isEmpty()) continue paint.color = try { parseNativeListColor(color) } catch (_: IllegalArgumentException) { Color.TRANSPARENT } + val opacity = item.json.optJSONObject("style")?.optJSONObject("container")?.optDouble("opacity", item.json.optDouble("opacity", 1.0)) ?: item.json.optDouble("opacity", 1.0) + paint.alpha = (Color.alpha(paint.color) * opacity * (if (item.json.optBoolean("disabled", false)) 0.5 else 1.0)).toInt() val top = child.y canvas.drawRect(0f, top, parent.width.toFloat(), top + child.height, paint) if (item.type == "system" && item.json.optString("variant") == "warning") { diff --git a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md index 5beefb546..b02b43c4b 100644 --- a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md +++ b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md @@ -37,7 +37,9 @@ The style roles below form the shared configurable contract on Web, iOS and Android. **Template** means structure and content fields; **row style** means allowlisted parameters applied to those existing fields. Each template exposes only applicable box/image parameters, listed in [STYLE_SPEC §4.1](STYLE_SPEC.md#41-shared-configurable-properties). -All text roles share the same typography/color/alignment properties; supported +All templates accept the shared `style.container` surface (background, opacity, +radius, border and content vertical alignment). All text roles share the same +typography, 1–3 line limit, truncation, alignment and optical-offset properties; supported image roles share the same dimensions/shape/fit properties. An absent optional field is not created by styling it. Native rendered acceptance is tracked separately from source implementation in STYLE_SPEC §9. @@ -71,7 +73,7 @@ separately from source implementation in STYLE_SPEC §9. - **Required data:** `parent: IdentityRow`, nonempty `children: IdentityRow[]`. Every member must use `presentation: 'walletSidebar'`; `parent.key` must equal the group row's `key`, and member keys must be unique within the group. -- **Style surface:** group `style` exposes horizontal/vertical padding only. Text styles belong to +- **Style surface:** group `style` exposes the shared container appearance and horizontal/vertical padding. Text styles belong to `parent.style` or the individual `children[i].style`; there is no inherited group `title`/`value` text style. - **Layout boundary:** preserve parent-first member order and wallet member @@ -119,9 +121,8 @@ separately from source implementation in STYLE_SPEC §9. - **Use:** a notification or message preview. - **Required data:** `title`, `body`, `time`; optional `leading`, `thumbnail`, - `unread`. `bodyLines` selects one, two or three body lines. -- **Text style roles:** `title`, `body`, `time`. `NativeListTextStyle.lines` - currently allows only one or two; it is not the same field as `bodyLines`. + `unread`. `bodyLines` is the legacy fallback for one, two or three body lines. +- **Text style roles:** `title`, `body`, `time`. `style.body.lines` accepts one, two or three and takes precedence over `bodyLines`. - **Layout boundary:** time and thumbnail keep their allocated regions; body text wraps/truncates within the message area. Styling cannot displace the timestamp, grow over the next row, or turn the preview into unbounded content. @@ -174,8 +175,8 @@ separately from source implementation in STYLE_SPEC §9. overlays keep their template anchors. A text style cannot change the grid column count, tile span or move the caption over the image. - **Common-capability boundary:** grid columns and list scrolling belong to - `snapshot.layout`, not to `MediaTileRowStyle`. Image overrides outside Market - are still a declared-but-unapplied surface in this stack. + `snapshot.layout`, not to `MediaTileRowStyle`. Primary-image overrides are + supported on all three platforms; network/close overlays retain their anchors. ## metricCard diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index 12ec7d0f8..f55ae3952 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -75,8 +75,9 @@ by styling them. See the complete applicability matrix in §4.1. The 12 templates each have a definition and schematic in ROW_TEMPLATES.md. Overflow fitting remains the integrating developer's responsibility: choose -fitting text/image/padding values and row heights. Automatic measurement, -combination-fit rejection and style-driven auto-height are outside this contract. +fitting text/image/padding values and row heights. Automatic overflow fitting, combination-fit rejection and converting fixed rows +to auto-height are outside this contract. Existing measured templates account +for explicit text metrics in their existing height calculation. The five source PRs are closed; implementation continues in the consolidated PR. ## 2. How this spec is enforced @@ -109,7 +110,7 @@ object identity. | `dataRow` column styles | Independent primary/secondary text on all platforms, in linear and table layouts | | Sticky header styles | Android uses the same row view/binder as normal headers; iOS and Web also reuse their row renderers | | `listStyle.separator` / `groupCornerRadius` | Initial/update propagation and actual-group scope aligned | -| Row heights and overflow | Caller owns row allocation and fitting; style does not introduce auto-height | +| Row heights and overflow | Caller owns fixed allocation and fitting; existing measured message/warning paths account for text metrics | | Legacy baseline dimensions | Inventory in §4/§6; explicit style values use logical units independently of Android's legacy list scale | | Rendered acceptance | The source contract and build checks do not establish full native interaction/pixel acceptance (§9) | @@ -214,8 +215,9 @@ meaning is defined in the [catalog](ROW_TEMPLATES.md). ### 4.1 Shared configurable properties Every text role listed below accepts the same `NativeListTextStyle`: -`token`, `fontSize`, `fontWeight`, `color`, `lineHeight`, `lines`, `alignment`. -Weights are `regular | medium | semibold | bold`; line counts are `1 | 2`; +`token`, `fontSize`, `fontWeight`, `color`, `lineHeight`, `lines`, `truncate`, +`alignment`, `verticalAlignment`, `offsetY`. +Weights are `regular | medium | semibold | bold`; line counts are `1 | 2 | 3`; `start | center | end` alignment follows layout direction. Colors must be `#RRGGBB` or `#RRGGBBAA`, the common native/Web color format. Omitted properties preserve the template's values. Explicit properties override token values, @@ -269,6 +271,107 @@ explicit typography. Legacy Android scaling may affect omitted defaults, but mus not rescale an explicit style value. Physical-pixel/font-rasterization differences remain platform-specific. See §3.3 for bounds and §7 for fitting responsibility. +### 4.1.1 Row container (all 12 templates) + +![Container surface, padding, semantic gaps and text regions](images/row-style-regions.svg) + +`style.container` addresses the outer row surface, including `walletGroup` and +rows used as headers/footers. It never overwrites member styles; opacity naturally composites the entire +container including descendants. + +| Property | Values / default when supplied | Meaning | +| --- | --- | --- | +| `backgroundColor` | `#RRGGBB` / `#RRGGBBAA` | Resting row background | +| `opacity` | `0…1` | Opacity of the whole row, multiplied by disabled dimming | +| `cornerRadius` | `0…80` logical units | All four outer row corners | +| `borderWidth` | `0…8` logical units | Inward border; no effect on content allocation | +| `borderColor` | `#RRGGBB` / `#RRGGBBAA` | Border color; transparent if width is supplied alone | +| `contentVerticalAlignment` | `top / center / bottom` | Align the row's content within its padded height; preserve slot order and orientation | + +Omission preserves the template's existing value. Explicit container background +and opacity take precedence over legacy `row.backgroundColor` / `row.opacity`. +Existing top-level fields remain accepted; new integrations use `style.container`. +An explicit background is the resting surface (including selection); existing +pressed feedback remains visible. Radius and border remain during press/selection. +Explicit radius overrides grouped corner geometry for this row only; it does not +change `listStyle.groupCornerRadius`. `backgroundFullWidth` remains a layout/model +option and uses the resolved resting background. A border never changes padding. + +`horizontalPadding` / `verticalPadding` remain on `row.style`; there is no second +padding entry under `container`. Outer margin, width, height, positioning, +visibility, flex direction, column weights and hit-area changes are not container +style properties. Row allocation remains with the existing model/layout API. +Shadows, gradients and per-corner/per-edge styling are outside this version. + +```ts +// Inside an identity row; the caller supplies a fitting row.height separately. +style: { + container: { + backgroundColor: '#EDF6FF', cornerRadius: 12, + borderWidth: 1, borderColor: '#8DB7E4', contentVerticalAlignment: 'center', + }, + horizontalPadding: 16, verticalPadding: 12, leadingGap: 10, lineGap: 4, + title: { lines: 1, truncate: 'tail', alignment: 'start' }, + subtitle: { lines: 3, truncate: 'clip', lineHeight: 18, offsetY: -1 }, +} +``` + +### 4.1.2 Text wrapping, truncation and alignment + +| Property | Shared meaning | +| --- | --- | +| `lines` | Maximum 1, 2 or 3 lines, not a forced reservation of that many lines | +| `truncate` | `tail` (ellipsis at the end of the last visible line) or `clip` (no ellipsis); default `tail` when line/truncation controls are supplied | +| `lineHeight` | Line box height inside one text field, distinct from `lineGap` between fields | +| `alignment` | Horizontal `start / center / end` inside the text region, respecting direction | +| `verticalAlignment` | `top / center / bottom` for the complete text block inside its allocated text region; no effect without spare height | +| `offsetY` | `-8…8` logical units of optical translation; does not change measurement, sibling spacing or hit areas | + +Single-line text does not wrap; explicit CR/LF line breaks become spaces (CRLF is +one break). Multi-line text wraps naturally, preserves explicit breaks, and +truncates after the last permitted line. Rich runs share one line budget. Styling +one field must not alter adjacent badges, values or member rows. Middle/head +truncation is not exposed in this version; it needs a separate cross-platform +contract rather than platform-specific fallback. + +Every existing declared text role accepts the same range `1…3`; each template's +omitted defaults remain documented in the inventory. Optional absent fields stay +absent. `style.title.lines` overrides legacy `titleLines`, +`style.subtitle.lines` overrides `subtitleLines`, and `style.body.lines` overrides +`message.bodyLines`. New callers should use styles for all three. Removing a +style restores the model/default value. `patch.changes.style` replaces the whole +style object; `{}` clears it, including container and text overrides. + +Fixed row heights are never expanded automatically. Integrators choose a fitting +height; this is not automatic overflow prevention. Existing intrinsic/measured +paths must account for explicit text metrics within their declared measurement +model. Font shaping and exact wrap points can vary across platform fonts; the +property meanings and maximum line counts must agree. + +`container.contentVerticalAlignment` moves the existing content arrangement as a +whole within the row; text `verticalAlignment` moves only that field's glyph block. +They must not be substituted for each other. For horizontal rows, the container +alignment aligns existing immediate content groups on the vertical axis. + +### 4.1.3 Spacing and admission rules + +Spacing is always between named semantic regions, never arbitrary descendants: +`leadingGap` is visual-to-content, `titleBadgeGap` is title-to-badge, and +`trailingGap` is between the template's trailing items (rail: title/badge-to-status). +`lineGap` is between text fields; the existing composite metric variant explicitly +uses it between its heading/metric blocks. New templates must identify both +endpoints in their diagram and must not invent a different meaning for these keys. +List `layout.itemSpacing` remains the gap between rows. + +Before adding a template or style property, update the catalog with its diagram, +semantic fields, defaults, applicable variants, units/ranges, precedence and +absent-field behavior. Reuse this shared text/image/container vocabulary. A new +property requires Web/iOS/Android implementations, validation for snapshot and +patch paths, and focused checks for replacement/reset, recycling, rich text, +member isolation, sticky headers and fixed footers where applicable. Source/build +checks and rendered acceptance must be reported separately. No renderer may +silently reinterpret an unsupported value. + ### 4.2 Existing defaults (inventory, not a pixel parity claim) Legend: **=** inventoried defaults agree; **≠** legacy divergence, see §6. @@ -595,7 +698,9 @@ exposed — a hairline is correct on iOS and a whole pixel is correct elsewhere. | Native reuse leaks | Restore the actual bound text and local geometry before rebinding; preserve unspecified rich-text attributes | Styled → unstyled → another template during scrolling | | Android sticky header uses a separate partial renderer | Normal row view renders pinned text, values and controls | Pin/push-off, checkbox/action taps, scrolling and accessibility | | List chrome propagation and group scope | Initial/update paths receive listStyle; custom radius only applies to actual groups | Initial and chrome-only snapshot interactions | -| Parent style cascades to wallet members | Group exposes padding only; members bind their own styles | Independent group/member styling during reorder | +| Parent style cascades to wallet members | Group exposes container appearance and padding; members bind their own styles | Independent group/member styling during reorder | +| Row container surface | Shared `style.container`, explicit-over-legacy precedence, disabled opacity, border/radius and content alignment | Press/selection/reorder and nested members on device | +| Text line controls | Shared 1–3 lines, tail/clip, vertical alignment and optical offset; measured text paths updated | Long/CJK/RTL/rich text and fixed-height clipping on device | Common headers/footers already exist through structural rows, `fixedFooter` and pager composition (§5.1). This work does not add an arbitrary React header slot. @@ -635,12 +740,11 @@ are explicitly out of scope. The following rules distinguish those responsibilit change slot order, orientation, column count/weight, structural spans, section membership, control placement or parent constraints. Those belong to the template, its explicit variant, or the container layout. -2. **Keep the allocated row frame authoritative.** A style-only update must not - alter the row's measured outer width/height, the next row's origin, scroll - offsets, sticky bounds or footer placement. If content needs more height, the - caller must supply an appropriate explicit `row.height` as a separate geometry - change and verify it with the container. There is no style-driven auto-height - in this stack. +2. **Keep fixed row allocation authoritative.** An explicit `row.height` and + fixed template allocation do not expand to fit style changes. If more space is + needed, the caller supplies a fitting height and verifies container geometry. + Existing measured message/warning paths include explicit text metrics in their + calculation; they do not turn other templates into auto-height rows. 3. **Integrator: fit content within that frame.** After local padding, each visual/control and text line box must fit. For a simple two-line identity row, a necessary check is `2 * verticalPadding + max(visualHeight, titleLineHeight + lineGap + diff --git a/native-views/react-native-native-list/docs/images/row-style-regions.svg b/native-views/react-native-native-list/docs/images/row-style-regions.svg new file mode 100644 index 000000000..0fa4bd6bb --- /dev/null +++ b/native-views/react-native-native-list/docs/images/row-style-regions.svg @@ -0,0 +1,30 @@ + + + +Row style: shared surface and semantic regions +Template owns order and grouping. The caller owns the allocated row height. + + + +image + +leadingGap + +Title text + +badge +titleBadgeGap + +lineGap +Body: lines 1 / 2 / 3, tail or clip +lineHeight separates lines inside this field + +value + + +trailingGap +container: background / opacity / cornerRadius / borderWidth / borderColor +Dashed area = content after horizontalPadding / verticalPadding. +contentVerticalAlignment: top / center / bottom in the padded row. +Text alignment stays inside its region; offsetY shifts only glyphs, without changing spacing. + diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index 32617e5c6..4fcfd6ddf 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -122,6 +122,7 @@ final class NativeListActionOrigin { // OneKey patch: explicit summary actions use the source text's physical-pixel line box. private final class NativeListAccessoryButton: UIButton { var pressedBackgroundColor: UIColor? + var rowTextOffsetY: CGFloat = 0 override var isHighlighted: Bool { didSet { @@ -156,6 +157,15 @@ private final class NativeListAccessoryButton: UIButton { override func layoutSubviews() { super.layoutSubviews() + defer { + if let titleLabel { + var frame = titleLabel.frame + if contentVerticalAlignment == .top { frame.origin.y = contentEdgeInsets.top } + else if contentVerticalAlignment == .bottom { frame.origin.y = bounds.height - contentEdgeInsets.bottom - frame.height } + frame.origin.y += rowTextOffsetY + titleLabel.frame = frame + } + } if let lineHeight = marketLineHeight, let titleLabel, let title = attributedTitle(for: .normal) { let measured = title.boundingRect( with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude), @@ -164,10 +174,13 @@ private final class NativeListAccessoryButton: UIButton { let width = min(bounds.width, ceil(measured * sourcePixelScale) / sourcePixelScale) let x = contentHorizontalAlignment == .trailing ? bounds.width - width : contentHorizontalAlignment == .leading ? 0 : (bounds.width - width) / 2 + let textHeight = titleLabel.numberOfLines > 1 + ? min(CGFloat(titleLabel.numberOfLines) * lineHeight, titleLabel.sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude)).height) + : lineHeight titleLabel.frame = CGRect( x: floor(x * sourcePixelScale) / sourcePixelScale, - y: (bounds.height - lineHeight) / 2, - width: width, height: lineHeight + y: (bounds.height - textHeight) / 2, + width: width, height: textHeight ) return } @@ -180,7 +193,23 @@ private final class NativeListAccessoryButton: UIButton { } } -private final class NativeListInsetLabel: UILabel { +private class NativeListTextLabel: UILabel { + var rowVerticalAlignment: String? { didSet { setNeedsDisplay() } } + var rowOffsetY: CGFloat = 0 { didSet { setNeedsDisplay() } } + + override func drawText(in rect: CGRect) { + var target = rect + if let alignment = rowVerticalAlignment { + let height = super.textRect(forBounds: rect, limitedToNumberOfLines: numberOfLines).height + target.origin.y += alignment == "top" ? 0 : alignment == "bottom" ? rect.height - height : (rect.height - height) / 2 + target.size.height = height + } + target.origin.y += rowOffsetY + super.drawText(in: target) + } +} + +private final class NativeListInsetLabel: NativeListTextLabel { var horizontalInset: CGFloat = 0 var topInset: CGFloat = 0 var bottomInset: CGFloat = 0 @@ -205,7 +234,7 @@ private final class NativeListInsetLabel: UILabel { } } -private final class NativeListDottedUnderlineLabel: UILabel { +private final class NativeListDottedUnderlineLabel: NativeListTextLabel { var showsDottedUnderline = false { didSet { setNeedsLayout() } } @@ -288,11 +317,11 @@ private final class NativeListDottedUnderlineLabel: UILabel { private final class NativeListTableColumnView: UIStackView { private let primaryLine = UIStackView() - private let primaryLabel = UILabel() + private let primaryLabel = NativeListTextLabel() private let badgesStack = UIStackView() private let secondaryLine = UIStackView() - private let secondaryLeadingLabel = UILabel() - private let secondaryLabel = UILabel() + private let secondaryLeadingLabel = NativeListTextLabel() + private let secondaryLabel = NativeListTextLabel() override init(frame: CGRect) { super.init(frame: frame) @@ -497,13 +526,13 @@ final class NativeListCell: UICollectionViewCell { private let mediaMetadataStack = UIStackView() private let titleRowStack = UIStackView() private let titleLabel = NativeListDottedUnderlineLabel() - private let subtitleLabel = UILabel() + private let subtitleLabel = NativeListTextLabel() // OneKey patch: keep Market name and volume in independent line boxes. private let marketSubtitleStack = UIStackView() private let marketSubtitleSpacer = UIView() - private let tertiaryLabel = UILabel() + private let tertiaryLabel = NativeListTextLabel() private let statusLabel = NativeListInsetLabel() - private let metricSubtitleLabel = UILabel() + private let metricSubtitleLabel = NativeListTextLabel() private let metricCompositeStack = UIStackView() private let badgeLabel = NativeListInsetLabel() // OneKey patch: reuse the existing explicit line-box layout for styled Market badges. @@ -520,7 +549,7 @@ final class NativeListCell: UICollectionViewCell { private let spinner = UIActivityIndicatorView(style: .medium) private let tableDataStack = UIStackView() private let tableDataColumns = (0..<4).map { _ in NativeListTableColumnView() } - private let mediaBadgeLabel = UILabel() + private let mediaBadgeLabel = NativeListTextLabel() private let skeletonPrimary = UIView() private let skeletonSecondary = UIView() private var walletGroupCells: [NativeListCell] = [] @@ -571,6 +600,7 @@ final class NativeListCell: UICollectionViewCell { private var visualBackdropColor = UIColor.white private var currentLayout = "linear" // OneKey patch: a row style wrote view properties that reset() does not restore. + private var styledTextViews = Set() private var styledViewsDirty = false private var styledTextRestorations: [() -> Void] = [] private var semanticBadgeLabels: [UILabel] = [] @@ -1004,7 +1034,7 @@ final class NativeListCell: UICollectionViewCell { pressedBackgroundColor = restingBackgroundColor } updateBackgroundColor() - if item.data.bool("backgroundFullWidth"), let background = item.data["backgroundColor"] as? String { + if item.data.bool("backgroundFullWidth"), let background = (item.data.dictionary("style")?.dictionary("container")?["backgroundColor"] ?? item.data["backgroundColor"]) as? String { selectorFullWidthBackground.backgroundColor = UIColor(nativeListHex: background, fallback: .clear).cgColor contentView.layer.insertSublayer(selectorFullWidthBackground, at: 0) clipsToBounds = false @@ -1025,7 +1055,7 @@ final class NativeListCell: UICollectionViewCell { isUserInteractionEnabled = !item.data.bool("disabled") // OneKey patch: deprecated wallets remain interactive while dimmed. // contentView.alpha = isUserInteractionEnabled ? 1 : 0.5 - contentView.alpha = CGFloat(item.data.double("opacity", default: 1)) * (isUserInteractionEnabled ? 1 : 0.5) + contentView.alpha = CGFloat(item.data.dictionary("style")?.dictionary("container")?.double("opacity", default: item.data.double("opacity", default: 1)) ?? item.data.double("opacity", default: 1)) * (isUserInteractionEnabled ? 1 : 0.5) accessibilityLabel = item.data.string("accessibilityLabel", default: item.data.string("title")) // OneKey patch: keep existing selector automation identifiers. accessibilityIdentifier = item.data["testID"] as? String @@ -1241,6 +1271,7 @@ final class NativeListCell: UICollectionViewCell { private func resetRowStyle() { guard styledViewsDirty else { return } styledViewsDirty = false + styledTextViews.removeAll(keepingCapacity: true) styledCircleViews.removeAll() styledTextRestorations.reversed().forEach { $0() } styledTextRestorations.removeAll(keepingCapacity: true) @@ -1497,6 +1528,8 @@ final class NativeListCell: UICollectionViewCell { layer.cornerRadius = 0 layer.cornerCurve = .circular contentView.layer.cornerRadius = 0 + contentView.layer.borderWidth = 0 + contentView.layer.borderColor = nil contentView.layer.cornerCurve = .circular contentView.clipsToBounds = false leadingContainer.alpha = 1 @@ -1707,7 +1740,62 @@ final class NativeListCell: UICollectionViewCell { ).cgColor } + private func applyContainerAppearance() { + guard let item = currentItem, let style = item.data.dictionary("style")?.dictionary("container") else { return } + if item.type == "walletGroup" && walletGroupCompactAppearanceActive { return } + let pressed = isHighlighted && isUserInteractionEnabled + if !pressed, let background = style["backgroundColor"] as? String { + contentView.backgroundColor = UIColor(nativeListHex: background, fallback: restingBackgroundColor) + } + if let opacity = style["opacity"] as? Double { contentView.alpha = CGFloat(opacity) * (item.data.bool("disabled") ? 0.5 : 1) } + if style["cornerRadius"] != nil { + let radius = CGFloat(style.double("cornerRadius")) + layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] + contentView.layer.maskedCorners = layer.maskedCorners + layer.cornerRadius = radius + contentView.layer.cornerRadius = radius + contentView.clipsToBounds = radius > 0 + } + if style["borderWidth"] != nil { + contentView.layer.borderWidth = CGFloat(style.double("borderWidth")) + contentView.layer.borderColor = UIColor(nativeListHex: style.string("borderColor", default: "#00000000"), fallback: .clear).cgColor + } else if let color = style["borderColor"] as? String { + contentView.layer.borderColor = UIColor(nativeListHex: color, fallback: .clear).cgColor + } + } + + private func applyContainerAlignment(_ style: [String: Any]) { + guard let alignment = style["contentVerticalAlignment"] as? String else { return } + let stack = rootStack + let topConstraint = rootTopConstraint! + let bottomConstraint = rootBottomConstraint! + let original = rootStack.alignment + if rootStack.axis == .horizontal { + rootStack.alignment = alignment == "top" ? .top : alignment == "bottom" ? .bottom : .center + styledTextRestorations.append { stack.alignment = original } + } else { + let top = rootTopConstraint.constant + let bottom = -rootBottomConstraint.constant + rootTopConstraint.isActive = false + rootBottomConstraint.isActive = false + let position: NSLayoutConstraint + if alignment == "top" { position = rootStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: top) } + else if alignment == "bottom" { position = rootStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -bottom) } + else { position = rootStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor, constant: (top - bottom) / 2) } + let bounds = [position, + rootStack.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: top), + rootStack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -bottom)] + NSLayoutConstraint.activate(bounds) + styledTextRestorations.append { + NSLayoutConstraint.deactivate(bounds) + topConstraint.isActive = true + bottomConstraint.isActive = true + } + } + } + private func updateBackgroundColor() { + defer { applyContainerAppearance() } let pressed = isHighlighted && isUserInteractionEnabled contentView.backgroundColor = pressed ? pressedBackgroundColor : restingBackgroundColor if currentItem?.type == "walletGroup" { @@ -1935,7 +2023,7 @@ final class NativeListCell: UICollectionViewCell { ]) line.addArrangedSubview(gap) } - let label = UILabel() + let label = NativeListTextLabel() label.font = nativeListFont(ofSize: 14) label.lineBreakMode = .byTruncatingTail // Match V1: the balance may shrink, but keep the shortened address intact. @@ -2338,9 +2426,19 @@ final class NativeListCell: UICollectionViewCell { last writer. Market owns its own richer path inside bindMarket. */ private func applyRowStyle(_ item: NativeListItem) { - if item.type == "market" { return } guard let style = item.data.dictionary("style") else { return } styledViewsDirty = true + defer { + applyContainerAppearance() + if let container = style.dictionary("container") { applyContainerAlignment(container) } + } + if item.type == "market" { + applyStyledText(titleLabel, textLayoutStyle(style.dictionary("title"))) + applyStyledText(subtitleLabel, textLayoutStyle(style.dictionary("subtitle"))) + applyStyledText(tertiaryLabel, textLayoutStyle(item.data.dictionary("subtitlePrefix")?.dictionary("style"))) + updateMarketQuote(item, theme: currentTheme) + return + } let constraints = [rootLeadingConstraint!, rootTrailingConstraint!, rootTopConstraint!, rootBottomConstraint!, leadingWidth!, leadingHeight!, mediaHeight!] let constants = constraints.map { $0.constant } let active = constraints.map { $0.isActive } @@ -2477,22 +2575,40 @@ final class NativeListCell: UICollectionViewCell { } } + private func textLayoutStyle(_ style: [String: Any]?) -> [String: Any] { + (style ?? [:]).filter { ["lines", "truncate", "verticalAlignment", "offsetY"].contains($0.key) } + } + + private func normalizeSingleLine(_ text: NSMutableAttributedString) { + let pattern = try! NSRegularExpression(pattern: "\\r\\n|[\\r\\n]") + for match in pattern.matches(in: text.string, range: NSRange(location: 0, length: text.length)).reversed() { + text.replaceCharacters(in: match.range, with: " ") + } + } + private func applyStyledText(_ label: UILabel, _ style: [String: Any]) { let text = label.attributedText?.string ?? label.text ?? "" - guard !text.isEmpty else { return } + guard !text.isEmpty, !style.isEmpty else { return } let originalFont = label.font let originalColor = label.textColor let originalText = label.attributedText let originalLines = label.numberOfLines let originalAlignment = label.textAlignment let originalBreakMode = label.lineBreakMode - styledTextRestorations.append { - label.font = originalFont - label.textColor = originalColor - label.attributedText = originalText - label.numberOfLines = originalLines - label.textAlignment = originalAlignment - label.lineBreakMode = originalBreakMode + let textLabel = label as? NativeListTextLabel + let originalVertical = textLabel?.rowVerticalAlignment + let originalOffset = textLabel?.rowOffsetY ?? 0 + if styledTextViews.insert(ObjectIdentifier(label)).inserted { + styledTextRestorations.append { + label.font = originalFont + label.textColor = originalColor + label.attributedText = originalText + label.numberOfLines = originalLines + label.textAlignment = originalAlignment + label.lineBreakMode = originalBreakMode + textLabel?.rowVerticalAlignment = originalVertical + textLabel?.rowOffsetY = originalOffset + } } let baseFont = (originalText?.attribute(.font, at: 0, effectiveRange: nil) as? UIFont) ?? label.font ?? nativeListFont(ofSize: 14) let size = CGFloat(style.double("fontSize", default: Double(baseFont.pointSize))) @@ -2509,12 +2625,17 @@ final class NativeListCell: UICollectionViewCell { .map { UIColor(nativeListHex: $0, fallback: label.textColor ?? .black) } ?? label.textColor ?? .black if style["lines"] != nil { - label.numberOfLines = min(2, max(1, style.int("lines", default: 1))) + label.numberOfLines = min(3, max(1, style.int("lines", default: 1))) label.lineBreakMode = .byTruncatingTail } if let alignmentName = style["alignment"] as? String { label.textAlignment = marketTextAlignment(alignmentName) } + if style["truncate"] != nil || style["lines"] != nil { + label.lineBreakMode = style.string("truncate", default: "tail") == "clip" ? .byClipping : .byTruncatingTail + } + if let alignment = style["verticalAlignment"] as? String { textLabel?.rowVerticalAlignment = alignment } + if style["offsetY"] != nil { textLabel?.rowOffsetY = CGFloat(style.double("offsetY")) } label.font = font label.textColor = color let attributed = originalText.map { NSMutableAttributedString(attributedString: $0) } @@ -2522,11 +2643,11 @@ final class NativeListCell: UICollectionViewCell { let paragraph = (attributed.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() if style["alignment"] != nil { paragraph.alignment = label.textAlignment } - if style["lines"] != nil { paragraph.lineBreakMode = label.lineBreakMode } + if style["lines"] != nil || style["truncate"] != nil { paragraph.lineBreakMode = label.lineBreakMode } var attributes: [NSAttributedString.Key: Any] = [:] applyStyledFont(attributed, style, fallback: baseFont) if style["color"] != nil { attributes[.foregroundColor] = color } - if style["alignment"] != nil || style["lines"] != nil || style["lineHeight"] != nil { + if style["alignment"] != nil || style["lines"] != nil || style["truncate"] != nil || style["lineHeight"] != nil { attributes[.paragraphStyle] = paragraph } if style["lineHeight"] != nil { @@ -2537,6 +2658,7 @@ final class NativeListCell: UICollectionViewCell { attributes[.baselineOffset] = max(0, (box - font.lineHeight) / 2) } attributed.addAttributes(attributes, range: NSRange(location: 0, length: attributed.length)) + if label.numberOfLines == 1 && (style["lines"] != nil || style["truncate"] != nil) { normalizeSingleLine(attributed) } label.attributedText = attributed } @@ -2544,16 +2666,24 @@ final class NativeListCell: UICollectionViewCell { let text = button.attributedTitle(for: .normal)?.string ?? button.title(for: .normal) ?? "" - guard !text.isEmpty else { return } + guard !text.isEmpty, !style.isEmpty else { return } let originalFont = button.titleLabel?.font let originalLines = button.titleLabel?.numberOfLines let originalText = button.attributedTitle(for: .normal) let originalAlignment = button.contentHorizontalAlignment - styledTextRestorations.append { - button.titleLabel?.font = originalFont - if let originalLines { button.titleLabel?.numberOfLines = originalLines } - button.setAttributedTitle(originalText, for: .normal) - button.contentHorizontalAlignment = originalAlignment + let originalVertical = button.contentVerticalAlignment + let originalBreakMode = button.titleLabel?.lineBreakMode + let originalOffset = (button as? NativeListAccessoryButton)?.rowTextOffsetY ?? 0 + if styledTextViews.insert(ObjectIdentifier(button)).inserted { + styledTextRestorations.append { + button.titleLabel?.font = originalFont + if let originalLines { button.titleLabel?.numberOfLines = originalLines } + button.setAttributedTitle(originalText, for: .normal) + button.contentHorizontalAlignment = originalAlignment + button.contentVerticalAlignment = originalVertical + if let originalBreakMode { button.titleLabel?.lineBreakMode = originalBreakMode } + (button as? NativeListAccessoryButton)?.rowTextOffsetY = originalOffset + } } let baseFont = (originalText?.attribute(.font, at: 0, effectiveRange: nil) as? UIFont) ?? button.titleLabel?.font ?? nativeListFont(ofSize: 14) let size = CGFloat(style.double("fontSize", default: Double(baseFont.pointSize))) @@ -2583,7 +2713,7 @@ final class NativeListCell: UICollectionViewCell { var attributes: [NSAttributedString.Key: Any] = [:] applyStyledFont(attributed, style, fallback: baseFont) if style["color"] != nil { attributes[.foregroundColor] = color } - if style["alignment"] != nil || style["lineHeight"] != nil { + if style["alignment"] != nil || style["lineHeight"] != nil || style["lines"] != nil || style["truncate"] != nil { attributes[.paragraphStyle] = paragraph } if style["lineHeight"] != nil { @@ -2593,8 +2723,18 @@ final class NativeListCell: UICollectionViewCell { attributes[.baselineOffset] = max(0, (box - font.lineHeight) / 2) } if style["lines"] != nil { - button.titleLabel?.numberOfLines = min(2, max(1, style.int("lines", default: 1))) + button.titleLabel?.numberOfLines = min(3, max(1, style.int("lines", default: 1))) + } + if style["lines"] != nil || style["truncate"] != nil { + let mode: NSLineBreakMode = style.string("truncate", default: "tail") == "clip" ? .byClipping : .byTruncatingTail + button.titleLabel?.lineBreakMode = mode + paragraph.lineBreakMode = mode + if button.titleLabel?.numberOfLines == 1 { normalizeSingleLine(attributed) } + } + if let alignment = style["verticalAlignment"] as? String { + button.contentVerticalAlignment = alignment == "top" ? .top : alignment == "bottom" ? .bottom : .center } + if style["offsetY"] != nil { (button as? NativeListAccessoryButton)?.rowTextOffsetY = CGFloat(style.double("offsetY")) } button.titleLabel?.font = font attributed.addAttributes(attributes, range: NSRange(location: 0, length: attributed.length)) button.setAttributedTitle(attributed, for: .normal) @@ -2617,7 +2757,7 @@ final class NativeListCell: UICollectionViewCell { label.textAlignment = data?["alignment"] == nil ? defaultAlignment : marketTextAlignment(data?.string("alignment") ?? "") - label.numberOfLines = min(2, max(1, data?.int("lines", default: 1) ?? 1)) + label.numberOfLines = min(3, max(1, data?.int("lines", default: 1) ?? 1)) setLineHeight(label, text: label.text ?? "", lineHeight: lineHeight) } @@ -2646,7 +2786,7 @@ final class NativeListCell: UICollectionViewCell { ? .leading : data?.string("alignment") == "end" ? .trailing : .center } - button.titleLabel?.numberOfLines = min(2, max(1, data?.int("lines", default: 1) ?? 1)) + button.titleLabel?.numberOfLines = min(3, max(1, data?.int("lines", default: 1) ?? 1)) } private func marketAttributedText( @@ -2941,6 +3081,14 @@ final class NativeListCell: UICollectionViewCell { width.isActive = true height.isActive = true accessorySizeConstraints.append(contentsOf: [width, height]) + if let priceStyle = style?.dictionary("price"), !textLayoutStyle(priceStyle).isEmpty { + styledViewsDirty = true + applyStyledButton(price, textLayoutStyle(priceStyle)) + } + if let changeStyle = style?.dictionary("change"), !textLayoutStyle(changeStyle).isEmpty { + styledViewsDirty = true + applyStyledButton(change, textLayoutStyle(changeStyle)) + } accessibilityLabel = item.data.string("accessibilityLabel", default: [item.data.string("title"), item.data.string("subtitle"), item.data.string("price"), changeData.string("text")].filter { !$0.isEmpty }.joined(separator: ", ")) } diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index 901750c03..05cd54142 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -1685,14 +1685,19 @@ final class NativeListView: UIView { } // OneKey patch: warning height follows the current native font and available width. if item.type == "system", item.data.string("variant") == "warning" { - let textWidth = max(1, collectionView.bounds.width - (config?.contentPaddingHorizontal ?? 0) * 2 - 24) + let style = item.data.dictionary("style") + let textWidth = max(1, collectionView.bounds.width - (config?.contentPaddingHorizontal ?? 0) * 2 - CGFloat(style?.double("horizontalPadding", default: 12) ?? 12) * 2) func textHeight(_ key: String, weight: NativeListFontWeight) -> CGFloat { + let textStyle = style?.dictionary(key) + let lineHeight = CGFloat(textStyle?.double("lineHeight", default: 20) ?? 20) + let fontSize = CGFloat(textStyle?.double("fontSize", default: 14) ?? 14) let paragraph = NSMutableParagraphStyle() - paragraph.minimumLineHeight = 20 - paragraph.maximumLineHeight = 20 - return ceil((item.data.string(key) as NSString).boundingRect(with: CGSize(width: textWidth, height: .greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: nativeListFont(ofSize: 14, weight: weight), .paragraphStyle: paragraph], context: nil).height / 20) * 20 + paragraph.minimumLineHeight = lineHeight + paragraph.maximumLineHeight = lineHeight + let measured = ceil((item.data.string(key) as NSString).boundingRect(with: CGSize(width: textWidth, height: .greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: nativeListFont(ofSize: fontSize, weight: measuredTextWeight(textStyle, fallback: weight)), .paragraphStyle: paragraph], context: nil).height / lineHeight) + return min(CGFloat(textStyle?.int("lines", default: Int.max) ?? Int.max), max(1, measured)) * lineHeight } - return 32 + textHeight("title", weight: .medium) + textHeight("message", weight: .regular) + return CGFloat(style?.double("verticalPadding", default: 14) ?? 14) * 2 + CGFloat(style?.double("lineGap", default: 4) ?? 4) + textHeight("title", weight: .medium) + textHeight("message", weight: .regular) } if item.type == "walletGroup" { if usingCompactReorderHeight, item.key == interactiveReorderCompactKey { return 68 } @@ -1796,27 +1801,38 @@ final class NativeListView: UIView { return max(0, base + modifier + sectionSpacing + tableAdjustment) } + private func measuredTextWeight(_ style: [String: Any]?, fallback: NativeListFontWeight) -> NativeListFontWeight { + switch style?.string("fontWeight") { + case "regular": return .regular + case "medium": return .medium + case "semibold": return .semibold + case "bold": return .bold + default: return fallback + } + } + private func messageHeight(_ item: NativeListItem) -> CGFloat { - let maximumBodyLines = min(3, max(1, item.data.int("bodyLines", default: 3))) - let horizontalInsets = (config?.contentPaddingHorizontal ?? 0) * 2 + 40 - let leadingWidth: CGFloat = item.data.dictionary("leading") == nil ? 0 : 40 + let style = item.data.dictionary("style") + let horizontalInsets = (config?.contentPaddingHorizontal ?? 0) * 2 + CGFloat(style?.double("horizontalPadding", default: 20) ?? 20) * 2 + let leadingWidth: CGFloat = item.data.dictionary("leading") == nil ? 0 : CGFloat(style?.dictionary("image")?.double("width", default: 28) ?? 28) + CGFloat(style?.double("leadingGap", default: 12) ?? 12) let thumbnailWidth: CGFloat = item.data.dictionary("thumbnail") == nil ? 0 : 76 let textWidth = max(1, collectionView.bounds.width - horizontalInsets - leadingWidth - thumbnailWidth) - let bodyBounds = (item.data.string("body") as NSString).boundingRect( - with: CGSize(width: textWidth, height: .greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin, .usesFontLeading], - attributes: [.font: nativeListFont(ofSize: 14)], - context: nil - ) - let titleBounds = (item.data.string("title") as NSString).boundingRect( - with: CGSize(width: textWidth, height: .greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin, .usesFontLeading], - attributes: [.font: nativeListFont(ofSize: 14, weight: .semibold)], - context: nil - ) - let bodyLines = min(maximumBodyLines, max(1, Int(ceil(bodyBounds.height / 20)))) - let titleLines = min(2, max(1, Int(ceil(titleBounds.height / 20)))) - return 32 + CGFloat(titleLines * 20 + bodyLines * 20) + 22 + func textHeight(_ key: String, lines: Int, weight: NativeListFontWeight) -> CGFloat { + let textStyle = style?.dictionary(key) + let lineHeight = CGFloat(textStyle?.double("lineHeight", default: 20) ?? 20) + let size = CGFloat(textStyle?.double("fontSize", default: 14) ?? 14) + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = lineHeight + paragraph.maximumLineHeight = lineHeight + let bounds = (item.data.string(key) as NSString).boundingRect(with: CGSize(width: textWidth, height: .greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: nativeListFont(ofSize: size, weight: measuredTextWeight(textStyle, fallback: weight)), .paragraphStyle: paragraph], context: nil) + let maximumLines = min(3, max(1, textStyle?.int("lines", default: lines) ?? lines)) + return CGFloat(min(maximumLines, max(1, Int(ceil(bounds.height / lineHeight))))) * lineHeight + } + return CGFloat(style?.double("verticalPadding", default: 16) ?? 16) * 2 + + textHeight("title", lines: 2, weight: .semibold) + + textHeight("body", lines: item.data.int("bodyLines", default: 3), weight: .regular) + + CGFloat(style?.dictionary("time")?.double("lineHeight", default: 20) ?? 20) + + CGFloat(style?.double("lineGap", default: 1) ?? 1) * 2 } private func emit(_ block: ((String) -> Void)?, _ value: [String: Any]) { diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index 8079ebbf2..30ca440d5 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -591,6 +591,230 @@ describe('web row style', () => { return body; }; + it('shares container appearance across every template without styling nested text', () => { + const parent: IdentityRow = { + type: 'identity', + key: 'parent', + presentation: 'walletSidebar', + leading: { kind: 'icon', name: 'coin' }, + title: 'Parent', + }; + const allRows: RowModel[] = [ + ...rows, + { + type: 'market', + key: 'all-market', + variant: 'token', + title: 'Market', + leading: { kind: 'icon', name: 'coin' }, + price: '$1', + change: { text: '+1%', tone: 'positive' }, + }, + { + type: 'walletGroup', + key: parent.key, + parent, + children: [{ ...parent, key: 'child' }], + }, + ]; + for (const row of allRows) { + const body = render({ + ...row, + style: { + container: { + backgroundColor: '#123456', + borderWidth: 2, + borderColor: '#abcdef', + cornerRadius: 7, + contentVerticalAlignment: 'bottom', + }, + }, + } as RowModel); + expect(body.style.getPropertyValue('--nl-container-background')).toBe( + '#123456' + ); + expect(body.style.borderRadius).toBe('7px'); + expect(body.style.boxShadow).toContain('inset 0 0 0 2px'); + expect( + body.querySelector('[data-nl-slot="title"]')?.style + .color ?? '' + ).toBe(''); + } + }); + + it('treats rich text as one single-line budget and keeps badges independent', () => { + const body = render({ + type: 'identity', + key: 'rich-lines', + leading: { kind: 'icon', name: 'coin' }, + title: 'Bit\r\ncoin', + titleMatch: [{ start: 0, end: 4 }], + badges: [{ key: 'badge', text: 'Badge' }], + style: { + title: { + lines: 1, + truncate: 'clip', + verticalAlignment: 'bottom', + offsetY: -2, + }, + }, + }); + const title = body.querySelector('[data-nl-slot="title"]')!; + const content = title.querySelector( + '.ok-native-list-text-content' + )!; + expect(title.textContent).toBe('Bit coin'); + expect(title.style.justifyContent).toBe('flex-end'); + expect(content.style.whiteSpace).toBe('nowrap'); + expect(content.style.textOverflow).toBe('clip'); + expect(content.style.transform).toBe('translateY(-2px)'); + expect( + body.querySelector('[data-nl-slot="badge"] .ok-native-list-text-content') + ).toBeNull(); + }); + + it('lets message style override legacy line limits and distinguishes multi-line clipping', () => { + const message = { + type: 'message', + key: 'message-lines', + title: 'Message', + body: 'First\nSecond\nThird\nFourth', + time: 'Now', + bodyLines: 1, + } as const; + const tail = render({ + ...message, + style: { body: { lines: 3 } }, + }).querySelector('[data-nl-slot="body"]')!; + expect(tail.style.getPropertyValue('-webkit-line-clamp')).toBe('3'); + expect(tail.style.whiteSpace).toBe('pre-wrap'); + expect(tail.textContent).toBe(message.body); + const clip = render({ + ...message, + style: { body: { lines: 3, lineHeight: 18, truncate: 'clip' } }, + }).querySelector('[data-nl-slot="body"]')!; + expect(clip.style.getPropertyValue('-webkit-line-clamp')).toBe(''); + expect(clip.style.maxHeight).toBe('54px'); + expect(clip.style.textOverflow).toBe('clip'); + }); + + it('restores legacy container appearance and fixed allocation after styles are removed', () => { + const { document } = new JSDOM('').window; + const host = document.createElement('div'); + document.body.appendChild(host); + const base: IdentityRow = { + type: 'identity', + key: 'container-reset', + leading: { kind: 'icon', name: 'coin' }, + title: 'Text', + height: 80, + opacity: 0.6, + backgroundColor: '#ffffff', + disabled: true, + }; + const engine = new NativeListWebEngine( + host, + snapshot({ kind: 'sectioned' }, [ + { + ...base, + style: { + container: { + backgroundColor: '#123456', + opacity: 0.8, + cornerRadius: 8, + borderWidth: 2, + borderColor: '#654321', + }, + title: { lines: 3, truncate: 'clip' }, + }, + }, + ]), + {}, + false + ); + try { + const item = host.querySelector( + '[data-native-list-row-key="container-reset"]' + )!; + expect(item.style.opacity).toBe('0.4'); + const height = item.style.height; + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [{ ...base, style: {} }]) + ); + expect(item.style.opacity).toBe('0.3'); + expect(item.style.height).toBe(height); + expect( + item.firstElementChild?.getAttribute('data-nl-container-background') + ).toBeNull(); + expect((item.firstElementChild as HTMLElement).style.borderRadius).toBe( + '' + ); + expect( + item.querySelector('[data-nl-slot="title"]')?.style + .maxHeight + ).toBe(''); + } finally { + engine.destroy(); + } + }); + + it('keeps the text layout through fast Market quote updates', () => { + const { document } = new JSDOM('').window; + const host = document.createElement('div'); + document.body.appendChild(host); + const base = { + type: 'market', + key: 'quote-layout', + variant: 'token', + height: 108, + title: 'Market', + leading: { kind: 'icon', name: 'coin' }, + price: '$1', + change: { text: 'Up\n1%', tone: 'positive' }, + style: { + changeHeight: 76, + change: { + lines: 2, + truncate: 'clip', + verticalAlignment: 'bottom', + offsetY: 2, + }, + }, + } as const; + const engine = new NativeListWebEngine( + host, + snapshot({ kind: 'sectioned' }, [base]), + {}, + false + ); + try { + for (let quote = 2; quote <= 5; quote++) { + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [ + { + ...base, + price: `$${quote}`, + change: { ...base.change, text: `Up\n${quote}%` }, + }, + ]) + ); + const change = host.querySelector( + '.ok-native-list-market-change' + )!; + expect(change.textContent).toBe(`Up\n${quote}%`); + expect(change.style.justifyContent).toBe('flex-end'); + expect( + change.querySelectorAll('.ok-native-list-text-content') + ).toHaveLength(1); + const content = change.firstElementChild as HTMLElement; + expect(content.style.maxHeight).toBe('40px'); + expect(content.style.transform).toBe('translateY(2px)'); + } + } finally { + engine.destroy(); + } + }); + it('styles primary and secondary data independently without restyling badges', () => { const body = render({ type: 'dataRow', diff --git a/native-views/react-native-native-list/src/__tests__/validation.test.ts b/native-views/react-native-native-list/src/__tests__/validation.test.ts index 38bb23112..49e32204b 100644 --- a/native-views/react-native-native-list/src/__tests__/validation.test.ts +++ b/native-views/react-native-native-list/src/__tests__/validation.test.ts @@ -1427,3 +1427,75 @@ describe('NativeList style contract', () => { expect((first as MarketRow).style?.changeWidth).toBe(80); }); }); + +describe('row container and text layout contract', () => { + const style = { + container: { + backgroundColor: '#12345678', + opacity: 0.8, + cornerRadius: 9, + borderWidth: 1.5, + borderColor: '#654321', + contentVerticalAlignment: 'top' as const, + }, + title: { + lines: 3 as const, + truncate: 'clip' as const, + alignment: 'end' as const, + verticalAlignment: 'bottom' as const, + offsetY: -2, + }, + }; + + it('round-trips the same nested surface through snapshots and whole-style replacement patches', () => { + const base = { + ...row('text-layout'), + backgroundColor: '#FFFFFF', + opacity: 0.4, + style, + }; + expect( + JSON.parse(serializeSnapshot(snapshot([base]))).rows[0].style + ).toEqual(style); + expect( + validatePatches([{ key: base.key, type: 'identity', changes: { style } }]) + ).toHaveLength(1); + const cleared = applyRowPatches(snapshot([base]), [ + { key: base.key, type: 'identity', changes: { style: {} } }, + ]); + expect(cleared.rows[0]).toMatchObject({ + style: {}, + backgroundColor: '#FFFFFF', + opacity: 0.4, + }); + }); + + it.each([ + ['container', { width: 120 }], + ['container', { opacity: 1.1 }], + ['container', { borderWidth: -1 }], + ['container', { backgroundColor: 'red' }], + ['container', { contentVerticalAlignment: 'stretch' }], + ['title', { lines: 4 }], + ['title', { lines: 1.5 }], + ['title', { truncate: 'middle' }], + ['title', { verticalAlignment: 'baseline' }], + ['title', { offsetY: 9 }], + ])('rejects invalid %s on snapshot and patch paths', (field, value) => { + const invalid = { [field as string]: value }; + expect(() => + validateSnapshot( + snapshot([{ ...row('invalid'), style: invalid } as IdentityRow]) + ) + ).toThrow('style'); + expect(() => + validatePatches([ + { + key: 'invalid', + type: 'identity', + changes: { style: invalid }, + } as RowPatch, + ]) + ).toThrow('style'); + }); +}); diff --git a/native-views/react-native-native-list/src/models.ts b/native-views/react-native-native-list/src/models.ts index 0554439a7..257766894 100644 --- a/native-views/react-native-native-list/src/models.ts +++ b/native-views/react-native-native-list/src/models.ts @@ -57,8 +57,13 @@ export type NativeListTextStyle = Readonly<{ fontWeight?: 'regular' | 'medium' | 'semibold' | 'bold'; color?: string; lineHeight?: number; - lines?: 1 | 2; + /** Maximum visible lines; does not reserve empty lines or enlarge the row. */ + lines?: 1 | 2 | 3; + truncate?: 'tail' | 'clip'; alignment?: 'start' | 'center' | 'end'; + verticalAlignment?: 'top' | 'center' | 'bottom'; + /** Optical translation in logical units; does not affect layout. */ + offsetY?: number; }>; /** Retained alias: Market shipped this name before the style surface was shared. */ @@ -74,8 +79,18 @@ export type NativeListImageStyle = Readonly<{ export type MarketImageStyle = NativeListImageStyle; +export type NativeListRowContainerStyle = Readonly<{ + backgroundColor?: string; + opacity?: number; + cornerRadius?: number; + borderWidth?: number; + borderColor?: string; + contentVerticalAlignment?: 'top' | 'center' | 'bottom'; +}>; + /** Local box vocabulary. Each template exposes only parameters for its own slots. */ export type RowBoxStyle = Readonly<{ + container?: NativeListRowContainerStyle; horizontalPadding?: number; verticalPadding?: number; leadingGap?: number; @@ -170,7 +185,7 @@ export const ROW_BOX_STYLE_KEYS_BY_TYPE = { type TemplateBoxStyle = Pick< RowBoxStyle, - (typeof ROW_BOX_STYLE_KEYS_BY_TYPE)[T][number] + (typeof ROW_BOX_STYLE_KEYS_BY_TYPE)[T][number] | 'container' >; /** diff --git a/native-views/react-native-native-list/src/validation.ts b/native-views/react-native-native-list/src/validation.ts index 821c377e0..78125b077 100644 --- a/native-views/react-native-native-list/src/validation.ts +++ b/native-views/react-native-native-list/src/validation.ts @@ -219,7 +219,10 @@ function assertTextStyle( 'color', 'lineHeight', 'lines', + 'truncate', 'alignment', + 'verticalAlignment', + 'offsetY', ...additionalKeys, ], path, @@ -255,12 +258,86 @@ function assertTextStyle( ) { fail(`${path}.alignment`, 'must be start, center, or end'); } - if (style.lines !== undefined && ![1, 2].includes(style.lines)) { - fail(`${path}.lines`, 'must be 1 or 2'); + if (style.lines !== undefined && ![1, 2, 3].includes(style.lines)) { + fail(`${path}.lines`, 'must be 1, 2, or 3'); } + if ( + style.truncate !== undefined && + !['tail', 'clip'].includes(style.truncate) + ) { + fail(`${path}.truncate`, 'must be tail or clip'); + } + if ( + style.verticalAlignment !== undefined && + !['top', 'center', 'bottom'].includes(style.verticalAlignment) + ) { + fail(`${path}.verticalAlignment`, 'must be top, center, or bottom'); + } + assertBoundedStyleNumber(style.offsetY, `${path}.offsetY`, -8, 8); } function assertBoxStyle(style: RowBoxStyle, path: string): void { + const container = style.container; + if (container !== undefined) { + const containerPath = `${path}.container`; + if ( + typeof container !== 'object' || + container === null || + Array.isArray(container) + ) { + fail(containerPath, 'must be an object'); + } + assertUnknownKeys( + container as Record, + [ + 'backgroundColor', + 'opacity', + 'cornerRadius', + 'borderWidth', + 'borderColor', + 'contentVerticalAlignment', + ], + containerPath, + 'row container style key' + ); + for (const key of ['backgroundColor', 'borderColor'] as const) { + const value = container[key]; + if ( + value !== undefined && + (typeof value !== 'string' || + !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(value)) + ) { + fail(`${containerPath}.${key}`, 'must be #RRGGBB or #RRGGBBAA'); + } + } + assertBoundedStyleNumber( + container.opacity, + `${containerPath}.opacity`, + 0, + 1 + ); + assertBoundedStyleNumber( + container.cornerRadius, + `${containerPath}.cornerRadius`, + 0, + 80 + ); + assertBoundedStyleNumber( + container.borderWidth, + `${containerPath}.borderWidth`, + 0, + 8 + ); + if ( + container.contentVerticalAlignment !== undefined && + !['top', 'center', 'bottom'].includes(container.contentVerticalAlignment) + ) { + fail( + `${containerPath}.contentVerticalAlignment`, + 'must be top, center, or bottom' + ); + } + } for (const field of [ 'horizontalPadding', 'verticalPadding', @@ -357,6 +434,7 @@ function assertRowStyle(row: RowModel, path: string): void { } const textKeys = TEXT_STYLE_KEYS_BY_ROW_TYPE[row.type] ?? []; const allowed = new Set([ + 'container', ...ROW_BOX_STYLE_KEYS_BY_TYPE[row.type], ...textKeys, ...(EXTRA_STYLE_KEYS_BY_ROW_TYPE[row.type] ?? []), diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index 9fc176e33..4813488b6 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -469,21 +469,47 @@ function sizeModifier(row: RowModel): number { function approximateMessageHeight(row: RowModel, availableWidth: number) { if (row.type !== 'message') return 0; - const leadingWidth = row.leading ? 52 : 0; + const style = row.style; + const leadingWidth = row.leading + ? (style?.image?.width ?? 40) + (style?.leadingGap ?? 12) + : 0; const thumbnailWidth = row.thumbnail ? 88 : 0; - const charactersPerLine = Math.max( - 18, - Math.floor((availableWidth - leadingWidth - thumbnailWidth - 40) / 7) - ); - const titleLines = Math.min( - 2, - Math.max(1, Math.ceil(row.title.length / charactersPerLine)) + const textWidth = Math.max( + 1, + availableWidth - + leadingWidth - + thumbnailWidth - + (style?.horizontalPadding ?? 20) * 2 ); - const bodyLines = Math.min( - row.bodyLines ?? 3, - Math.max(1, Math.ceil(row.body.length / charactersPerLine)) + const height = ( + text: string, + textStyle: NativeListTextStyle | undefined, + fallbackLines: number + ) => { + const size = textStyle?.fontSize ?? 14; + const charactersPerLine = Math.max( + textStyle ? 1 : 18, + Math.floor(textWidth / (size / 2)) + ); + const measured = text + .split(/\r\n|[\r\n]/) + .reduce( + (total, line) => + total + Math.max(1, Math.ceil(line.length / charactersPerLine)), + 0 + ); + return ( + Math.min(textStyle?.lines ?? fallbackLines, measured) * + (textStyle?.lineHeight ?? 20) + ); + }; + return ( + (style?.verticalPadding ?? 16) * 2 + + height(row.title, style?.title, 2) + + height(row.body, style?.body, row.bodyLines ?? 3) + + (style?.time?.lineHeight ?? 20) + + (style?.lineGap ?? 1) * 2 ); - return 32 + titleLines * 20 + bodyLines * 20 + 22; } export function estimateWebRowHeight( @@ -495,18 +521,39 @@ export function estimateWebRowHeight( // if (row.type === 'system' && row.variant === 'spacer') return row.height; if (row.height !== undefined) return row.height; if (row.type === 'system' && row.variant === 'warning') { - const width = Math.max(1, availableWidth - 24); - const lines = (text: string) => - Math.max( - 1, - Math.ceil( - Array.from(text).reduce( - (length, char) => length + (char.charCodeAt(0) > 255 ? 14 : 7), - 0 - ) / width - ) + const width = Math.max( + 1, + availableWidth - (row.style?.horizontalPadding ?? 12) * 2 + ); + const height = (text: string, style: NativeListTextStyle | undefined) => { + const size = style?.fontSize ?? 14; + const lines = text + .split(/\r\n|[\r\n]/) + .reduce( + (total, line) => + total + + Math.max( + 1, + Math.ceil( + Array.from(line).reduce( + (length, char) => + length + (char.charCodeAt(0) > 255 ? size : size / 2), + 0 + ) / width + ) + ), + 0 + ); + return ( + Math.min(style?.lines ?? Infinity, lines) * (style?.lineHeight ?? 20) ); - return 32 + 20 * (lines(row.title) + lines(row.message)); + }; + return ( + (row.style?.verticalPadding ?? 14) * 2 + + (row.style?.lineGap ?? 4) + + height(row.title, row.style?.title) + + height(row.message, row.style?.message) + ); } if (row.type === 'walletGroup') // OneKey patch: wallet badges participate in the outer group height. @@ -957,11 +1004,11 @@ export const WEB_LIST_CSS = ` .ok-native-list-viewport-frame{position:relative;flex:1;min-width:0;min-height:0;overflow:hidden} .ok-native-list-viewport{position:absolute;inset:0;overflow:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;scrollbar-gutter:stable} .ok-native-list-content{position:relative;min-width:100%;min-height:100%} -.ok-native-list-item{position:absolute;box-sizing:border-box;contain:layout paint style;outline:none} +.ok-native-list-item{position:absolute;left:0;top:0;box-sizing:border-box;contain:layout paint style;outline:none} .ok-native-list-row{width:100%;height:100%;box-sizing:border-box;display:flex;align-items:center;gap:12px;overflow:hidden;background:var(--nl-row);color:var(--nl-primary);cursor:default;user-select:none;-webkit-user-select:none} .ok-native-list-item[data-table-alternate="true"]>.ok-native-list-row{background:var(--nl-bg)} .ok-native-list-item[data-native-list-selected="true"]>.ok-native-list-row{background:var(--nl-selected)} -.ok-native-list-item[data-native-list-disabled="true"]>.ok-native-list-row{opacity:.5;cursor:default} +.ok-native-list-item[data-native-list-disabled="true"]>.ok-native-list-row{cursor:default} .ok-native-list-item[data-native-list-row-press-enabled="true"]>.ok-native-list-row{cursor:pointer} .ok-native-list-item[data-native-list-row-press-enabled="true"]:hover>.ok-native-list-row{background:var(--nl-pressed)} .ok-native-list-item[data-native-list-row-press-enabled="true"]:active>.ok-native-list-row{background:var(--nl-pressed)} @@ -1044,6 +1091,9 @@ export const WEB_LIST_CSS = ` .ok-native-list-root .ok-native-list-item>.ok-native-list-wallet-row,.ok-native-list-root .ok-native-list-wallet-member>.ok-native-list-wallet-row,.ok-native-list-root .ok-native-list-item>.ok-native-list-account-row,.ok-native-list-root .ok-native-list-item>.ok-native-list-account-action-row,.ok-native-list-wallet-member{cursor:default} /* OneKey patch: Add account hover and pressed backgrounds keep the account row corner radius. */ .ok-native-list-account-action-row{border-radius:12px} + +.ok-native-list-root .ok-native-list-item>[data-nl-container-background="true"],.ok-native-list-root .ok-native-list-wallet-member>[data-nl-container-background="true"]{background:var(--nl-container-background)} +.ok-native-list-root .ok-native-list-item[data-native-list-row-press-enabled="true"]:hover>[data-nl-container-background="true"],.ok-native-list-root .ok-native-list-item[data-native-list-row-press-enabled="true"]:active>[data-nl-container-background="true"],.ok-native-list-root .ok-native-list-wallet-member:hover>[data-nl-container-background="true"],.ok-native-list-root .ok-native-list-wallet-member:active>[data-nl-container-background="true"]{background:var(--nl-pressed)} `; function createElement( @@ -3096,6 +3146,20 @@ function createIdentityActivityOrMessageRow( tertiary: 'tertiary', } ); + if (row.type === 'message') { + const messageTitle = column.querySelector( + '[data-nl-slot="title"]' + ); + const messageBody = column.querySelector( + '[data-nl-slot="body"]' + ); + if (messageTitle) + applyTextLayout(messageTitle, { lines: row.style?.title?.lines ?? 2 }); + if (messageBody) + applyTextLayout(messageBody, { + lines: row.style?.body?.lines ?? row.bodyLines ?? 3, + }); + } // OneKey patch: match existing search, subtitle fragments, and sidebar badges. if (row.type === 'identity') { const titleElement = column.firstElementChild as HTMLElement; @@ -3308,7 +3372,12 @@ function createWalletGroupRow( memberElement.style.flexBasis = String(member.height ?? 68 + (member.badges?.length ? 24 : 0)) + 'px'; memberElement.style.height = memberElement.style.flexBasis; - memberElement.style.opacity = String(member.opacity ?? 1); + memberElement.style.opacity = String( + (member.style?.container?.opacity ?? member.opacity ?? 1) * + (member.disabled ? 0.5 : 1) + ); + if (member.style?.container?.cornerRadius !== undefined) + memberElement.style.borderRadius = `${member.style.container.cornerRadius}px`; body.appendChild(memberElement); }); return body; @@ -3343,13 +3412,13 @@ function applyMarketTextStyle( ); element.style.color = style?.color ?? ''; element.style.textAlign = style?.alignment ?? defaults.alignment; - element.style.whiteSpace = style?.lines === 2 ? 'normal' : 'nowrap'; + element.style.whiteSpace = (style?.lines ?? 1) > 1 ? 'normal' : 'nowrap'; element.style.display = ''; element.style.removeProperty('-webkit-line-clamp'); element.style.removeProperty('-webkit-box-orient'); - if (style?.lines === 2) { + if ((style?.lines ?? 1) > 1) { element.style.display = '-webkit-box'; - element.style.setProperty('-webkit-line-clamp', '2'); + element.style.setProperty('-webkit-line-clamp', String(style?.lines ?? 2)); element.style.setProperty('-webkit-box-orient', 'vertical'); } } @@ -3401,6 +3470,8 @@ function setMarketQuoteContent(element: HTMLElement, row: MarketRow) { change.style.color = row.change.textColor ?? style?.change?.color ?? ''; change.style.background = row.change.backgroundColor ?? ''; } + if (price) applyTextLayout(price, style?.price ?? {}, 1); + if (change) applyTextLayout(change, style?.change ?? {}, 1); element.setAttribute( 'aria-label', row.accessibilityLabel ?? @@ -3631,10 +3702,21 @@ function createMarketRow(context: RenderContext, row: MarketRow): HTMLElement { ); body.appendChild(trailing); setMarketQuoteContent(body, row); + for (const key of ['title', 'subtitle'] as const) { + const text = body.querySelector( + `.ok-native-list-market-${key}` + ); + if (text) applyTextLayout(text, style?.[key] ?? {}, 1); + } + const prefix = body.querySelector( + '.ok-native-list-market-subtitle-prefix' + ); + if (prefix) applyTextLayout(prefix, row.subtitlePrefix?.style ?? {}, 1); return body; } const ROW_BOX_STYLE_KEYS: ReadonlySet = new Set([ + 'container', 'horizontalPadding', 'verticalPadding', 'leadingGap', @@ -3644,6 +3726,169 @@ const ROW_BOX_STYLE_KEYS: ReadonlySet = new Set([ 'image', ]); +/** Text owns a single wrapping budget, including its rich runs. */ +function applyTextLayout( + element: HTMLElement, + style: NativeListTextStyle, + defaultLines?: number +): void { + if ( + style.lines === undefined && + style.truncate === undefined && + style.verticalAlignment === undefined && + style.offsetY === undefined + ) + return; + let content = element.querySelector( + ':scope > .ok-native-list-text-content' + ); + const computed = element.ownerDocument.defaultView?.getComputedStyle(element); + const inheritedClamp = Number( + element.style.getPropertyValue('-webkit-line-clamp') || + computed?.getPropertyValue('-webkit-line-clamp') + ); + const lines = + style.lines ?? + defaultLines ?? + (Number( + element.style.getPropertyValue('-webkit-line-clamp') || + computed?.getPropertyValue('-webkit-line-clamp') + ) || + 1); + if ( + !content && + (style.verticalAlignment !== undefined || style.offsetY !== undefined) + ) { + content = element.ownerDocument.createElement('span'); + content.className = 'ok-native-list-text-content'; + content.append(...Array.from(element.childNodes)); + element.appendChild(content); + element.style.display = 'flex'; + element.style.flexDirection = 'column'; + element.style.removeProperty('-webkit-line-clamp'); + element.style.removeProperty('-webkit-box-orient'); + content.style.minWidth = '0'; + content.style.flex = '0 1 auto'; + content.style.width = '100%'; + } + const text = content ?? element; + if (style.verticalAlignment !== undefined) { + element.style.justifyContent = { + top: 'flex-start', + center: 'center', + bottom: 'flex-end', + }[style.verticalAlignment]; + } + if (style.offsetY !== undefined) + text.style.transform = `translateY(${style.offsetY}px)`; + if ( + style.lines === undefined && + style.truncate === undefined && + defaultLines === undefined && + !inheritedClamp + ) { + if (content) { + content.style.whiteSpace = computed?.whiteSpace || 'inherit'; + content.style.textOverflow = computed?.textOverflow || 'inherit'; + content.style.overflow = 'hidden'; + } + return; + } + if (lines === 1) { + const walker = element.ownerDocument.createTreeWalker( + text, + 4 /* SHOW_TEXT */ + ); + let previousCarriageReturn = false; + while (walker.nextNode()) { + const value = walker.currentNode.nodeValue ?? ''; + if (!value) continue; + // A CRLF pair may straddle two styled runs. + const textValue = + previousCarriageReturn && value.startsWith('\n') + ? value.slice(1) + : value; + walker.currentNode.nodeValue = textValue.replace(/\r\n|[\r\n]/g, ' '); + previousCarriageReturn = value.endsWith('\r'); + } + } + text.style.whiteSpace = lines === 1 ? 'nowrap' : 'pre-wrap'; + text.style.overflow = 'hidden'; + text.style.textOverflow = style.truncate === 'clip' ? 'clip' : 'ellipsis'; + text.style.removeProperty('-webkit-line-clamp'); + text.style.removeProperty('-webkit-box-orient'); + text.style.maxHeight = ''; + if (lines > 1 && style.truncate !== 'clip') { + text.style.display = '-webkit-box'; + text.style.setProperty('-webkit-line-clamp', String(lines)); + text.style.setProperty('-webkit-box-orient', 'vertical'); + } else { + text.style.display = 'block'; + if (lines > 1) { + const lineHeight = + style.lineHeight ?? + (parseFloat(element.style.lineHeight || computed?.lineHeight || '') || + 1.2 * (parseFloat(computed?.fontSize || '') || 14)); + text.style.maxHeight = `${lines * lineHeight}px`; + } + } +} + +function applyRowContainerStyle(body: HTMLElement, row: RowModel): void { + const container = row.style?.container; + if (!container) return; + if (container.backgroundColor !== undefined) { + body.style.setProperty( + '--nl-container-background', + container.backgroundColor + ); + body.setAttribute('data-nl-container-background', 'true'); + // A CSS rule allows existing hover/press feedback to override the resting fill. + body.style.removeProperty('background'); + body.style.removeProperty('background-color'); + } + if (container.cornerRadius !== undefined) + body.style.borderRadius = `${container.cornerRadius}px`; + if (container.borderWidth !== undefined) { + const border = `inset 0 0 0 ${container.borderWidth}px ${ + container.borderColor ?? 'transparent' + }`; + body.style.boxShadow = body.style.boxShadow + ? `${body.style.boxShadow}, ${border}` + : border; + body.style.borderColor = 'transparent'; + } else if (container.borderColor !== undefined) + body.style.borderColor = container.borderColor; + if (container.contentVerticalAlignment !== undefined) { + const alignment = { + top: 'flex-start', + center: 'center', + bottom: 'flex-end', + }[container.contentVerticalAlignment]; + const vertical = + row.type === 'walletGroup' || + row.type === 'mediaTile' || + row.type === 'metricCard' || + (row.type === 'identity' && row.presentation === 'walletSidebar') || + (row.type === 'system' && row.variant === 'warning'); + if (vertical) { + body.style.display = 'flex'; + body.style.flexDirection = 'column'; + if (row.type === 'mediaTile') body.style.gap = '0px'; + body.style.justifyContent = alignment; + Array.from(body.children).forEach((child) => { + (child as HTMLElement).style.flexShrink = '0'; + }); + } else { + body.style.alignItems = alignment; + // Timestamp/accessory defaults may have their own align-self. + Array.from(body.children).forEach((child) => { + (child as HTMLElement).style.alignSelf = alignment; + }); + } + } +} + function applyTextStyleToSlot( element: HTMLElement, style: NativeListTextStyle @@ -3666,17 +3911,7 @@ function applyTextStyleToSlot( run.style.lineHeight = String(style.lineHeight) + 'px'; }); if (style.alignment !== undefined) element.style.textAlign = style.alignment; - if (style.lines !== undefined) { - element.style.removeProperty('-webkit-line-clamp'); - element.style.removeProperty('-webkit-box-orient'); - element.style.display = ''; - element.style.whiteSpace = style.lines === 2 ? 'normal' : 'nowrap'; - if (style.lines === 2) { - element.style.display = '-webkit-box'; - element.style.setProperty('-webkit-line-clamp', '2'); - element.style.setProperty('-webkit-box-orient', 'vertical'); - } - } + applyTextLayout(element, style); } /** @@ -3686,6 +3921,7 @@ function applyTextStyleToSlot( * keeps its own richer path. See docs/STYLE_SPEC.md §4 and §8. */ export function applyRowStyle(body: HTMLElement, row: RowModel): void { + applyRowContainerStyle(body, row); if (row.type === 'market') return; const style = (row as { style?: Record }).style; if (!style) return; @@ -4654,7 +4890,10 @@ export class NativeListWebEngine { setData(element, 'nativeListRowPressEnabled', isRowPressEnabled(row)); setData(element, 'testid', row.testID); // OneKey patch: deprecation dims a row without disabling its actions. - element.style.opacity = String(row.opacity ?? 1); + element.style.opacity = String( + (row.style?.container?.opacity ?? row.opacity ?? 1) * + (row.disabled ? 0.5 : 1) + ); setData(element, 'nativeListReorderable', this.isReorderable(row)); setData( element, @@ -4692,18 +4931,20 @@ export class NativeListWebEngine { // OneKey patch: explicit selector fields preserve original page geometry. element.style.contain = row.backgroundFullWidth ? 'layout style' : ''; if (row.backgroundColor) body.style.backgroundColor = row.backgroundColor; - if (row.backgroundFullWidth && row.backgroundColor) { + const restingBackground = + row.style?.container?.backgroundColor ?? row.backgroundColor; + if (row.backgroundFullWidth && restingBackground) { const bleed = paddingValues(this.snapshot).horizontal; body.style.position = 'relative'; body.style.overflow = 'visible'; body.style.boxShadow = String(-bleed) + 'px 0 ' + - row.backgroundColor + + restingBackground + ',' + String(bleed) + 'px 0 ' + - row.backgroundColor; + restingBackground; } if (row.type === 'identity' && row.height !== undefined) { const title = body.querySelector('.ok-native-list-title'); From 656de36e37521942a83f935e9d974e1a96a8054b Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 19:09:11 +0800 Subject: [PATCH 11/71] fix(native-list): align row styles and support styled heights --- .../pages/NativeListRowStylePage.tsx | 5 +- .../onekey/nativelist/NativeListAdapter.kt | 2 +- .../com/onekey/nativelist/NativeListModels.kt | 3 + .../onekey/nativelist/NativeListRowView.kt | 47 ++- .../com/onekey/nativelist/NativeListView.kt | 2 +- .../react-native-native-list/docs/DESIGN.md | 7 +- .../docs/ROW_TEMPLATES.md | 5 +- .../docs/STYLE_SPEC.md | 77 +++- .../docs/images/row-style-regions.svg | 4 +- .../ios/NativeListCell.swift | 45 +- .../ios/NativeListModels.swift | 4 + .../ios/RNCNativeListView.swift | 13 +- .../src/__tests__/NativeList.web.test.ts | 383 +++++++++++++++++- .../src/__tests__/validation.test.ts | 66 +++ .../react-native-native-list/src/models.ts | 2 + .../src/validation.ts | 24 +- .../src/web/NativeListWebEngine.ts | 122 ++++-- 17 files changed, 715 insertions(+), 96 deletions(-) diff --git a/example/react-native/pages/NativeListRowStylePage.tsx b/example/react-native/pages/NativeListRowStylePage.tsx index bbab88fa7..eac1e955c 100644 --- a/example/react-native/pages/NativeListRowStylePage.tsx +++ b/example/react-native/pages/NativeListRowStylePage.tsx @@ -184,7 +184,7 @@ function buildRows(styled: boolean): RowModel[] { }); // Cover remaining templates with local text/image/gap overrides. Outer row - // allocation stays fixed; callers choose fitting dimensions. + // callers can override allocation with container.height and choose fitting dimensions. const additional: readonly [RowModel, RowModel['style']][] = [ [ { @@ -360,7 +360,7 @@ function buildRows(styled: boolean): RowModel[] { }); rows.push( - header('text-layout', 'Text layout / row container', 'same allocation'), + header('text-layout', 'Text layout / row container', 'height: 180 → 200'), ); for (const alignment of ['top', 'center', 'bottom'] as const) { rows.push({ @@ -378,6 +378,7 @@ function buildRows(styled: boolean): RowModel[] { verticalPadding: 12, lineGap: 6, container: { + height: 200, backgroundColor: '#EDF6FF', opacity: 0.9, cornerRadius: 14, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt index aa80dbbe8..51cfade4f 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt @@ -143,7 +143,7 @@ internal class NativeListAdapter( items.forEachIndexed { index, item -> val style = item.json.optJSONObject("style") val height = item.json.optDouble("height", 0.0) - val sourceHeight = if (item.type == "market" && style != null) { + val sourceHeight = item.styledHeight ?: if (item.type == "market" && style != null) { val titleHeight = ceil((style.optJSONObject("title")?.optDouble("lineHeight", 24.0) ?: 24.0) * density) / density val subtitleHeight = if (item.json.optString("subtitle").isNotEmpty() || item.json.optJSONObject("subtitlePrefix") != null) { ceil((style.optJSONObject("subtitle")?.optDouble("lineHeight", 20.0) ?: 20.0) * density) / density + style.optDouble("lineGap", 0.0) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index 54cf20d0f..e0136a091 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -127,6 +127,9 @@ internal data class NativeListItem( val json: JSONObject, ) { val content: String = json.toString() + val styledHeight: Double? + get() = json.optJSONObject("style")?.optJSONObject("container") + ?.takeIf { it.has("height") }?.optDouble("height") // OneKey patch: only a host-validated stable snapshot can request a lightweight diff payload. var selectionUpdateFromContent: String? = null diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index e1b2cf562..939af018f 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -833,7 +833,7 @@ internal class NativeListRowView( // OneKey patch: Yoga rounds the two absolute edges, not both padding values. val sourcePadding = marketStyle.optDouble("horizontalPadding") * resources.displayMetrics.density val width = MeasureSpec.getSize(widthMeasureSpec) - val rowHeight = selectorHeight + val rowHeight = marketItem.styledHeight?.let(::styleDp) ?: selectorHeight val sourceVerticalPadding = marketStyle.takeIf { it.has("verticalPadding") && rowHeight != null } ?.optDouble("verticalPadding")?.times(resources.displayMetrics.density) setPadding( @@ -857,7 +857,11 @@ internal class NativeListRowView( leadingFrame.layoutParams = params } // OneKey patch: RecyclerView must use the adapter's measured selector height. - super.onMeasure(widthMeasureSpec, selectorHeight?.let { MeasureSpec.makeMeasureSpec(it, MeasureSpec.EXACTLY) } ?: heightMeasureSpec) + val heightItem = tag as? NativeListItem + val styledHeight = heightItem?.let(::styledHeightPixels)?.takeUnless { + heightItem.type == "walletGroup" && (reorderActive || walletGroupExpandAnimator?.isRunning == true) + } + super.onMeasure(widthMeasureSpec, (styledHeight ?: selectorHeight)?.let { MeasureSpec.makeMeasureSpec(it, MeasureSpec.EXACTLY) } ?: heightMeasureSpec) val item = tag as? NativeListItem ?: return if (item.json.has("height") && item.json.optString("presentation") == "networkSelector" && item.type in setOf("identity", "sectionHeader") && checkbox.visibility == VISIBLE && trailingColumn.parent === this) { // OneKey patch: Yoga snaps the compound accessory before its children; their rounded edges can overflow it. @@ -875,15 +879,16 @@ internal class NativeListRowView( override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { super.onLayout(changed, left, top, right, bottom) val item = tag as? NativeListItem ?: return + val hasContainerAlignment = item.json.optJSONObject("style")?.optJSONObject("container")?.has("contentVerticalAlignment") == true if (item.type == "market" && item.json.optJSONObject("style") != null) { // OneKey patch: preserve Yoga's half-pixel centering for Market columns and badges. for (column in listOf(leadingFrame, mainColumn, trailingColumn)) { - if (column.parent === this && column.visibility != GONE) { + if (!hasContainerAlignment && column.parent === this && column.visibility != GONE) { column.offsetTopAndBottom((height - column.height + 1) / 2 - column.top) } } // OneKey patch: token avatars stay centered in the row when text rounding adds a pixel. - if (item.json.optString("variant") != "token" && leadingFrame.parent === this && mainColumn.parent === this) { + if (!hasContainerAlignment && item.json.optString("variant") != "token" && leadingFrame.parent === this && mainColumn.parent === this) { leadingFrame.offsetTopAndBottom(mainColumn.top + (mainColumn.height - leadingFrame.height + 1) / 2 - leadingFrame.top) } // OneKey patch: preserve Yoga's absolute-edge rounding for the Market network badge. @@ -933,7 +938,7 @@ internal class NativeListRowView( } } val accessory = item.json.optJSONArray("trailing")?.optJSONObject(0) - if (item.type == "identity" && item.json.has("height") && item.json.optString("presentation") == "accountSelector" && accessory?.optString("kind") == "icon" && accessory.optString("name") == "PlusSmallOutline") { + if (!hasContainerAlignment && item.type == "identity" && item.json.has("height") && item.json.optString("presentation") == "accountSelector" && accessory?.optString("kind") == "icon" && accessory.optString("name") == "PlusSmallOutline") { // OneKey patch: the borderless Plus retains the source's fixed top18/negative7 slot. val icon = trailingIcons[0] trailingColumn.offsetTopAndBottom(dp(18) - dp(7) - trailingColumn.top - icon.top) @@ -943,7 +948,7 @@ internal class NativeListRowView( leadingIcon.offsetLeftAndRight((leadingFrame.width - leadingIcon.width + 1) / 2 - leadingIcon.left) leadingIcon.offsetTopAndBottom((leadingFrame.height - leadingIcon.height + 1) / 2 - leadingIcon.top) } - if (!item.json.has("height")) return + if (!item.json.has("height") || hasContainerAlignment) return val isNetworkIdentity = item.type == "identity" && item.json.optString("presentation") == "networkSelector" val isAccountAction = item.type == "action" && item.json.optString("presentation") == "accountSelector" val isNetworkSummary = item.type == "sectionHeader" && item.json.optString("presentation") == "networkSelector" && item.json.optString("variant") == "summary" @@ -1123,6 +1128,16 @@ internal class NativeListRowView( } restingRowBackground = decorate(restingRowBackground, true) pressedRowBackground = decorate(pressedRowBackground, false) + if (style.has("borderWidth")) { + val originalForeground = foreground + styledBoxRestorations.add { foreground = originalForeground } + foreground = (restingRowBackground?.constantState?.newDrawable()?.mutate() as? GradientDrawable)?.apply { + setColor(Color.TRANSPARENT) + } + // Background strokes are covered by opaque wallet members. Paint once above them. + (restingRowBackground as? GradientDrawable)?.setStroke(0, Color.TRANSPARENT) + (pressedRowBackground as? GradientDrawable)?.setStroke(0, Color.TRANSPARENT) + } background = if (touchPressed || reorderActive) pressedRowBackground else restingRowBackground val oldClip = clipToOutline val oldGravity = gravity @@ -1253,6 +1268,11 @@ internal class NativeListRowView( private fun styleDp(value: Double): Int = (value * resources.displayMetrics.density).roundToInt() + private fun styledHeightPixels(item: NativeListItem): Int? = item.styledHeight?.let { height -> + val pixels = height * resources.displayMetrics.density + if (item.json.optString("heightRounding") == "floor") pixels.toInt() else pixels.roundToInt() + } + private fun styleMargins(view: View, start: Int? = null, end: Int? = null, top: Int? = null, bottom: Int? = null) { val params = view.layoutParams as? MarginLayoutParams ?: return val saved = intArrayOf(params.marginStart, params.marginEnd, params.topMargin, params.bottomMargin) @@ -1334,8 +1354,9 @@ internal class NativeListRowView( if (style.has("lineHeight")) { val text = SpannableStringBuilder(view.text) text.getSpans(0, text.length, SelectorLineHeightSpan::class.java).forEach(text::removeSpan) + text.setSpan(SelectorLineHeightSpan(styleDp(style.optDouble("lineHeight"))), 0, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + view.setLineSpacing(0f, 1f) view.text = text - TextViewCompat.setLineHeight(view, styleDp(style.optDouble("lineHeight"))) } if (style.has("lines")) { view.maxLines = style.optInt("lines").coerceIn(1, 3) @@ -1951,7 +1972,10 @@ internal class NativeListRowView( walletGroupExpandedHeightPx = // OneKey patch: expanded groups include individual wallet badge heights. // dp(members.size * 68 + walletGroupDragChildCount * 12) - dp(members.sumOf { it.optInt("height", if ((it.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68) } + childCount * 12 + if (members.first().has("height")) 2 else 0) + styledHeightPixels(item) ?: (members.sumOf { member -> + styledHeightPixels(NativeListItem.parse(member)) + ?: dp(member.optInt("height", if ((member.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68)) + } + dp(childCount * 12 + if (members.first().has("height")) 2 else 0)) walletGroupDragBadgeBackgroundPaint.color = color( theme, "inverseBackground", @@ -1999,7 +2023,7 @@ internal class NativeListRowView( ) // OneKey patch: member geometry matches the outer group height calculation. // memberRow.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp(68)).apply { - memberRow.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp(memberJson.optInt("height", if ((memberJson.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68))).apply { + memberRow.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, styledHeightPixels(member) ?: dp(memberJson.optInt("height", if ((memberJson.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68))).apply { if (index > 0) topMargin = dp(12) } addView(memberRow) @@ -2449,13 +2473,14 @@ internal class NativeListRowView( marketOriginalPaintFlags.putIfAbsent(view, view.paintFlags) view.paintFlags = view.paintFlags or Paint.SUBPIXEL_TEXT_FLAG or Paint.LINEAR_TEXT_FLAG if (style.has("fontSize")) { - val sourceSize = sp(style.optDouble("fontSize").toFloat()) * resources.displayMetrics.density + val sourceSize = style.optDouble("fontSize").toFloat() * resources.displayMetrics.density view.setTextSize(TypedValue.COMPLEX_UNIT_PX, kotlin.math.ceil(sourceSize.toDouble()).toFloat()) } val text = SpannableStringBuilder(view.text) + if (style.has("fontSize")) text.getSpans(0, text.length, AbsoluteSizeSpan::class.java).forEach(text::removeSpan) text.getSpans(0, text.length, SelectorLineHeightSpan::class.java).forEach(text::removeSpan) if (style.has("lineHeight")) { - val lineHeight = kotlin.math.ceil(style.optDouble("lineHeight") * (if (selectorUsesSourceScale) 1f else NativeListScale.factor(resources)) * resources.displayMetrics.density).toInt() + val lineHeight = styleDp(style.optDouble("lineHeight")) text.setSpan(SelectorLineHeightSpan(lineHeight), 0, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) view.setLineSpacing(0f, 1f) } diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt index d388e2e75..9818e4d38 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt @@ -2696,7 +2696,7 @@ private class ItemSpacingDecoration( val sourceWallet = item?.type == "identity" && item.json.optString("presentation") == "walletSidebar" && item.json.has("height") // OneKey patch: V1 measures the wallet and its bottom padding as one sortable row. val itemSpacing = if (!horizontal && sourceWallet) { - val sourceHeight = item!!.json.optInt("height") + val sourceHeight = item!!.styledHeight ?: item.json.optDouble("height") ((sourceHeight + sourceSpacing) * density).roundToInt() - (sourceHeight * density).roundToInt() } else spacing if (horizontal) outRect.right = itemSpacing else outRect.bottom = itemSpacing diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index 864680954..e4901a5c1 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -133,8 +133,11 @@ and `reorder`. ## Height, layout, and performance constraints -Template sizes are fixed to a small set of variants. `message` body text is -bounded to three lines and uses a deterministic template height. System spacers +`style.container.height` defines an explicit row height before legacy `row.height`. +Without either, templates use their existing preset or measured sizes. Styling +can change measured sizes; explicit heights remain exact, and clearing the style +restores the model/template height. `message` body text is bounded to three lines +and uses a deterministic measurement. System spacers are explicit and bounded. Images have explicit sizes, and grid columns are fixed per snapshot. This keeps layout work predictable for 1,000- and 5,000-row workloads without an unbounded self-sizing pass. diff --git a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md index b02b43c4b..fa2e0a116 100644 --- a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md +++ b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md @@ -15,7 +15,10 @@ an optional field does not mean every variant displays it. All rows have a stable `key` and discriminant `type`. Shared `RowBase` metadata includes `height`, `size`, `density`, `sectionKey`, `groupId`, `groupPosition`, selection/disabled state and separator intent. These are model/layout inputs, -not a freeform style object. Declaring `sectionKey` never makes a business +not a freeform style object. Every template also accepts `style.container.height` +to override `row.height`; clearing it restores the original height/measurement. +The style changes the row allocation without changing its internal slot order. +Declaring `sectionKey` never makes a business template the owner of sections or the indexed bar. | Type | Purpose | Fixed structure | diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index f55ae3952..fff6296a0 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -44,7 +44,7 @@ belong to the list container, independently of the content template. | List container | Viewport, header/footer placement, sections, scroll position, refresh/load more, indexed bar and list chrome | The business meaning of a content row | | Layout | Orientation, row allocation, grid spans/table columns, content padding and item spacing | Typography of a particular row field | | Row template + variant | Slot order, hierarchy, required/optional fields, compression and bounded line counts | Whether the list has a header, footer, section index or scroll API | -| `row.style` | Allowlisted typography, colors and bounded local metrics | Arbitrary children, flex direction, positioning, reparenting or list-level geometry | +| `row.style` | Allowlisted typography, colors, local metrics and explicit container height | Arbitrary children, flex direction, positioning, reparenting or list-level geometry | **Normative requirement:** replacing an `identity` row with a `market` or `dataRow` in the same valid container must not remove that container's common @@ -75,9 +75,11 @@ by styling them. See the complete applicability matrix in §4.1. The 12 templates each have a definition and schematic in ROW_TEMPLATES.md. Overflow fitting remains the integrating developer's responsibility: choose -fitting text/image/padding values and row heights. Automatic overflow fitting, combination-fit rejection and converting fixed rows -to auto-height are outside this contract. Existing measured templates account -for explicit text metrics in their existing height calculation. +fitting text/image/padding values and row heights. `style.container.height` can +define a new row height; omission preserves the model/template measurement rules. +Existing measured templates account for explicit text/image/padding metrics. +Automatic overflow fitting, combination-fit rejection and automatic font shrinking +remain outside this contract. The five source PRs are closed; implementation continues in the consolidated PR. ## 2. How this spec is enforced @@ -110,7 +112,7 @@ object identity. | `dataRow` column styles | Independent primary/secondary text on all platforms, in linear and table layouts | | Sticky header styles | Android uses the same row view/binder as normal headers; iOS and Web also reuse their row renderers | | `listStyle.separator` / `groupCornerRadius` | Initial/update propagation and actual-group scope aligned | -| Row heights and overflow | Caller owns fixed allocation and fitting; existing measured message/warning paths account for text metrics | +| Row heights and overflow | `style.container.height` overrides `row.height`; omission retains template measurement, including existing style-dependent sizing; caller owns content fitting | | Legacy baseline dimensions | Inventory in §4/§6; explicit style values use logical units independently of Android's legacy list scale | | Rendered acceptance | The source contract and build checks do not establish full native interaction/pixel acceptance (§9) | @@ -228,7 +230,10 @@ Every supported image role accepts `width`, `height`, `shape`, `cornerRadius`, Fit is `cover | contain | fill | center`. These parameters target the primary visual, not network badges, corner decorations, message thumbnails or nested metric images. A media tile keeps its square default when only width is set; -an explicit height overrides that aspect. Width/height do not change the outer row allocation. +an explicit image height overrides that aspect. Image/text/padding metrics can +participate in an existing intrinsic/measured template, such as Market or message. +They do not override an explicit row height. Use `style.container.height` to +define a new outer height for any template. In the matrix, **padding** means both `horizontalPadding` and `verticalPadding`. Each cell describes the same public contract on **Web + iOS + Android**. @@ -281,6 +286,7 @@ container including descendants. | Property | Values / default when supplied | Meaning | | --- | --- | --- | +| `height` | `0…4096` logical units | Explicit row allocation; takes precedence over `row.height` and template measurement | | `backgroundColor` | `#RRGGBB` / `#RRGGBBAA` | Resting row background | | `opacity` | `0…1` | Opacity of the whole row, multiplied by disabled dimming | | `cornerRadius` | `0…80` logical units | All four outer row corners | @@ -298,16 +304,17 @@ change `listStyle.groupCornerRadius`. `backgroundFullWidth` remains a layout/mod option and uses the resolved resting background. A border never changes padding. `horizontalPadding` / `verticalPadding` remain on `row.style`; there is no second -padding entry under `container`. Outer margin, width, height, positioning, -visibility, flex direction, column weights and hit-area changes are not container -style properties. Row allocation remains with the existing model/layout API. +padding entry under `container`. Outer margin, width, positioning, visibility, +flex direction, column weights and independent hit-area changes are not container +style properties. `height` changes the row frame and its normal row hit area; it +does not rearrange slots or resize their contents. Shadows, gradients and per-corner/per-edge styling are outside this version. ```ts -// Inside an identity row; the caller supplies a fitting row.height separately. +// Inside an identity row; the style itself can allocate a new row height. style: { container: { - backgroundColor: '#EDF6FF', cornerRadius: 12, + height: 112, backgroundColor: '#EDF6FF', cornerRadius: 12, borderWidth: 1, borderColor: '#8DB7E4', contentVerticalAlignment: 'center', }, horizontalPadding: 16, verticalPadding: 12, leadingGap: 10, lineGap: 4, @@ -342,8 +349,17 @@ absent. `style.title.lines` overrides legacy `titleLines`, style restores the model/default value. `patch.changes.style` replaces the whole style object; `{}` clears it, including container and text overrides. -Fixed row heights are never expanded automatically. Integrators choose a fitting -height; this is not automatic overflow prevention. Existing intrinsic/measured +Height precedence is `style.container.height` → `row.height` → existing +template/layout measurement. A style height is an exact logical height, not a +minimum. It applies to every template, normal rows, grid/horizontal layouts, +wallet members, sticky headers and fixed footers. Clearing the whole style or +omitting `container.height` restores the original model height or measurement; +the original `row.height` is never rewritten. Wallet-group compact reorder keeps +its temporary drag height and restores the styled height afterward. Android +uses unscaled logical dp, with `heightRounding` when supplied (nearest otherwise). + +An explicit height is not automatically expanded. With neither explicit height, +styles may change an existing measured/intrinsic row height. Existing intrinsic/measured paths must account for explicit text metrics within their declared measurement model. Font shaping and exact wrap points can vary across platform fonts; the property meanings and maximum line counts must agree. @@ -699,9 +715,29 @@ exposed — a hairline is correct on iOS and a whole pixel is correct elsewhere. | Android sticky header uses a separate partial renderer | Normal row view renders pinned text, values and controls | Pin/push-off, checkbox/action taps, scrolling and accessibility | | List chrome propagation and group scope | Initial/update paths receive listStyle; custom radius only applies to actual groups | Initial and chrome-only snapshot interactions | | Parent style cascades to wallet members | Group exposes container appearance and padding; members bind their own styles | Independent group/member styling during reorder | -| Row container surface | Shared `style.container`, explicit-over-legacy precedence, disabled opacity, border/radius and content alignment | Press/selection/reorder and nested members on device | +| Row container surface | Shared `style.container`, explicit height and appearance precedence, disabled opacity, border/radius and content alignment | Press/selection/reorder and nested members on device | | Text line controls | Shared 1–3 lines, tail/clip, vertical alignment and optical offset; measured text paths updated | Long/CJK/RTL/rich text and fixed-height clipping on device | +### 6.6 Follow-up source audit (2026-09-22) + +| Difference found | Repair | +| --- | --- | +| Web clip height guessed before CSS was mounted; iOS clip disabled natural wrapping | CSS line-height-relative clipping and iOS wrapping without ellipsis | +| Web ignored legacy identity line limits and Market badge metrics | Apply declared fields with style precedence and restore defaults on clear | +| Market rich subscripts retained their old size despite explicit font size | Explicit field size overrides every run on all three platforms | +| Web Market center fit used an invalid CSS value; change color used reversed precedence | Map center to object-fit none; explicit style color wins | +| Android line height excluded first/last text lines | Apply an exact line-height span including first/last font metrics | +| Legacy native centering overrode container alignment | Apply legacy positioning only when container alignment is omitted | +| Media leading gap changed when container alignment was supplied | Keep image-to-metadata spacing independent from vertical alignment | +| Opaque group members covered Web/Android explicit borders | Draw the border above member content without changing padding or hit targets | +| Web fixed footer was laid out beside the viewport | Stack viewport and footer vertically; styled footer height participates in allocation | +| Message timestamp line limits were absent from measured height | Include the timestamp text style in the existing message measurement | +| Runtime Market badge validation accepted text properties absent from its public type | Restrict badge style to its five declared metrics; it is not a row text role | + +Market badge metrics remain `fontSize`, `fontWeight`, `lineHeight`, `height` and +`horizontalPadding`; badge colors stay on its existing model fields. This does +not extend the row-style vocabulary to arbitrary nested parts. + Common headers/footers already exist through structural rows, `fixedFooter` and pager composition (§5.1). This work does not add an arbitrary React header slot. Layout fitting is caller-owned, not an unresolved component feature. Legacy @@ -740,15 +776,16 @@ are explicitly out of scope. The following rules distinguish those responsibilit change slot order, orientation, column count/weight, structural spans, section membership, control placement or parent constraints. Those belong to the template, its explicit variant, or the container layout. -2. **Keep fixed row allocation authoritative.** An explicit `row.height` and - fixed template allocation do not expand to fit style changes. If more space is - needed, the caller supplies a fitting height and verifies container geometry. - Existing measured message/warning paths include explicit text metrics in their - calculation; they do not turn other templates into auto-height rows. +2. **Resolve row height consistently.** `style.container.height` defines the row + allocation before legacy `row.height`; without either, retain the template + measurement rules, including existing style-dependent text/image/padding + sizing. A new template documents whether its default height is a preset or + measured. Integrators can set a fitting style height without changing the + row model. Do not add automatic content fitting or font shrinking. 3. **Integrator: fit content within that frame.** After local padding, each visual/control and text line box must fit. For a simple two-line identity row, a necessary check is `2 * verticalPadding + max(visualHeight, titleLineHeight + lineGap + - subtitleLineHeight, trailingHeight) <= row.height`. More lines and template + subtitleLineHeight, trailingHeight) <= resolvedRowHeight`. More lines and template subrows add their own budget. This formula is an example, not a general row measurement algorithm; it does not cover platform font scaling or rounding. 4. **Integrator: verify compression and interaction.** Text may truncate/wrap only inside diff --git a/native-views/react-native-native-list/docs/images/row-style-regions.svg b/native-views/react-native-native-list/docs/images/row-style-regions.svg index 0fa4bd6bb..7ce2b2588 100644 --- a/native-views/react-native-native-list/docs/images/row-style-regions.svg +++ b/native-views/react-native-native-list/docs/images/row-style-regions.svg @@ -2,7 +2,7 @@ Row style: shared surface and semantic regions -Template owns order and grouping. The caller owns the allocated row height. +Template owns order and grouping. container.height can define the row height. @@ -23,7 +23,7 @@ trailingGap -container: background / opacity / cornerRadius / borderWidth / borderColor +container: height / background / opacity / cornerRadius / borderWidth / borderColor Dashed area = content after horizontalPadding / verticalPadding. contentVerticalAlignment: top / center / bottom in the padded row. Text alignment stays inside its region; offsetY shifts only glyphs, without changing spacing. diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index 4fcfd6ddf..7e4382f8f 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -900,7 +900,8 @@ final class NativeListCell: UICollectionViewCell { let origin = titleLabel.convert(titleLabel.bounds, to: contentView).origin let x = floor((contentView.bounds.width - titleLabel.bounds.width) / 2 * scale) / scale let contentHeight: CGFloat = item.data.string("variant") == "retry" ? 56 : 24 - let y = floor(max(32, (contentView.bounds.height - contentHeight) / 2) * scale) / scale + let y = item.data.dictionary("style")?.dictionary("container")?["contentVerticalAlignment"] != nil + ? origin.y : floor(max(32, (contentView.bounds.height - contentHeight) / 2) * scale) / scale titleLabel.transform = CGAffineTransform(translationX: x - origin.x, y: y - origin.y) } // OneKey patch: extend only the background across the section list outer inset. @@ -917,6 +918,7 @@ final class NativeListCell: UICollectionViewCell { (view.layer.mask as? CAShapeLayer)?.path = UIBezierPath(ovalIn: view.bounds).cgPath } if currentItem?.type == "identity", currentItem?.data["height"] != nil, + currentItem?.data.dictionary("style")?.dictionary("container")?["contentVerticalAlignment"] == nil, currentItem?.data.string("presentation") == "accountSelector", let accessory = currentItem?.data.dictionaries("trailing").first, accessory.string("kind") == "icon", accessory.string("name") == "PlusSmallOutline" { @@ -1602,7 +1604,7 @@ final class NativeListCell: UICollectionViewCell { let memberCell = walletGroupCells[index] // OneKey patch: badges add a second line within their logical wallet group. memberCell.constraints.filter { $0.firstAttribute == .height && $0.secondItem == nil }.forEach { $0.isActive = false } - memberCell.heightAnchor.constraint(equalToConstant: CGFloat(member.data.double("height", default: member.data.dictionaries("badges").isEmpty ? 68 : 92))).isActive = true + memberCell.heightAnchor.constraint(equalToConstant: member.styledHeight ?? CGFloat(member.data.double("height", default: member.data.dictionaries("badges").isEmpty ? 68 : 92))).isActive = true memberCell.onAction = { [weak self] source, action, target, origin in self?.onAction?(source, action, target, origin) } @@ -1782,12 +1784,9 @@ final class NativeListCell: UICollectionViewCell { if alignment == "top" { position = rootStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: top) } else if alignment == "bottom" { position = rootStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -bottom) } else { position = rootStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor, constant: (top - bottom) / 2) } - let bounds = [position, - rootStack.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: top), - rootStack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -bottom)] - NSLayoutConstraint.activate(bounds) + position.isActive = true styledTextRestorations.append { - NSLayoutConstraint.deactivate(bounds) + position.isActive = false topConstraint.isActive = true bottomConstraint.isActive = true } @@ -2430,7 +2429,12 @@ final class NativeListCell: UICollectionViewCell { styledViewsDirty = true defer { applyContainerAppearance() - if let container = style.dictionary("container") { applyContainerAlignment(container) } + if var container = style.dictionary("container") { + if item.styledHeight != nil, rootStack.axis == .vertical, container["contentVerticalAlignment"] == nil { + container["contentVerticalAlignment"] = item.type == "identity" && item.data.string("presentation") == "walletSidebar" ? "center" : "top" + } + applyContainerAlignment(container) + } } if item.type == "market" { applyStyledText(titleLabel, textLayoutStyle(style.dictionary("title"))) @@ -2461,6 +2465,12 @@ final class NativeListCell: UICollectionViewCell { if style["lineGap"] != nil, item.type != "walletGroup", item.type != "dataRow" { let gap = CGFloat(style.double("lineGap")) styleGap(mainStack, gap) + if item.type == "message" { + let label = statusLabel + let inset = label.topInset + styledTextRestorations.append { label.topInset = inset } + label.topInset = 0 + } if item.type == "metricCard", ["activity", "performance"].contains(item.data.string("variant")) { styleGap(metricCompositeStack, gap) if item.data.string("variant") == "performance", let summary = metricCompositeStack.arrangedSubviews.first as? UIStackView { styleGap(summary, gap) } @@ -2586,6 +2596,12 @@ final class NativeListCell: UICollectionViewCell { } } + private func styledLineBreakMode(_ style: [String: Any], lines: Int) -> NSLineBreakMode { + if style.string("truncate", default: "tail") != "clip" { return .byTruncatingTail } + // Clipping disables wrapping; multi-line clip must wrap without adding an ellipsis. + return lines == 1 ? .byClipping : .byWordWrapping + } + private func applyStyledText(_ label: UILabel, _ style: [String: Any]) { let text = label.attributedText?.string ?? label.text ?? "" guard !text.isEmpty, !style.isEmpty else { return } @@ -2632,7 +2648,7 @@ final class NativeListCell: UICollectionViewCell { label.textAlignment = marketTextAlignment(alignmentName) } if style["truncate"] != nil || style["lines"] != nil { - label.lineBreakMode = style.string("truncate", default: "tail") == "clip" ? .byClipping : .byTruncatingTail + label.lineBreakMode = styledLineBreakMode(style, lines: label.numberOfLines) } if let alignment = style["verticalAlignment"] as? String { textLabel?.rowVerticalAlignment = alignment } if style["offsetY"] != nil { textLabel?.rowOffsetY = CGFloat(style.double("offsetY")) } @@ -2674,6 +2690,9 @@ final class NativeListCell: UICollectionViewCell { let originalVertical = button.contentVerticalAlignment let originalBreakMode = button.titleLabel?.lineBreakMode let originalOffset = (button as? NativeListAccessoryButton)?.rowTextOffsetY ?? 0 + let accessory = button as? NativeListAccessoryButton + let originalMarketLineHeight = accessory?.marketLineHeight + let originalSummaryLineHeight = accessory?.selectorSummaryLineHeight if styledTextViews.insert(ObjectIdentifier(button)).inserted { styledTextRestorations.append { button.titleLabel?.font = originalFont @@ -2683,6 +2702,8 @@ final class NativeListCell: UICollectionViewCell { button.contentVerticalAlignment = originalVertical if let originalBreakMode { button.titleLabel?.lineBreakMode = originalBreakMode } (button as? NativeListAccessoryButton)?.rowTextOffsetY = originalOffset + accessory?.marketLineHeight = originalMarketLineHeight + accessory?.selectorSummaryLineHeight = originalSummaryLineHeight } } let baseFont = (originalText?.attribute(.font, at: 0, effectiveRange: nil) as? UIFont) ?? button.titleLabel?.font ?? nativeListFont(ofSize: 14) @@ -2721,12 +2742,14 @@ final class NativeListCell: UICollectionViewCell { paragraph.minimumLineHeight = box paragraph.maximumLineHeight = box attributes[.baselineOffset] = max(0, (box - font.lineHeight) / 2) + if accessory?.marketLineHeight != nil { accessory?.marketLineHeight = box } + if accessory?.selectorSummaryLineHeight != nil { accessory?.selectorSummaryLineHeight = box } } if style["lines"] != nil { button.titleLabel?.numberOfLines = min(3, max(1, style.int("lines", default: 1))) } if style["lines"] != nil || style["truncate"] != nil { - let mode: NSLineBreakMode = style.string("truncate", default: "tail") == "clip" ? .byClipping : .byTruncatingTail + let mode = styledLineBreakMode(style, lines: button.titleLabel?.numberOfLines ?? 1) button.titleLabel?.lineBreakMode = mode paragraph.lineBreakMode = mode if button.titleLabel?.numberOfLines == 1 { normalizeSingleLine(attributed) } @@ -2812,7 +2835,7 @@ final class NativeListCell: UICollectionViewCell { let result = NSMutableAttributedString(string: "") let source = segments.isEmpty ? [["text": text]] : segments for segment in source { - let segmentSize = segment.string("style") == "subscript" ? ceil(size * 0.6) : size + let segmentSize = style?["fontSize"] == nil && segment.string("style") == "subscript" ? ceil(size * 0.6) : size result.append(NSAttributedString(string: segment.string("text"), attributes: [ .font: nativeListTabularFont(ofSize: segmentSize, weight: weight), .foregroundColor: resolvedColor, diff --git a/native-views/react-native-native-list/ios/NativeListModels.swift b/native-views/react-native-native-list/ios/NativeListModels.swift index 9b37268eb..b120f309a 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -9,6 +9,10 @@ struct NativeListItem { let data: [String: Any] let content: String + var styledHeight: CGFloat? { + (data.dictionary("style")?.dictionary("container")?["height"] as? Double).map { CGFloat($0) } + } + var isSelectable: Bool { let disabled = data["disabled"] as? Bool ?? false return !disabled && Self.selectableTypes.contains(type) diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index 05cd54142..c49b462ef 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -1679,7 +1679,9 @@ final class NativeListView: UIView { usingCompactReorderHeight: Bool = true ) -> CGFloat { // OneKey patch: honor selector baseline geometry; keep compact drag sizing. - if item.type != "walletGroup", item.data["height"] != nil { return CGFloat(item.data.double("height")) } + if item.type == "walletGroup", usingCompactReorderHeight, item.key == interactiveReorderCompactKey { return 68 } + if let height = item.styledHeight { return height } + if item.data["height"] != nil { return CGFloat(item.data.double("height")) } if item.type == "system", item.data.string("variant") == "spacer" { return CGFloat(item.data.int("height")) } @@ -1706,7 +1708,7 @@ final class NativeListView: UIView { // return CGFloat((childCount + 1) * 68 + childCount * 12) let members = [item.data.dictionary("parent")].compactMap { $0 } + item.data.dictionaries("children") return members.reduce(CGFloat(childCount * 12 + (members.first?["height"] != nil ? 2 : 0))) { total, data in - total + CGFloat(data.double("height", default: data.dictionaries("badges").isEmpty ? 68 : 92)) + total + CGFloat(data.dictionary("style")?.dictionary("container")?.double("height", default: data.double("height", default: data.dictionaries("badges").isEmpty ? 68 : 92)) ?? data.double("height", default: data.dictionaries("badges").isEmpty ? 68 : 92)) } } if item.type == "identity", item.data.string("presentation") == "walletSidebar" { @@ -1831,7 +1833,7 @@ final class NativeListView: UIView { return CGFloat(style?.double("verticalPadding", default: 16) ?? 16) * 2 + textHeight("title", lines: 2, weight: .semibold) + textHeight("body", lines: item.data.int("bodyLines", default: 3), weight: .regular) - + CGFloat(style?.dictionary("time")?.double("lineHeight", default: 20) ?? 20) + + textHeight("time", lines: 1, weight: .regular) + CGFloat(style?.double("lineGap", default: 1) ?? 1) * 2 } @@ -2180,7 +2182,8 @@ extension NativeListView: UICollectionViewDelegateFlowLayout { if config.orientation == "horizontal" { let width: CGFloat = item.type == "rail" ? railWidth(item) : item.type == "mediaTile" ? 200 : 280 let availableHeight = max(0, collectionView.bounds.height - insets.top - insets.bottom) - let height = item.type == "rail" ? min(rowHeight(item), availableHeight) : availableHeight + let height = item.type == "walletGroup" && item.key == interactiveReorderCompactKey ? 68 + : item.styledHeight ?? (item.data["height"] != nil ? rowHeight(item) : item.type == "rail" ? min(rowHeight(item), availableHeight) : availableHeight) return CGSize(width: width, height: height) } let available = max(0, collectionView.bounds.width - insets.left - insets.right) @@ -2188,7 +2191,7 @@ extension NativeListView: UICollectionViewDelegateFlowLayout { if config.layout == "grid", !structural { let spacing = CGFloat(config.gridColumns - 1) * config.itemSpacing let width = floor((available - spacing) / CGFloat(config.gridColumns)) - let height = item.type == "mediaTile" ? width + 48 : rowHeight(item) + let height = item.styledHeight ?? (item.type == "mediaTile" && item.data["height"] == nil ? width + 48 : rowHeight(item)) return CGSize(width: width, height: height) } return CGSize(width: available, height: rowHeight(item)) diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index 30ca440d5..8a844d0df 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -591,6 +591,372 @@ describe('web row style', () => { return body; }; + it('honors legacy identity line limits and restores them when style is cleared', () => { + const base: IdentityRow = { + type: 'identity', + key: 'legacy-lines', + leading: { kind: 'icon', name: 'coin' }, + title: 'First\nSecond', + subtitle: 'One\nTwo', + titleLines: 2, + subtitleLines: 2, + titleMatch: [{ start: 0, end: 5 }], + }; + for (const style of [ + { title: { lines: 1 as const }, subtitle: { lines: 3 as const } }, + {}, + ]) { + const body = render({ ...base, style }); + const title = body.querySelector('[data-nl-slot="title"]')!; + const subtitle = body.querySelector( + '[data-nl-slot="subtitle"]' + )!; + expect(title.style.getPropertyValue('-webkit-line-clamp')).toBe( + 'title' in style ? '' : '2' + ); + expect(title.textContent).toBe( + 'title' in style ? 'First Second' : base.title + ); + expect(subtitle.style.getPropertyValue('-webkit-line-clamp')).toBe( + 'subtitle' in style ? '3' : '2' + ); + } + }); + + it('uses template line height for detached multi-line clip and retains unbounded warning defaults', () => { + const message = render({ + type: 'message', + key: 'clip', + title: 'Title', + body: 'One\nTwo\nThree\nFour', + time: 'Now', + style: { body: { lines: 3, truncate: 'clip' } }, + }); + expect( + message.querySelector('[data-nl-slot="body"]')!.style + .maxHeight + ).toBe('3lh'); + const warning = render({ + type: 'system', + key: 'warning', + variant: 'warning', + title: 'Warning', + message: 'One\nTwo\nThree\nFour', + style: { message: { truncate: 'clip' } }, + }); + const text = warning.querySelector( + '[data-nl-slot="message"]' + )!; + expect(text.style.whiteSpace).toBe('pre-wrap'); + expect(text.style.maxHeight).toBe(''); + expect(text.textContent).toContain('\n'); + }); + + it('applies Market badge metrics and center image fitting without changing overlays', () => { + const body = render({ + type: 'market', + key: 'market-badge', + variant: 'token', + title: 'Market', + leading: { kind: 'token', image, networkImage: image }, + price: '$1', + change: { text: '+1%', tone: 'positive' }, + badges: [ + { + key: 'tag', + text: 'Tag', + style: { + fontSize: 15, + fontWeight: 'bold', + lineHeight: 22, + height: 30, + horizontalPadding: 7, + }, + }, + ], + style: { image: { contentFit: 'center' }, leadingGap: 9 }, + }); + const badge = body.querySelector( + '.ok-native-list-market-badge' + )!; + expect(badge.style.fontSize).toBe('15px'); + expect(badge.style.fontWeight).toBe('700'); + expect(badge.style.lineHeight).toBe('22px'); + expect(badge.style.height).toBe('30px'); + expect(badge.style.paddingInline).toBe('7px'); + const visual = body.querySelector('.ok-native-list-visual')!; + expect(visual.style.marginInlineEnd).toBe('9px'); + expect( + visual.querySelector('.ok-native-list-visual-main')!.style + .objectFit + ).toBe('none'); + expect( + visual.querySelector('.ok-native-list-visual-corner')!.style + .objectFit + ).not.toBe('none'); + }); + + it('gives explicit Market typography priority over rich runs and legacy change colors', () => { + const body = render({ + type: 'market', + key: 'market-rich', + variant: 'token', + title: 'Market', + leading: { kind: 'icon', name: 'coin' }, + price: '$01', + priceSegments: [{ text: '$0' }, { text: '1', style: 'subscript' }], + change: { + text: '+01', + textSegments: [{ text: '+0' }, { text: '1', style: 'subscript' }], + tone: 'positive', + textColor: '#ff0000', + }, + style: { + price: { fontSize: 18 }, + change: { fontSize: 17, color: '#123456' }, + }, + }); + const price = body.querySelector( + '.ok-native-list-market-price' + )!; + const change = body.querySelector( + '.ok-native-list-market-change' + )!; + expect( + Array.from(price.querySelectorAll('span')).map( + (run) => run.style.fontSize + ) + ).toEqual(['18px', '18px']); + expect( + Array.from( + change.querySelectorAll( + '.ok-native-list-text-content > span' + ) + ).map((run) => run.style.fontSize) + ).toEqual(['17px', '17px']); + expect(change.style.color).toBe('rgb(18, 52, 86)'); + }); + + it('allows Market measurement changes while giving explicit style height precedence', () => { + const row: RowModel = { + type: 'market', + key: 'market-height', + variant: 'token', + title: 'Market', + leading: { kind: 'icon', name: 'coin' }, + price: '$1', + change: { text: '+1%', tone: 'positive' }, + }; + const config = snapshot({ kind: 'linear' }, [row]); + expect( + estimateWebRowHeight( + { ...row, style: { image: { height: 140 }, verticalPadding: 48 } }, + config, + 320 + ) + ).toBeGreaterThan(estimateWebRowHeight(row, config, 320)); + expect( + estimateWebRowHeight( + { + ...row, + height: 180, + style: { image: { height: 140 }, verticalPadding: 48 }, + }, + config, + 320 + ) + ).toBe(180); + }); + + it('resolves style heights in linear, grid, horizontal, nested and footer paths without overwriting legacy heights', () => { + const base: IdentityRow = { + type: 'identity', + key: 'sized', + height: 80, + title: 'Sized', + leading: { kind: 'icon', name: 'coin' }, + }; + const sized: IdentityRow = { + ...base, + style: { container: { height: 124 } }, + }; + for (const layout of [ + { kind: 'linear' }, + { kind: 'grid', gridColumns: 2 }, + { kind: 'linear', orientation: 'horizontal' }, + ] as const) { + expect( + computeWebListLayout(snapshot(layout, [sized]), 360, 500).items[0]! + .height + ).toBe(124); + expect(sized.height).toBe(80); + } + const media: RowModel = { + type: 'mediaTile', + variant: 'gallery', + key: 'media-sized', + title: 'Media', + style: { container: { height: 150 } }, + }; + expect( + computeWebListLayout( + snapshot({ kind: 'grid', gridColumns: 2 }, [media]), + 360, + 500 + ).items[0]!.height + ).toBe(150); + const parent: IdentityRow = { + ...base, + key: 'parent', + presentation: 'walletSidebar', + style: { container: { height: 100 } }, + }; + const group: RowModel = { + type: 'walletGroup', + key: 'parent', + parent, + children: [ + { ...parent, key: 'child', style: { container: { height: 90 } } }, + ], + }; + expect( + estimateWebRowHeight(group, snapshot({ kind: 'linear' }, [group]), 360) + ).toBe(204); + const groupBody = render(group); + expect( + Array.from(groupBody.children).map( + (node) => (node as HTMLElement).style.height + ) + ).toEqual(['100px', '90px']); + const compact = computeWebListLayout( + snapshot({ kind: 'linear' }, [ + { ...group, style: { container: { height: 250 } } }, + ]), + 360, + 500, + 'parent' + ); + expect(compact.items[0]!.height).toBe(68); + const { document } = new JSDOM('').window; + const host = document.createElement('div'); + document.body.appendChild(host); + const footer = { + type: 'action', + key: 'footer', + actionKey: 'footer-action', + title: 'Footer', + height: 50, + style: { container: { height: 92 } }, + } as const; + const engine = new NativeListWebEngine( + host, + { ...snapshot({ kind: 'sectioned' }, [sized]), fixedFooter: footer }, + {}, + false + ); + try { + expect( + host.querySelector('[data-native-list-row-key="sized"]')! + .style.height + ).toBe('124px'); + expect( + host.querySelector( + '.ok-native-list-footer > .ok-native-list-item' + )!.style.height + ).toBe('92px'); + engine.applySnapshot({ + ...snapshot({ kind: 'sectioned' }, [{ ...base, style: {} }]), + fixedFooter: { ...footer, style: {} }, + }); + expect( + host.querySelector('[data-native-list-row-key="sized"]')! + .style.height + ).toBe('80px'); + expect( + host.querySelector( + '.ok-native-list-footer > .ok-native-list-item' + )!.style.height + ).toBe('50px'); + } finally { + engine.destroy(); + } + }); + + it('includes timestamp line limits in measured message height and respects a styled height', () => { + const base = { + type: 'message', + key: 'time-height', + title: 'Title', + body: 'Body', + time: 'One\nTwo\nThree', + } as const; + const config = snapshot({ kind: 'linear' }, [base]); + const single = { + ...base, + style: { time: { lines: 1 as const, lineHeight: 22 } }, + }; + const multi = { + ...base, + style: { time: { lines: 3 as const, lineHeight: 22 } }, + }; + expect( + estimateWebRowHeight(multi, config, 400) - + estimateWebRowHeight(single, config, 400) + ).toBe(44); + expect( + estimateWebRowHeight( + { ...multi, style: { ...multi.style, container: { height: 120 } } }, + config, + 400 + ) + ).toBe(120); + }); + + it('preserves the value-pair break inside a shared multi-line budget', () => { + const base: IdentityRow = { + type: 'identity', + key: 'pair-lines', + title: 'Pair', + leading: { kind: 'icon', name: 'coin' }, + trailing: [{ kind: 'valuePair', primary: 'One', secondary: 'Two' }], + }; + const multi = render({ + ...base, + style: { value: { lines: 3, truncate: 'clip' } }, + }).querySelector('[data-nl-slot="value"]')!; + expect(multi.textContent).toBe('One\nTwo'); + expect(multi.lastElementChild?.getAttribute('style')).toContain( + 'white-space: inherit' + ); + const single = render({ + ...base, + style: { value: { lines: 1 } }, + }).querySelector('[data-nl-slot="value"]')!; + expect(single.textContent).toBe('One Two'); + }); + + it('keeps the media leading gap independent of container alignment', () => { + const body = render({ + type: 'mediaTile', + key: 'media-gap', + variant: 'gallery', + title: 'Media', + image, + style: { + leadingGap: 11, + container: { contentVerticalAlignment: 'bottom' }, + }, + }); + expect( + body.querySelector('.ok-native-list-media-meta')!.style + .paddingTop + ).toBe('11px'); + expect( + body.querySelector('.ok-native-list-media-image')!.style + .marginBottom + ).toBe(''); + }); + it('shares container appearance across every template without styling nested text', () => { const parent: IdentityRow = { type: 'identity', @@ -622,6 +988,7 @@ describe('web row style', () => { ...row, style: { container: { + height: 141, backgroundColor: '#123456', borderWidth: 2, borderColor: '#abcdef', @@ -630,11 +997,21 @@ describe('web row style', () => { }, }, } as RowModel); + expect( + estimateWebRowHeight( + { ...row, style: { container: { height: 141 } } } as RowModel, + snapshot({ kind: 'linear' }, [row]), + 320 + ) + ).toBe(141); expect(body.style.getPropertyValue('--nl-container-background')).toBe( '#123456' ); expect(body.style.borderRadius).toBe('7px'); - expect(body.style.boxShadow).toContain('inset 0 0 0 2px'); + expect(body.getAttribute('data-nl-container-border')).toBe('true'); + expect(body.style.getPropertyValue('--nl-container-border-width')).toBe( + '2px' + ); expect( body.querySelector('[data-nl-slot="title"]')?.style .color ?? '' @@ -807,7 +1184,7 @@ describe('web row style', () => { change.querySelectorAll('.ok-native-list-text-content') ).toHaveLength(1); const content = change.firstElementChild as HTMLElement; - expect(content.style.maxHeight).toBe('40px'); + expect(content.style.maxHeight).toBe('2lh'); expect(content.style.transform).toBe('translateY(2px)'); } } finally { @@ -1000,7 +1377,7 @@ describe('web row style', () => { style: { value: { fontSize: 19, color: '#123456' } }, }); const value = body.querySelector('[data-nl-slot="value"]')!; - expect(value.textContent).toBe('$102 BTC'); + expect(value.textContent).toBe('$10\n2 BTC'); expect(value.style.fontSize).toBe('19px'); expect( Array.from(value.children).every( diff --git a/native-views/react-native-native-list/src/__tests__/validation.test.ts b/native-views/react-native-native-list/src/__tests__/validation.test.ts index 49e32204b..065a1ebef 100644 --- a/native-views/react-native-native-list/src/__tests__/validation.test.ts +++ b/native-views/react-native-native-list/src/__tests__/validation.test.ts @@ -1431,6 +1431,7 @@ describe('NativeList style contract', () => { describe('row container and text layout contract', () => { const style = { container: { + height: 120.5, backgroundColor: '#12345678', opacity: 0.8, cornerRadius: 9, @@ -1450,6 +1451,7 @@ describe('row container and text layout contract', () => { it('round-trips the same nested surface through snapshots and whole-style replacement patches', () => { const base = { ...row('text-layout'), + height: 80, backgroundColor: '#FFFFFF', opacity: 0.4, style, @@ -1465,6 +1467,7 @@ describe('row container and text layout contract', () => { ]); expect(cleared.rows[0]).toMatchObject({ style: {}, + height: 80, backgroundColor: '#FFFFFF', opacity: 0.4, }); @@ -1472,6 +1475,10 @@ describe('row container and text layout contract', () => { it.each([ ['container', { width: 120 }], + ['container', { height: -1 }], + ['container', { height: 4097 }], + ['container', { height: NaN }], + ['container', { height: Infinity }], ['container', { opacity: 1.1 }], ['container', { borderWidth: -1 }], ['container', { backgroundColor: 'red' }], @@ -1499,3 +1506,62 @@ describe('row container and text layout contract', () => { ).toThrow('style'); }); }); + +describe('declared nested style and line-limit boundaries', () => { + it.each([ + 'color', + 'lines', + 'truncate', + 'alignment', + 'verticalAlignment', + 'offsetY', + 'token', + ])( + 'rejects undeclared Market badge property %s in snapshots and patches', + (key) => { + const values: Record = { + color: '#123456', + lines: 2, + truncate: 'clip', + alignment: 'end', + verticalAlignment: 'top', + offsetY: 1, + token: '$bodySm', + }; + const badges = [ + { key: 'badge', text: 'Badge', style: { [key]: values[key] } }, + ]; + expect(() => + validateSnapshot(snapshot([{ ...marketRow(), badges } as MarketRow])) + ).toThrow('Market badge style key'); + expect(() => + validatePatches([ + { key: 'market', type: 'market', changes: { badges } } as RowPatch, + ]) + ).toThrow('Market badge style key'); + } + ); + it.each([1.5, NaN, Infinity])( + 'rejects non-integral message bodyLines %s on both update paths', + (bodyLines) => { + const message = { + type: 'message', + key: 'message', + title: 'Title', + body: 'Body', + time: 'Now', + bodyLines, + } as RowModel; + expect(() => validateSnapshot(snapshot([message]))).toThrow('bodyLines'); + expect(() => + validatePatches([ + { + key: 'message', + type: 'message', + changes: { bodyLines }, + } as RowPatch, + ]) + ).toThrow('bodyLines'); + } + ); +}); diff --git a/native-views/react-native-native-list/src/models.ts b/native-views/react-native-native-list/src/models.ts index 257766894..dd321e52d 100644 --- a/native-views/react-native-native-list/src/models.ts +++ b/native-views/react-native-native-list/src/models.ts @@ -80,6 +80,8 @@ export type NativeListImageStyle = Readonly<{ export type MarketImageStyle = NativeListImageStyle; export type NativeListRowContainerStyle = Readonly<{ + /** Explicit row height in logical units; overrides RowBase.height. */ + height?: number; backgroundColor?: string; opacity?: number; cornerRadius?: number; diff --git a/native-views/react-native-native-list/src/validation.ts b/native-views/react-native-native-list/src/validation.ts index 78125b077..6c4aa6141 100644 --- a/native-views/react-native-native-list/src/validation.ts +++ b/native-views/react-native-native-list/src/validation.ts @@ -290,6 +290,7 @@ function assertBoxStyle(style: RowBoxStyle, path: string): void { assertUnknownKeys( container as Record, [ + 'height', 'backgroundColor', 'opacity', 'cornerRadius', @@ -310,6 +311,12 @@ function assertBoxStyle(style: RowBoxStyle, path: string): void { fail(`${containerPath}.${key}`, 'must be #RRGGBB or #RRGGBBAA'); } } + assertBoundedStyleNumber( + container.height, + `${containerPath}.height`, + 0, + 4096 + ); assertBoundedStyleNumber( container.opacity, `${containerPath}.opacity`, @@ -652,6 +659,13 @@ function assertMarketRow(row: MarketRow, path: string): void { 'height', 'horizontalPadding', ]); + if (badge.style) + assertUnknownKeys( + badge.style, + ['fontSize', 'fontWeight', 'lineHeight', 'height', 'horizontalPadding'], + `${badgePath}.style`, + 'Market badge style key' + ); assertBoundedStyleNumber( badge.style?.height, `${badgePath}.style.height`, @@ -947,7 +961,7 @@ function assertRow( fail(`${path}.height`, 'must be within 0...4096'); if ( row.heightRounding !== undefined && - (row.height === undefined || + ((row.height === undefined && row.style?.container?.height === undefined) || !['floor', 'nearest'].includes(row.heightRounding)) ) fail( @@ -1051,8 +1065,8 @@ function assertRow( case 'message': assertText(row.title, `${path}.title`); assertText(row.body, `${path}.body`); - if ((row.bodyLines ?? 3) < 1 || (row.bodyLines ?? 3) > 3) { - fail(`${path}.bodyLines`, 'must be within 1...3'); + if (![1, 2, 3].includes(row.bodyLines ?? 3)) { + fail(`${path}.bodyLines`, 'must be 1, 2, or 3'); } assertImage(row.thumbnail, `${path}.thumbnail`); break; @@ -1496,9 +1510,9 @@ function assertPatchChanges(patch: RowPatch, index: number): void { assertText(patch.changes.time, `${path}.time`); if ( patch.changes.bodyLines !== undefined && - (patch.changes.bodyLines < 1 || patch.changes.bodyLines > 3) + ![1, 2, 3].includes(patch.changes.bodyLines) ) { - fail(`${path}.bodyLines`, 'must be within 1...3'); + fail(`${path}.bodyLines`, 'must be 1, 2, or 3'); } assertImage(patch.changes.thumbnail, `${path}.thumbnail`); break; diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index 4813488b6..4c584ece8 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -507,7 +507,7 @@ function approximateMessageHeight(row: RowModel, availableWidth: number) { (style?.verticalPadding ?? 16) * 2 + height(row.title, style?.title, 2) + height(row.body, style?.body, row.bodyLines ?? 3) + - (style?.time?.lineHeight ?? 20) + + height(row.time, style?.time, 1) + (style?.lineGap ?? 1) * 2 ); } @@ -519,7 +519,8 @@ export function estimateWebRowHeight( ): number { // OneKey patch: explicit selector height takes precedence over presets. // if (row.type === 'system' && row.variant === 'spacer') return row.height; - if (row.height !== undefined) return row.height; + const explicitHeight = row.style?.container?.height ?? row.height; + if (explicitHeight !== undefined) return explicitHeight; if (row.type === 'system' && row.variant === 'warning') { const width = Math.max( 1, @@ -729,9 +730,14 @@ export function computeWebListLayout( y: padding.top, width: itemWidth, height: - row.type === 'rail' + (row.type === 'walletGroup' && row.key === compactRowKey + ? WALLET_REORDER_COMPACT_HEIGHT + : undefined) ?? + row.style?.container?.height ?? + row.height ?? + (row.type === 'rail' ? Math.min(rowHeight, availableHeight) - : availableHeight, + : availableHeight), }); x += itemWidth + spacing; }); @@ -809,9 +815,11 @@ export function computeWebListLayout( return; } const itemHeight = - row.type === 'mediaTile' + row.style?.container?.height ?? + row.height ?? + (row.type === 'mediaTile' ? itemWidth + 48 - : estimateWebRowHeight(row, snapshot, itemWidth); + : estimateWebRowHeight(row, snapshot, itemWidth)); items.push({ index, key: row.key, @@ -1000,7 +1008,7 @@ export function webMarketImageBindDeltaForPatches( // OneKey patch: selector names must shrink before the sidebar clips their contents. export const WEB_LIST_CSS = ` [data-native-list-selector="walletSidebar"] .ok-native-list-title{max-width:100%;min-width:0} -.ok-native-list-root{--nl-bg:#f7f7f7;--nl-row:#fff;--nl-selected:#eaf2ff;--nl-pressed:#e8e8e8;--nl-subdued:#f9f9f9;--nl-strong:#0000000f;--nl-primary:#111;--nl-secondary:#6b7280;--nl-disabled:#8d8d8d;--nl-icon:#111;--nl-icon-subdued:#8d8d8d;--nl-separator:#e5e7eb;--nl-accent:#2f6bff;--nl-positive:#15803d;--nl-negative:#dc2626;--nl-critical:#feecec;--nl-inverse:#202020;--nl-inverse-text:#fcfcfc;--nl-info:#0d74ce;position:absolute;inset:0;display:flex;min-width:0;min-height:0;overflow:hidden;background:var(--nl-bg);color:var(--nl-primary);font-family:Roobert,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none} +.ok-native-list-root{--nl-bg:#f7f7f7;--nl-row:#fff;--nl-selected:#eaf2ff;--nl-pressed:#e8e8e8;--nl-subdued:#f9f9f9;--nl-strong:#0000000f;--nl-primary:#111;--nl-secondary:#6b7280;--nl-disabled:#8d8d8d;--nl-icon:#111;--nl-icon-subdued:#8d8d8d;--nl-separator:#e5e7eb;--nl-accent:#2f6bff;--nl-positive:#15803d;--nl-negative:#dc2626;--nl-critical:#feecec;--nl-inverse:#202020;--nl-inverse-text:#fcfcfc;--nl-info:#0d74ce;position:absolute;inset:0;display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden;background:var(--nl-bg);color:var(--nl-primary);font-family:Roobert,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none} .ok-native-list-viewport-frame{position:relative;flex:1;min-width:0;min-height:0;overflow:hidden} .ok-native-list-viewport{position:absolute;inset:0;overflow:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;scrollbar-gutter:stable} .ok-native-list-content{position:relative;min-width:100%;min-height:100%} @@ -1093,6 +1101,8 @@ export const WEB_LIST_CSS = ` .ok-native-list-account-action-row{border-radius:12px} .ok-native-list-root .ok-native-list-item>[data-nl-container-background="true"],.ok-native-list-root .ok-native-list-wallet-member>[data-nl-container-background="true"]{background:var(--nl-container-background)} +.ok-native-list-root [data-nl-container-border="true"]{position:relative} +.ok-native-list-root [data-nl-container-border="true"]::before{content:"";position:absolute;inset:0;border-radius:inherit;box-shadow:inset 0 0 0 var(--nl-container-border-width) var(--nl-container-border-color);z-index:2;pointer-events:none} .ok-native-list-root .ok-native-list-item[data-native-list-row-press-enabled="true"]:hover>[data-nl-container-background="true"],.ok-native-list-root .ok-native-list-item[data-native-list-row-press-enabled="true"]:active>[data-nl-container-background="true"],.ok-native-list-root .ok-native-list-wallet-member:hover>[data-nl-container-background="true"],.ok-native-list-root .ok-native-list-wallet-member:active>[data-nl-container-background="true"]{background:var(--nl-pressed)} `; @@ -2132,7 +2142,7 @@ function createAccessory( accessory.secondary ); secondary.style.color = toneColor(accessory.secondaryTone, 'secondary'); - element.replaceChildren(primary, secondary); + element.replaceChildren(primary, '\n', secondary); element.classList.add('ok-native-list-amounts'); break; } @@ -3235,6 +3245,13 @@ function createIdentityActivityOrMessageRow( ); column.appendChild(badges); } + for (const key of ['title', 'subtitle'] as const) { + const lines = row.style?.[key]?.lines ?? row[`${key}Lines`]; + if (lines !== undefined) + column + .querySelectorAll(`[data-nl-slot="${key}"]`) + .forEach((text) => applyTextLayout(text, { lines })); + } } if (row.type === 'activity' && row.status) column.appendChild( @@ -3370,7 +3387,11 @@ function createWalletGroupRow( memberElement.appendChild(memberBody); // OneKey patch: group children use their own measured badge height. memberElement.style.flexBasis = - String(member.height ?? 68 + (member.badges?.length ? 24 : 0)) + 'px'; + String( + member.style?.container?.height ?? + member.height ?? + 68 + (member.badges?.length ? 24 : 0) + ) + 'px'; memberElement.style.height = memberElement.style.flexBasis; memberElement.style.opacity = String( (member.style?.container?.opacity ?? member.opacity ?? 1) * @@ -3467,11 +3488,11 @@ function setMarketQuoteContent(element: HTMLElement, row: MarketRow) { change.style.width = String(layoutStyle.changeWidth) + 'px'; change.style.height = String(layoutStyle.changeHeight) + 'px'; change.style.borderRadius = String(layoutStyle.changeCornerRadius) + 'px'; - change.style.color = row.change.textColor ?? style?.change?.color ?? ''; + change.style.color = style?.change?.color ?? row.change.textColor ?? ''; change.style.background = row.change.backgroundColor ?? ''; } - if (price) applyTextLayout(price, style?.price ?? {}, 1); - if (change) applyTextLayout(change, style?.change ?? {}, 1); + if (price) applyTextStyleToSlot(price, style?.price ?? {}, 1); + if (change) applyTextStyleToSlot(change, style?.change ?? {}, 1); element.setAttribute( 'aria-label', row.accessibilityLabel ?? @@ -3538,7 +3559,9 @@ function createMarketRow(context: RenderContext, row: MarketRow): HTMLElement { 'inset(-1px round ' + String(layoutStyle.imageCornerRadius) + 'px)'; } image.style.objectFit = - style?.image?.contentFit ?? image.style.objectFit; + style?.image?.contentFit === 'center' + ? 'none' + : style?.image?.contentFit ?? image.style.objectFit; } else { image.style.width = '20px'; image.style.height = '20px'; @@ -3548,7 +3571,7 @@ function createMarketRow(context: RenderContext, row: MarketRow): HTMLElement { } } }); - visual.style.marginRight = String(layoutStyle.leadingGap) + 'px'; + visual.style.marginInlineEnd = String(layoutStyle.leadingGap) + 'px'; body.appendChild(visual); } const main = createElement( @@ -3606,6 +3629,13 @@ function createMarketRow(context: RenderContext, row: MarketRow): HTMLElement { if ((badge.icon || badge.iconName) && !badge.text) { element.style.padding = '0'; } + if (badge.style) { + applyTextStyleToSlot(element, badge.style); + if (badge.style.height !== undefined) + element.style.height = `${badge.style.height}px`; + if (badge.style.horizontalPadding !== undefined && badge.text) + element.style.paddingInline = `${badge.style.horizontalPadding}px`; + } if (badge.accessibilityLabel) element.setAttribute('aria-label', badge.accessibilityLabel); if (badge.icon) { @@ -3706,12 +3736,12 @@ function createMarketRow(context: RenderContext, row: MarketRow): HTMLElement { const text = body.querySelector( `.ok-native-list-market-${key}` ); - if (text) applyTextLayout(text, style?.[key] ?? {}, 1); + if (text) applyTextStyleToSlot(text, style?.[key] ?? {}, 1); } const prefix = body.querySelector( '.ok-native-list-market-subtitle-prefix' ); - if (prefix) applyTextLayout(prefix, row.subtitlePrefix?.style ?? {}, 1); + if (prefix) applyTextStyleToSlot(prefix, row.subtitlePrefix?.style ?? {}, 1); return body; } @@ -3736,7 +3766,8 @@ function applyTextLayout( style.lines === undefined && style.truncate === undefined && style.verticalAlignment === undefined && - style.offsetY === undefined + style.offsetY === undefined && + defaultLines === undefined ) return; let content = element.querySelector( @@ -3757,7 +3788,9 @@ function applyTextLayout( 1); if ( !content && - (style.verticalAlignment !== undefined || style.offsetY !== undefined) + (style.verticalAlignment !== undefined || + style.offsetY !== undefined || + element.classList.contains('ok-native-list-market-change')) ) { content = element.ownerDocument.createElement('span'); content.className = 'ok-native-list-text-content'; @@ -3825,16 +3858,23 @@ function applyTextLayout( } else { text.style.display = 'block'; if (lines > 1) { - const lineHeight = - style.lineHeight ?? - (parseFloat(element.style.lineHeight || computed?.lineHeight || '') || - 1.2 * (parseFloat(computed?.fontSize || '') || 14)); - text.style.maxHeight = `${lines * lineHeight}px`; + // Rows are styled before mounting, when computed typography is unavailable. + // Resolve omitted line height in CSS after the template rules take effect. + text.style.maxHeight = + style.lineHeight !== undefined + ? `${lines * style.lineHeight}px` + : `${lines}lh`; } } } function applyRowContainerStyle(body: HTMLElement, row: RowModel): void { + if ( + row.type === 'system' && + row.variant === 'warning' && + (row.style?.container?.height ?? row.height) !== undefined + ) + body.style.height = '100%'; const container = row.style?.container; if (!container) return; if (container.backgroundColor !== undefined) { @@ -3850,12 +3890,16 @@ function applyRowContainerStyle(body: HTMLElement, row: RowModel): void { if (container.cornerRadius !== undefined) body.style.borderRadius = `${container.cornerRadius}px`; if (container.borderWidth !== undefined) { - const border = `inset 0 0 0 ${container.borderWidth}px ${ + // Paint above member backgrounds without changing the content box or hit targets. + body.setAttribute('data-nl-container-border', 'true'); + body.style.setProperty( + '--nl-container-border-width', + `${container.borderWidth}px` + ); + body.style.setProperty( + '--nl-container-border-color', container.borderColor ?? 'transparent' - }`; - body.style.boxShadow = body.style.boxShadow - ? `${body.style.boxShadow}, ${border}` - : border; + ); body.style.borderColor = 'transparent'; } else if (container.borderColor !== undefined) body.style.borderColor = container.borderColor; @@ -3891,7 +3935,8 @@ function applyRowContainerStyle(body: HTMLElement, row: RowModel): void { function applyTextStyleToSlot( element: HTMLElement, - style: NativeListTextStyle + style: NativeListTextStyle, + defaultLines?: number ): void { if (style.fontSize !== undefined) element.style.fontSize = String(style.fontSize) + 'px'; @@ -3909,9 +3954,11 @@ function applyTextStyleToSlot( run.style.fontWeight = String(marketFontWeight(style.fontWeight, 400)); if (style.lineHeight !== undefined) run.style.lineHeight = String(style.lineHeight) + 'px'; + if (style.lines !== undefined || style.truncate !== undefined) + run.style.whiteSpace = 'inherit'; }); if (style.alignment !== undefined) element.style.textAlign = style.alignment; - applyTextLayout(element, style); + applyTextLayout(element, style, defaultLines); } /** @@ -3980,7 +4027,12 @@ export function applyRowStyle(body: HTMLElement, row: RowModel): void { ? String(box.lineGap) + 'px' : defaultGap) + ')'; - if (vertical) leading.style.marginBottom = gap; + if (row.type === 'mediaTile') { + const metadata = body.querySelector( + '.ok-native-list-media-meta' + ); + if (metadata) metadata.style.paddingTop = `${box.leadingGap}px`; + } else if (vertical) leading.style.marginBottom = gap; else leading.style.marginInlineEnd = gap; } if (box.titleBadgeGap !== undefined) { @@ -4083,7 +4135,11 @@ export function applyRowStyle(body: HTMLElement, row: RowModel): void { body .querySelectorAll('[data-nl-slot="' + key + '"]') .forEach((element) => { - applyTextStyleToSlot(element, slotStyle); + applyTextStyleToSlot( + element, + slotStyle, + row.type === 'system' && row.variant === 'warning' ? 0 : undefined + ); if (row.type === 'dataRow' && slotStyle.alignment) { element.style.flex = '1'; if (element.parentElement) element.parentElement.style.width = '100%'; @@ -4713,6 +4769,7 @@ export class NativeListWebEngine { rows: this.snapshot.rows.map((row) => row.type === 'system' && row.variant === 'warning' && + row.style?.container?.height === undefined && row.height === undefined && this.measuredWarningHeights.has(row.key) ? { ...row, height: this.measuredWarningHeights.get(row.key) } @@ -4845,6 +4902,7 @@ export class NativeListWebEngine { if ( row?.type !== 'system' || row.variant !== 'warning' || + row.style?.container?.height !== undefined || row.height !== undefined ) return; From c33a5b12512168e60b894860a97985955f29a255 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 19:34:58 +0800 Subject: [PATCH 12/71] test(native-list): lock message binding and style reset behavior --- .../src/__tests__/NativeList.web.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index 8a844d0df..204383983 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -912,6 +912,98 @@ describe('web row style', () => { ).toBe(120); }); + it('rebinds a message through styled, cleared and different-template snapshots', () => { + const { document } = new JSDOM('').window; + const host = document.createElement('div'); + document.body.appendChild(host); + const message = { + type: 'message', + key: 'reused-message', + title: 'First\nSecond', + body: 'One\nTwo\nThree', + time: 'Now', + bodyLines: 1, + height: 136, + unread: true, + } as const; + const engine = new NativeListWebEngine( + host, + snapshot({ kind: 'sectioned' }, [message]), + {}, + false + ); + const item = () => + host.querySelector( + '[data-native-list-row-key="reused-message"]' + )!; + const body = () => + item().querySelector('[data-nl-slot="body"]')!; + try { + const wrapper = item(); + expect(body().textContent).toBe('One Two Three'); + engine.applyPatches([ + { + type: 'message', + key: message.key, + changes: { + style: { + container: { height: 192, contentVerticalAlignment: 'bottom' }, + body: { + lines: 3, + lineHeight: 22, + truncate: 'clip', + color: '#FF0000', + }, + }, + }, + }, + ]); + expect(item()).toBe(wrapper); + expect(item().style.height).toBe('192px'); + expect(body().textContent).toBe(message.body); + expect(body().style.maxHeight).toBe('66px'); + engine.applyPatches([ + { type: 'message', key: message.key, changes: { style: {} } }, + ]); + expect(item().style.height).toBe('136px'); + expect(body().textContent).toBe('One Two Three'); + expect(body().style.color).toBe(''); + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [ + { + ...message, + title: 'Updated', + body: 'Replacement', + time: 'Later', + unread: false, + }, + ]) + ); + expect(item()).toBe(wrapper); + expect(item().querySelector('.ok-native-list-unread')).toBeNull(); + expect(item().querySelector('[data-nl-slot="time"]')?.textContent).toBe( + 'Later' + ); + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [ + { + type: 'identity', + key: message.key, + title: 'Identity', + leading: { kind: 'icon', name: 'StarOutline' }, + }, + ]) + ); + expect(item().querySelector('[data-nl-slot="body"]')).toBeNull(); + expect(item().querySelector('[data-nl-slot="time"]')).toBeNull(); + engine.applySnapshot(snapshot({ kind: 'sectioned' }, [message])); + expect(item().style.height).toBe('136px'); + expect(body().textContent).toBe('One Two Three'); + } finally { + engine.destroy(); + } + }); + it('preserves the value-pair break inside a shared multi-line budget', () => { const base: IdentityRow = { type: 'identity', From 1bfb5c2922e3d5c59332c00f4c6a638b65f4f95f Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 19:40:51 +0800 Subject: [PATCH 13/71] refactor(native-list): isolate web message renderer --- .../src/web/NativeListWebEngine.ts | 119 +++--------------- .../src/web/templates/MessageRowRenderer.ts | 94 ++++++++++++++ 2 files changed, 112 insertions(+), 101 deletions(-) create mode 100644 native-views/react-native-native-list/src/web/templates/MessageRowRenderer.ts diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index 4c584ece8..b0698cda2 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -1,3 +1,7 @@ +import { + createMessageRow, + measureMessageRow, +} from './templates/MessageRowRenderer'; import type { ActionAnchorInvalidatedEvent, CheckboxState, @@ -467,51 +471,6 @@ function sizeModifier(row: RowModel): number { return 0; } -function approximateMessageHeight(row: RowModel, availableWidth: number) { - if (row.type !== 'message') return 0; - const style = row.style; - const leadingWidth = row.leading - ? (style?.image?.width ?? 40) + (style?.leadingGap ?? 12) - : 0; - const thumbnailWidth = row.thumbnail ? 88 : 0; - const textWidth = Math.max( - 1, - availableWidth - - leadingWidth - - thumbnailWidth - - (style?.horizontalPadding ?? 20) * 2 - ); - const height = ( - text: string, - textStyle: NativeListTextStyle | undefined, - fallbackLines: number - ) => { - const size = textStyle?.fontSize ?? 14; - const charactersPerLine = Math.max( - textStyle ? 1 : 18, - Math.floor(textWidth / (size / 2)) - ); - const measured = text - .split(/\r\n|[\r\n]/) - .reduce( - (total, line) => - total + Math.max(1, Math.ceil(line.length / charactersPerLine)), - 0 - ); - return ( - Math.min(textStyle?.lines ?? fallbackLines, measured) * - (textStyle?.lineHeight ?? 20) - ); - }; - return ( - (style?.verticalPadding ?? 16) * 2 + - height(row.title, style?.title, 2) + - height(row.body, style?.body, row.bodyLines ?? 3) + - height(row.time, style?.time, 1) + - (style?.lineGap ?? 1) * 2 - ); -} - export function estimateWebRowHeight( row: RowModel, snapshot: NativeListSnapshot, @@ -587,7 +546,7 @@ export function estimateWebRowHeight( base = row.footerActions?.length ? 100 : 60; break; case 'message': - base = approximateMessageHeight(row, availableWidth); + base = measureMessageRow(row, availableWidth); break; case 'mediaTile': base = 244; @@ -3041,9 +3000,9 @@ function createDataRow( return body; } -function createIdentityActivityOrMessageRow( +function createIdentityOrActivityRow( context: RenderContext, - row: Extract + row: Extract ): HTMLElement { const presentation = row.type === 'identity' ? row.presentation : undefined; const body = createElement( @@ -3125,17 +3084,8 @@ function createIdentityActivityOrMessageRow( const secondVisual = createVisual(context, row.secondaryLeading); if (secondVisual) body.appendChild(secondVisual); } - if (row.type === 'message' && row.unread) - body.appendChild( - createElement(context.document, 'span', 'ok-native-list-unread') - ); const title = row.title; - const subtitle = - row.type === 'identity' - ? row.subtitle - : row.type === 'activity' - ? row.description - : row.body; + const subtitle = row.type === 'identity' ? row.subtitle : row.description; const column = createTextColumn( context, title, @@ -3147,29 +3097,10 @@ function createIdentityActivityOrMessageRow( : undefined, { title: 'title', - subtitle: - row.type === 'activity' - ? 'description' - : row.type === 'message' - ? 'body' - : 'subtitle', + subtitle: row.type === 'activity' ? 'description' : 'subtitle', tertiary: 'tertiary', } ); - if (row.type === 'message') { - const messageTitle = column.querySelector( - '[data-nl-slot="title"]' - ); - const messageBody = column.querySelector( - '[data-nl-slot="body"]' - ); - if (messageTitle) - applyTextLayout(messageTitle, { lines: row.style?.title?.lines ?? 2 }); - if (messageBody) - applyTextLayout(messageBody, { - lines: row.style?.body?.lines ?? row.bodyLines ?? 3, - }); - } // OneKey patch: match existing search, subtitle fragments, and sidebar badges. if (row.type === 'identity') { const titleElement = column.firstElementChild as HTMLElement; @@ -3319,26 +3250,6 @@ function createIdentityActivityOrMessageRow( ) ); body.appendChild(amounts); - } else if (row.type === 'message') { - body.appendChild( - tagSlot( - createElement( - context.document, - 'span', - 'ok-native-list-time', - row.time - ), - 'time' - ) - ); - if (row.thumbnail) { - const thumbnail = createImage( - context, - row.thumbnail, - 'ok-native-list-thumbnail' - ); - if (thumbnail) body.appendChild(thumbnail); - } } else { appendAccessories(body, context, row.key, row.trailing); } @@ -3380,8 +3291,8 @@ function createWalletGroupRow( setData(memberElement, 'nativeListGroupParent', memberIndex === 0); setData(memberElement, 'nativeListSelected', member.selected); // OneKey patch: grouped members have the same selector typography as standalone wallets. - // memberElement.appendChild(createIdentityActivityOrMessageRow(context, member)); - const memberBody = createIdentityActivityOrMessageRow(context, member); + // memberElement.appendChild(createIdentityOrActivityRow(context, member)); + const memberBody = createIdentityOrActivityRow(context, member); applySelectorTabularNumbers(memberBody, member); applyRowStyle(memberBody, member); memberElement.appendChild(memberBody); @@ -4173,8 +4084,14 @@ export function createRowBody( return createMarketRow(context, row); case 'identity': case 'activity': + return createIdentityOrActivityRow(context, row); case 'message': - return createIdentityActivityOrMessageRow(context, row); + return createMessageRow(context.document, row, { + visual: (source) => createVisual(context, source), + thumbnail: (source) => + createImage(context, source, 'ok-native-list-thumbnail'), + textLayout: applyTextLayout, + }); } } diff --git a/native-views/react-native-native-list/src/web/templates/MessageRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/MessageRowRenderer.ts new file mode 100644 index 000000000..617f8a989 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/MessageRowRenderer.ts @@ -0,0 +1,94 @@ +import type { + ImageSource, + LeadingVisual, + MessageRow, + NativeListTextStyle, +} from '../../models'; + +// Asset loading and text styling remain shared; the template owns its structure. +type MessagePrimitives = Readonly<{ + visual: (source: LeadingVisual | undefined) => HTMLElement | undefined; + thumbnail: (source: ImageSource) => HTMLElement | undefined; + textLayout: (element: HTMLElement, style: NativeListTextStyle) => void; +}>; + +export function createMessageRow( + document: Document, + row: MessageRow, + primitives: MessagePrimitives +): HTMLElement { + const span = (className: string, text?: string, role?: string) => { + const element = document.createElement('span'); + element.className = className; + if (text !== undefined) element.textContent = text; + if (role) element.dataset.nlSlot = role; + return element; + }; + const body = document.createElement('div'); + body.className = 'ok-native-list-row ok-native-list-standard'; + const leading = primitives.visual(row.leading); + if (leading) body.appendChild(leading); + if (row.unread) body.appendChild(span('ok-native-list-unread')); + const column = span('ok-native-list-flex'); + const title = span('ok-native-list-title', row.title, 'title'); + primitives.textLayout(title, { lines: row.style?.title?.lines ?? 2 }); + column.appendChild(title); + if (row.body) { + const text = span('ok-native-list-secondary', row.body, 'body'); + primitives.textLayout(text, { + lines: row.style?.body?.lines ?? row.bodyLines ?? 3, + }); + column.appendChild(text); + } + body.appendChild(column); + body.appendChild(span('ok-native-list-time', row.time, 'time')); + if (row.thumbnail) { + const thumbnail = primitives.thumbnail(row.thumbnail); + if (thumbnail) body.appendChild(thumbnail); + } + return body; +} + +export function measureMessageRow(row: MessageRow, availableWidth: number) { + const style = row.style; + const leadingWidth = row.leading + ? (style?.image?.width ?? 40) + (style?.leadingGap ?? 12) + : 0; + const thumbnailWidth = row.thumbnail ? 88 : 0; + const textWidth = Math.max( + 1, + availableWidth - + leadingWidth - + thumbnailWidth - + (style?.horizontalPadding ?? 20) * 2 + ); + const height = ( + text: string, + textStyle: NativeListTextStyle | undefined, + fallbackLines: number + ) => { + const size = textStyle?.fontSize ?? 14; + const charactersPerLine = Math.max( + textStyle ? 1 : 18, + Math.floor(textWidth / (size / 2)) + ); + const measured = text + .split(/\r\n|[\r\n]/) + .reduce( + (total, line) => + total + Math.max(1, Math.ceil(line.length / charactersPerLine)), + 0 + ); + return ( + Math.min(textStyle?.lines ?? fallbackLines, measured) * + (textStyle?.lineHeight ?? 20) + ); + }; + return ( + (style?.verticalPadding ?? 16) * 2 + + height(row.title, style?.title, 2) + + height(row.body, style?.body, row.bodyLines ?? 3) + + height(row.time, style?.time, 1) + + (style?.lineGap ?? 1) * 2 + ); +} From fe2f5fc6f9174c424bec23404d62fe0fa271d8a9 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 19:44:51 +0800 Subject: [PATCH 14/71] refactor(native-list): isolate Android message binding and style targets --- .../nativelist/NativeListMessageRenderer.kt | 64 +++++++++++++++++++ .../onekey/nativelist/NativeListRowView.kt | 43 +++++-------- 2 files changed, 80 insertions(+), 27 deletions(-) create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt new file mode 100644 index 000000000..72bcf52e1 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt @@ -0,0 +1,64 @@ +package com.margelo.nitro.nativelist + +import android.graphics.drawable.Drawable +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams +import android.widget.TextView +import androidx.core.widget.TextViewCompat +import com.margelo.nitro.onekeyimage.OneKeyImageReusableView +import org.json.JSONObject + +// Stateless template binding. The legacy row host still owns allocation and reset +// during migration; asset and text primitives retain their existing lifecycle. +internal object NativeListMessageRenderer { + data class Views( + val column: LinearLayout, + val title: TextView, + val body: TextView, + val time: TextView, + val unread: View, + val thumbnail: OneKeyImageReusableView, + ) + + fun bind( + root: LinearLayout, + views: Views, + item: NativeListItem, + theme: JSONObject?, + dp: (Int) -> Int, + color: (JSONObject?, String, String) -> Int, + showText: (TextView, String, Int) -> Unit, + leading: (JSONObject, Int) -> Unit, + thumbnailBorder: (Int, Float) -> Drawable, + image: (JSONObject, OneKeyImageReusableView) -> Unit, + ) = with(views) { + root.gravity = Gravity.TOP + root.setPadding(dp(12), dp(16), dp(12), dp(16)) + item.json.optJSONObject("leading")?.let { leading(it, 28) } + unread.visibility = if (item.json.optBoolean("unread", false)) View.VISIBLE else View.GONE + root.addView(column, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + showText(title, item.json.optString("title"), 2) + showText(body, item.json.optString("body"), item.json.optInt("bodyLines", 3).coerceIn(1, 3)) + showText(time, item.json.optString("time"), 1) + TextViewCompat.setLineHeight(title, dp(20)) + TextViewCompat.setLineHeight(body, dp(20)) + TextViewCompat.setLineHeight(time, dp(16)) + body.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { topMargin = dp(2) } + time.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { topMargin = dp(4) } + time.setTextColor(color(theme, "disabledText", "#00000072")) + item.json.optJSONObject("thumbnail")?.let { source -> + thumbnail.visibility = View.VISIBLE + thumbnail.foreground = thumbnailBorder(color(theme, "strongBackground", "#0000000F"), 6f) + root.addView(thumbnail, LayoutParams(dp(64), dp(64)).apply { marginStart = dp(12) }) + image(source, thumbnail) + } + } + + fun applyTextStyles(views: Views, style: JSONObject, apply: (TextView, JSONObject) -> Unit) { + style.optJSONObject("title")?.let { apply(views.title, it) } + style.optJSONObject("body")?.let { apply(views.body, it) } + style.optJSONObject("time")?.let { apply(views.time, it) } + } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index 939af018f..c09f4ff1e 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -1240,6 +1240,10 @@ internal class NativeListRowView( } } if (item.type == "dataRow") tableDataColumns.forEach { it.applyStyle(style, ::applyStyledText) } + if (item.type == "message") { + NativeListMessageRenderer.applyTextStyles(messageViews, style, ::applyStyledText) + return + } val variant = item.json.optString("variant") if (item.type == "metricCard" && variant in setOf("activity", "performance")) { style.optJSONObject("title")?.let { titleStyle -> @@ -2341,34 +2345,19 @@ internal class NativeListRowView( } } + private val messageViews by lazy { + NativeListMessageRenderer.Views(mainColumn, title, subtitle, status, unreadDot, secondaryImage) + } + private fun bindMessage(item: NativeListItem, theme: JSONObject?) { - gravity = Gravity.TOP - setPadding(dp(12), dp(16), dp(12), dp(16)) - item.json.optJSONObject("leading")?.let { addLeading(it, 28) } - unreadDot.visibility = if (item.json.optBoolean("unread", false)) VISIBLE else GONE - addView(mainColumn, weighted()) - showText(title, item.json.optString("title"), 2) - showText(subtitle, item.json.optString("body"), item.json.optInt("bodyLines", 3).coerceIn(1, 3)) - showText(status, item.json.optString("time"), 1) - TextViewCompat.setLineHeight(title, dp(20)) - TextViewCompat.setLineHeight(subtitle, dp(20)) - TextViewCompat.setLineHeight(status, dp(16)) - subtitle.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { - topMargin = dp(2) - } - status.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { - topMargin = dp(4) - } - status.setTextColor(color(theme, "disabledText", "#00000072")) - item.json.optJSONObject("thumbnail")?.let { source -> - secondaryImage.visibility = VISIBLE - secondaryImage.foreground = roundedHairlineStroke( - color(theme, "strongBackground", "#0000000F"), - 6f, - ) - addView(secondaryImage, LayoutParams(dp(64), dp(64)).apply { marginStart = dp(12) }) - bindImage(source, secondaryImage, item.key, 0, "generic") - } + NativeListMessageRenderer.bind(this, messageViews, item, theme, + dp = ::dp, + color = ::color, + showText = ::showText, + leading = ::addLeading, + thumbnailBorder = ::roundedHairlineStroke, + image = { source, view -> bindImage(source, view, item.key, 0, "generic") }, + ) } private fun bindDataRow( From d77c5550cf436592495c948acc6349e9d97cb565 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 19:44:51 +0800 Subject: [PATCH 15/71] refactor(native-list): isolate iOS message binding and measurement --- .../ios/NativeListCell.swift | 56 ++++------ .../ios/NativeListMessageRenderer.swift | 102 ++++++++++++++++++ .../ios/RNCNativeListView.swift | 25 +---- 3 files changed, 127 insertions(+), 56 deletions(-) create mode 100644 native-views/react-native-native-list/ios/NativeListMessageRenderer.swift diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index 7e4382f8f..00bcada72 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -2232,43 +2232,25 @@ final class NativeListCell: UICollectionViewCell { } } + private var messageViews: NativeListMessageRenderer.Views { + NativeListMessageRenderer.Views( + root: rootStack, column: mainStack, title: titleLabel, body: subtitleLabel, + time: statusLabel, unread: unreadDot, thumbnail: secondaryImage, + top: rootTopConstraint, bottom: rootBottomConstraint, + leadingWidth: leadingWidth, leadingHeight: leadingHeight, + thumbnailWidth: secondaryWidth, thumbnailHeight: secondaryHeight + ) + } + private func bindMessage(_ item: NativeListItem, theme: [String: Any]?) { - rootStack.alignment = .top - rootTopConstraint.constant = 16 - rootBottomConstraint.constant = -16 - leadingWidth.constant = 28 - leadingHeight.constant = 28 - if let leading = item.data.dictionary("leading") { addLeading(leading, key: item.key) } - unreadDot.isHidden = !item.data.bool("unread") - rootStack.addArrangedSubview(mainStack) - show(titleLabel, item.data.string("title"), lines: 2) - titleLabel.font = nativeListFont(ofSize: 14, weight: .semibold) - setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20) - show( - subtitleLabel, - item.data.string("body"), - lines: min(3, max(1, item.data.int("bodyLines", default: 3))) + NativeListMessageRenderer.bind( + messageViews, item: item, theme: theme, + show: show, + lineHeight: { self.setLineHeight($0, text: $1, lineHeight: $2) }, + timeInset: { self.statusLabel.topInset = $0 }, + leading: { self.addLeading($0, key: item.key) }, + image: { self.bindImage($0, into: $1, token: item.key, slot: 0, variant: "generic") } ) - subtitleLabel.font = nativeListFont(ofSize: 14) - setLineHeight(subtitleLabel, text: item.data.string("body"), lineHeight: 20) - show(statusLabel, item.data.string("time"), lines: 1) - statusLabel.font = nativeListFont(ofSize: 12) - statusLabel.textColor = nativeListColor(theme, "disabledText", "#8D8D8D") - statusLabel.topInset = 2 - setLineHeight(statusLabel, text: item.data.string("time"), lineHeight: 16) - if let thumbnail = item.data.dictionary("thumbnail") { - rootStack.addArrangedSubview(secondaryImage) - secondaryWidth.constant = 64 - secondaryHeight.constant = 64 - secondaryWidth.isActive = true - secondaryHeight.isActive = true - secondaryImage.layer.borderWidth = 1 / UIScreen.main.scale - secondaryImage.layer.borderColor = UIColor( - nativeListHex: "#0000000F", - fallback: .lightGray - ).cgColor - bindImage(thumbnail, into: secondaryImage, token: item.key, slot: 0, variant: "generic") - } } private func bindDataRow( @@ -2526,6 +2508,10 @@ final class NativeListCell: UICollectionViewCell { if item.type == "dataRow" { tableDataColumns.forEach { $0.applyStyle(style, text: applyStyledText) } } + if item.type == "message" { + NativeListMessageRenderer.applyTextStyles(messageViews, style: style, apply: applyStyledText) + return + } let variant = item.data.string("variant") if item.type == "metricCard" && ["activity", "performance"].contains(variant) { // Composite cards render the heading, not the standard card's value slot. diff --git a/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift b/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift new file mode 100644 index 000000000..87260536a --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift @@ -0,0 +1,102 @@ +import UIKit +import OneKeyImage + +// Template-owned binding and semantic text targets. The existing cell owns +// allocation/reset until the row-host migration replaces the legacy view tree. +enum NativeListMessageRenderer { + struct Views { + let root: UIStackView + let column: UIStackView + let title: UILabel + let body: UILabel + let time: UILabel + let unread: UIView + let thumbnail: OneKeyImageReusableView + let top: NSLayoutConstraint + let bottom: NSLayoutConstraint + let leadingWidth: NSLayoutConstraint + let leadingHeight: NSLayoutConstraint + let thumbnailWidth: NSLayoutConstraint + let thumbnailHeight: NSLayoutConstraint + } + + static func bind( + _ views: Views, item: NativeListItem, theme: [String: Any]?, + show: (UILabel, String, Int) -> Void, + lineHeight: (UILabel, String, CGFloat) -> Void, + timeInset: (CGFloat) -> Void, + leading: ([String: Any]) -> Void, + image: ([String: Any], OneKeyImageReusableView) -> Void + ) { + views.root.alignment = .top + views.top.constant = 16 + views.bottom.constant = -16 + views.leadingWidth.constant = 28 + views.leadingHeight.constant = 28 + if let visual = item.data.dictionary("leading") { leading(visual) } + views.unread.isHidden = !item.data.bool("unread") + views.root.addArrangedSubview(views.column) + show(views.title, item.data.string("title"), 2) + views.title.font = nativeListFont(ofSize: 14, weight: .semibold) + lineHeight(views.title, item.data.string("title"), 20) + show(views.body, item.data.string("body"), min(3, max(1, item.data.int("bodyLines", default: 3)))) + views.body.font = nativeListFont(ofSize: 14) + lineHeight(views.body, item.data.string("body"), 20) + show(views.time, item.data.string("time"), 1) + views.time.font = nativeListFont(ofSize: 12) + views.time.textColor = nativeListColor(theme, "disabledText", "#8D8D8D") + timeInset(2) + lineHeight(views.time, item.data.string("time"), 16) + if let source = item.data.dictionary("thumbnail") { + views.root.addArrangedSubview(views.thumbnail) + views.thumbnailWidth.constant = 64 + views.thumbnailHeight.constant = 64 + views.thumbnailWidth.isActive = true + views.thumbnailHeight.isActive = true + views.thumbnail.layer.borderWidth = 1 / UIScreen.main.scale + views.thumbnail.layer.borderColor = UIColor(nativeListHex: "#0000000F", fallback: .lightGray).cgColor + image(source, views.thumbnail) + } + } + + static func applyTextStyles(_ views: Views, style: [String: Any], apply: (UILabel, [String: Any]) -> Void) { + if let text = style.dictionary("title") { apply(views.title, text) } + if let text = style.dictionary("body") { apply(views.body, text) } + if let text = style.dictionary("time") { apply(views.time, text) } + } + + private static func textWeight(_ style: [String: Any]?, fallback: NativeListFontWeight) -> NativeListFontWeight { + switch style?.string("fontWeight") { + case "regular": return .regular + case "medium": return .medium + case "semibold": return .semibold + case "bold": return .bold + default: return fallback + } + } + + static func measure(_ item: NativeListItem, availableWidth: CGFloat, contentPaddingHorizontal: CGFloat) -> CGFloat { + let style = item.data.dictionary("style") + let horizontalInsets = contentPaddingHorizontal * 2 + CGFloat(style?.double("horizontalPadding", default: 20) ?? 20) * 2 + let leadingWidth: CGFloat = item.data.dictionary("leading") == nil ? 0 : CGFloat(style?.dictionary("image")?.double("width", default: 28) ?? 28) + CGFloat(style?.double("leadingGap", default: 12) ?? 12) + let thumbnailWidth: CGFloat = item.data.dictionary("thumbnail") == nil ? 0 : 76 + let textWidth = max(1, availableWidth - horizontalInsets - leadingWidth - thumbnailWidth) + func textHeight(_ key: String, lines: Int, weight: NativeListFontWeight) -> CGFloat { + let textStyle = style?.dictionary(key) + let lineHeight = CGFloat(textStyle?.double("lineHeight", default: 20) ?? 20) + let size = CGFloat(textStyle?.double("fontSize", default: 14) ?? 14) + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = lineHeight + paragraph.maximumLineHeight = lineHeight + let bounds = (item.data.string(key) as NSString).boundingRect(with: CGSize(width: textWidth, height: .greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: nativeListFont(ofSize: size, weight: textWeight(textStyle, fallback: weight)), .paragraphStyle: paragraph], context: nil) + let maximumLines = min(3, max(1, textStyle?.int("lines", default: lines) ?? lines)) + return CGFloat(min(maximumLines, max(1, Int(ceil(bounds.height / lineHeight))))) * lineHeight + } + return CGFloat(style?.double("verticalPadding", default: 16) ?? 16) * 2 + + textHeight("title", lines: 2, weight: .semibold) + + textHeight("body", lines: item.data.int("bodyLines", default: 3), weight: .regular) + + textHeight("time", lines: 1, weight: .regular) + + CGFloat(style?.double("lineGap", default: 1) ?? 1) * 2 + } + +} diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index c49b462ef..ae2fb4712 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -1814,27 +1814,10 @@ final class NativeListView: UIView { } private func messageHeight(_ item: NativeListItem) -> CGFloat { - let style = item.data.dictionary("style") - let horizontalInsets = (config?.contentPaddingHorizontal ?? 0) * 2 + CGFloat(style?.double("horizontalPadding", default: 20) ?? 20) * 2 - let leadingWidth: CGFloat = item.data.dictionary("leading") == nil ? 0 : CGFloat(style?.dictionary("image")?.double("width", default: 28) ?? 28) + CGFloat(style?.double("leadingGap", default: 12) ?? 12) - let thumbnailWidth: CGFloat = item.data.dictionary("thumbnail") == nil ? 0 : 76 - let textWidth = max(1, collectionView.bounds.width - horizontalInsets - leadingWidth - thumbnailWidth) - func textHeight(_ key: String, lines: Int, weight: NativeListFontWeight) -> CGFloat { - let textStyle = style?.dictionary(key) - let lineHeight = CGFloat(textStyle?.double("lineHeight", default: 20) ?? 20) - let size = CGFloat(textStyle?.double("fontSize", default: 14) ?? 14) - let paragraph = NSMutableParagraphStyle() - paragraph.minimumLineHeight = lineHeight - paragraph.maximumLineHeight = lineHeight - let bounds = (item.data.string(key) as NSString).boundingRect(with: CGSize(width: textWidth, height: .greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: nativeListFont(ofSize: size, weight: measuredTextWeight(textStyle, fallback: weight)), .paragraphStyle: paragraph], context: nil) - let maximumLines = min(3, max(1, textStyle?.int("lines", default: lines) ?? lines)) - return CGFloat(min(maximumLines, max(1, Int(ceil(bounds.height / lineHeight))))) * lineHeight - } - return CGFloat(style?.double("verticalPadding", default: 16) ?? 16) * 2 - + textHeight("title", lines: 2, weight: .semibold) - + textHeight("body", lines: item.data.int("bodyLines", default: 3), weight: .regular) - + textHeight("time", lines: 1, weight: .regular) - + CGFloat(style?.double("lineGap", default: 1) ?? 1) * 2 + NativeListMessageRenderer.measure( + item, availableWidth: collectionView.bounds.width, + contentPaddingHorizontal: config?.contentPaddingHorizontal ?? 0 + ) } private func emit(_ block: ((String) -> Void)?, _ value: [String: Any]) { From 929a100a46a8b089cc6cad86d642d302f68dea2c Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 19:56:17 +0800 Subject: [PATCH 16/71] docs(native-list): record message migration and runtime acceptance --- .../react-native-native-list/docs/DESIGN.md | 71 +++++++++++++++++++ .../docs/STYLE_SPEC.md | 7 ++ 2 files changed, 78 insertions(+) diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index e4901a5c1..df306ea92 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -62,6 +62,77 @@ container even when the displayed content uses an existing row template. selection reconciliation, and scrolling. Nitro callback props carry bounded JSON payloads which the wrapper decodes into strict public event types. +### Incremental renderer migration + +Template dispatch is a closed-set lookup, not a state machine. The target +ownership is container → template registry → renderer → shared primitives: + +- The container owns data, placement, sections, header/footer, scrolling, + indexed bar, selection and reorder. +- A renderer owns its view structure, template defaults, semantic text/image + targets, measurement, binding and supported partial updates. +- A row host owns shared container appearance, events and binding invalidation. + Image/text primitives do not branch on business keys or template names. +- `row.key` identifies data. A structural reuse key identifies a compatible + renderer/view tree; style values and item keys must not enter that key. +- Resolved style must come from template defaults, theme, model and explicit + style, never from the previous bound view. Rendering and measurement consume + the same resolved inputs. Height precedence remains `style.container.height` + → `row.height` → template measurement/default. There is no automatic fit pass. +- Full binding must be correct without a preceding recycle callback. Image + descriptors that did not change should retain their bindings. Async work and + action anchors must still be invalidated by the host's binding epoch. + +Migration begins with `message`, one platform per commit. Its current boundary: + +| Platform | Extracted | Still owned by the existing host | +| --- | --- | --- | +| Web | DOM structure, semantic text roles, estimated measurement in `src/web/templates/MessageRowRenderer.ts` | Shared style/asset primitives; wrapper pooling and body replacement | +| Android | Content binding and direct title/body/time style targets in `NativeListMessageRenderer.kt` | View allocation, default typography in `applySize`, box styles, measurement and reset | +| iOS | Content binding, direct title/body/time style targets and existing measured height in `NativeListMessageRenderer.swift` | View allocation, box styles, reset and image lifecycle | + +This first extraction deliberately keeps existing platform defaults and the +native monolithic hosts. It does **not** establish a new registry, structural +reuse pool, renderer-owned view allocation or complete resolved-style pipeline. +The callback parameters are temporary adapters to the existing image/text +primitives, not a public plugin API. Shared legacy slot maps still serve +unmigrated templates and will be retired as ownership moves. + +The next Message step replaces those temporary adapters with shared primitives +and a renderer-owned view tree, then connects structural reuse and update +classification. On iOS, a template change must reload a cell rather than merely +reconfigure a cell from an incompatible reuse class. On Android, the same change +must select a different `getItemViewType`; ordinary style changes keep the same +type. Preserve Market quote and selection update paths throughout migration. +Move the remaining simple templates only after this lifecycle works on all +three platforms; migrate Market/Identity/Header and composed wallet groups last. + +Acceptance for every step includes style set/change/clear, binding A → B without +recycle, same-key template changes, scrolling away/back, image add/remove, +explicit height restoration and shared footer placement. Keep geometry and +typography changes in separate commits from structural extraction. + +The first extraction was checked on 2026-09-22: + +- Web: 149 tests, typecheck and lint (zero errors); actual Chrome desktop and + 390px RTL rendering, rapid style/template switching, image decoding and + scrolling through 41 messages. Styled height 192 returns to model height 136. +- Android: Kotlin build and seven unit tests; an isolated RN app using the real + NativeList/Image/Logger/Skeleton/Nitro packages ran on API 36. Style clearing, + same-key Message/Identity switching, content updates, image add/remove, + scroll-to-end/back and the fixed footer were checked. At the test device's + density, legacy model height 136 rendered as 122px through the existing scale; + explicit style height rendered as 192px and clearing restored 122px. The + styled three-line body measured 66px; clearing restored its one-line box. +- iOS: models/assets/cell/Message renderer typechecked against the simulator + UIKit SDK with a OneKeyImage interface test double; the list view parsed. + Full app compilation/linking and rendered acceptance remain unverified. + Creation of the required external-drive simulator remained stuck in the + creation state; no existing simulator owned by another task was reused. + +These results cover the Message extraction, not native acceptance of every +template or completion of the target architecture. + `linear`, `sectioned`, `grid`, and `table` are observable native container semantics. Linear is a full-width stream. Sectioned adds a visual break before section-header rows and can pin those rows. Grid allocates native spans and diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index fff6296a0..379148d35 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -828,6 +828,13 @@ state before the normal template reset. This includes text metrics, color and alignment; iOS also restores attributed text. This avoids guessing one baseline for views shared by different templates. Device reuse checks remain required. +Message is the first incremental renderer extraction: its native title/body/time +styles now target the renderer's typed view bundle directly, while box styles +and restoration still run through the existing host. Web Message owns its DOM +structure and measurement in a separate module. See +[DESIGN.md](DESIGN.md#incremental-renderer-migration) for the current ownership, +remaining migration steps and acceptance scope. + The existing market helpers generalize rather than being rewritten: `applyMarketTextStyle` / `applyMarketButtonStyle` / `marketAttributedText` (iOS), `applyMarketTextStyle` / `applyMarketTextMetrics` (Android), From 24af96884facac89e5e31e690a00e9d7dca7ebab Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 20:18:44 +0800 Subject: [PATCH 17/71] docs(native-list): record iOS simulator runtime acceptance --- .../react-native-native-list/docs/DESIGN.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index df306ea92..b05a963bd 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -124,11 +124,18 @@ The first extraction was checked on 2026-09-22: density, legacy model height 136 rendered as 122px through the existing scale; explicit style height rendered as 192px and clearing restored 122px. The styled three-line body measured 66px; clearing restored its one-line box. -- iOS: models/assets/cell/Message renderer typechecked against the simulator - UIKit SDK with a OneKeyImage interface test double; the list view parsed. - Full app compilation/linking and rendered acceptance remain unverified. - Creation of the required external-drive simulator remained stuck in the - creation state; no existing simulator owned by another task was reused. +- iOS: the isolated RN app compiled and linked with the real + NativeList/Image/Logger/Skeleton/Nitro packages, then ran on a dedicated + iPhone 17 Pro simulator with iOS 26.5. Its data volume is an APFS sparsebundle + stored on the external drive and mounted at the simulator's standard data + directory. Style set/clear restored row height 136 → 192 → 136pt and body + height 20 → 66 → 20pt. Same-key Message/Identity switching, content updates, + leading image/thumbnail add/remove and scrolling to row 39/back passed; + fixed-footer placement stayed unchanged. Screenshots, accessibility trees + and a screen recording were retained. The build used the example app's iOS + 16.4 deployment target for all pods because Xcode 27 rejects older pod targets. + Sampled gallery scrolling and style off/on checks also covered Message + appearance reset and top/center/bottom container alignment with explicit height. These results cover the Message extraction, not native acceptance of every template or completion of the target architecture. From 7652d6d7661e16f81da435bce9a250e759bf8144 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 20:26:32 +0800 Subject: [PATCH 18/71] refactor(native-list): partition web Message row reuse --- .../src/__tests__/NativeList.web.test.ts | 65 +++++++++++++++++++ .../src/web/NativeListWebEngine.ts | 36 ++++++++-- 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index 204383983..f8ae08642 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -996,7 +996,10 @@ describe('web row style', () => { ); expect(item().querySelector('[data-nl-slot="body"]')).toBeNull(); expect(item().querySelector('[data-nl-slot="time"]')).toBeNull(); + expect(item()).not.toBe(wrapper); + expect(wrapper.isConnected).toBe(false); engine.applySnapshot(snapshot({ kind: 'sectioned' }, [message])); + expect(item()).toBe(wrapper); expect(item().style.height).toBe('136px'); expect(body().textContent).toBe('One Two Three'); } finally { @@ -1004,6 +1007,68 @@ describe('web row style', () => { } }); + it('reuses only compatible row hosts after removal, insertion and index changes', () => { + const { document } = new JSDOM('').window; + const host = document.createElement('div'); + document.body.appendChild(host); + const message = { + type: 'message', + key: 'old-message', + title: 'Old', + body: 'Old body', + time: 'Now', + style: { container: { height: 192 }, body: { color: '#FF0000' } }, + } as const; + const identity = { + type: 'identity', + key: 'identity', + title: 'Identity', + leading: { kind: 'icon', name: 'StarOutline' }, + } as const; + const engine = new NativeListWebEngine( + host, + snapshot({ kind: 'sectioned' }, [message, identity]), + {}, + false + ); + const item = (key: string) => + host.querySelector(`[data-native-list-row-key="${key}"]`)!; + try { + const messageHost = item(message.key); + const identityHost = item(identity.key); + engine.applySnapshot(snapshot({ kind: 'sectioned' }, [])); + expect(messageHost.isConnected).toBe(false); + expect(identityHost.isConnected).toBe(false); + const replacement = { + type: 'message', + key: 'new-message', + title: 'New', + body: 'Fresh', + time: 'Later', + height: 136, + } as const; + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [identity, replacement]) + ); + expect(item(identity.key)).toBe(identityHost); + expect(item(replacement.key)).toBe(messageHost); + expect(messageHost.style.height).toBe('136px'); + expect( + messageHost.querySelector('[data-nl-slot="body"]')!.style + .color + ).toBe(''); + expect(messageHost.textContent).toContain('Fresh'); + expect(messageHost.textContent).not.toContain('Old body'); + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [replacement, identity]) + ); + expect(item(identity.key)).toBe(identityHost); + expect(item(replacement.key)).toBe(messageHost); + } finally { + engine.destroy(); + } + }); + it('preserves the value-pair break inside a shared multi-line budget', () => { const base: IdentityRow = { type: 'identity', diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index b0698cda2..1a442e418 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -4095,6 +4095,14 @@ export function createRowBody( } } +// Only Message has begun renderer migration. The other templates still share +// the legacy view tree; data keys and style values never partition reuse. +type RowRendererKey = 'legacy' | 'message'; + +function rowRendererKey(row: RowModel): RowRendererKey { + return row.type === 'message' ? 'message' : 'legacy'; +} + export class NativeListWebEngine { private readonly document: Document; private readonly root: HTMLElement; @@ -4119,7 +4127,11 @@ export class NativeListWebEngine { horizontal: false, }; private readonly mounted = new Map(); - private readonly pool: HTMLElement[] = []; + private readonly pool: Record = { + legacy: [], + message: [], + }; + private readonly rendererKeys = new WeakMap(); private frameHandle: number | undefined; private resizeObserver: ResizeObserver | undefined; private sectionIndexTransformObserver: MutationObserver | undefined; @@ -4561,14 +4573,18 @@ export class NativeListWebEngine { this.viewport.removeEventListener('pointercancel', this.handlePullEnd); const host = this.root.parentElement; disposeWebImageRetries(this.root); - this.pool.forEach(disposeWebImageRetries); + Object.values(this.pool).forEach((elements) => + elements.forEach(disposeWebImageRetries) + ); this.root.remove(); this.indexRail.remove(); this.hideReorderPreview(); this.reorderPreview.remove(); if (host) host.style.position = this.previousHostPosition; this.mounted.clear(); - this.pool.length = 0; + Object.values(this.pool).forEach((elements) => { + elements.length = 0; + }); this.avatarLeases.forEach((release) => release()); this.avatarLeases.clear(); } @@ -4781,13 +4797,19 @@ export class NativeListWebEngine { ); const desired = new Set(visible.map((item) => item.index)); this.mounted.forEach((element, index) => { - if (!desired.has(index)) { + const nextRow = this.rows[index]; + const rendererKey = this.rendererKeys.get(element); + if ( + !desired.has(index) || + !nextRow || + rendererKey !== rowRendererKey(nextRow) + ) { this.invalidateActionAnchorForElement(element); this.mounted.delete(index); if (disposeWebImageRetries(element)) element.removeAttribute('data-render-signature'); element.remove(); - this.pool.push(element); + if (rendererKey) this.pool[rendererKey].push(element); } }); @@ -4796,9 +4818,11 @@ export class NativeListWebEngine { if (!row) return; let element = this.mounted.get(layoutItem.index); if (!element) { + const rendererKey = rowRendererKey(row); element = - this.pool.pop() ?? + this.pool[rendererKey].pop() ?? createElement(this.document, 'div', 'ok-native-list-item'); + this.rendererKeys.set(element, rendererKey); setData(element, 'nativeListAnimateReorder', false); this.mounted.set(layoutItem.index, element); this.content.appendChild(element); From 08934344338cddb6802b604d6ce87cb5bcf5f16a Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 20:28:50 +0800 Subject: [PATCH 19/71] refactor(native-list): route Android Message through its own view type --- .../main/java/com/onekey/nativelist/NativeListAdapter.kt | 5 ++++- .../src/main/java/com/onekey/nativelist/NativeListModels.kt | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt index 51cfade4f..516d87668 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt @@ -69,6 +69,9 @@ internal class NativeListAdapter( override fun getItemCount(): Int = reorderItems?.size ?: differ.currentList.size + override fun getItemViewType(position: Int): Int = + requireNotNull(itemAt(position)).rendererKey.ordinal + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NativeListViewHolder { val view = NativeListRowView(context) view.layoutParams = ViewGroup.LayoutParams( @@ -216,7 +219,7 @@ internal class NativeListAdapter( companion object { private val DIFF = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: NativeListItem, newItem: NativeListItem): Boolean = - oldItem.key == newItem.key && oldItem.type == newItem.type + oldItem.key == newItem.key override fun areContentsTheSame(oldItem: NativeListItem, newItem: NativeListItem): Boolean = oldItem.revision == newItem.revision && oldItem.content == newItem.content diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index e0136a091..d955894d6 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -119,6 +119,10 @@ private fun updateLengthPrefixed(digest: MessageDigest, value: String) { digest.update(bytes) } +// Message is the first migrated renderer. All remaining templates still share +// a compatible legacy tree; keys and style values never partition the pool. +internal enum class NativeListRendererKey { LEGACY, MESSAGE } + internal data class NativeListItem( val key: String, val type: String, @@ -127,6 +131,8 @@ internal data class NativeListItem( val json: JSONObject, ) { val content: String = json.toString() + val rendererKey: NativeListRendererKey + get() = if (type == "message") NativeListRendererKey.MESSAGE else NativeListRendererKey.LEGACY val styledHeight: Double? get() = json.optJSONObject("style")?.optJSONObject("container") ?.takeIf { it.has("height") }?.optDouble("height") From f821913a453bb910fedc21d877b1ba801e564ed5 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 20:31:53 +0800 Subject: [PATCH 20/71] refactor(native-list): reload iOS rows when renderer reuse changes --- .../ios/NativeListCell.swift | 4 +++- .../ios/NativeListModels.swift | 10 ++++++++++ .../ios/RNCNativeListView.swift | 14 +++++++++++--- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index 00bcada72..442990dd1 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -496,7 +496,9 @@ private final class NativeListTableColumnView: UIStackView { } final class NativeListCell: UICollectionViewCell { - static let reuseIdentifier = "NativeListCell" + static func reuseIdentifier(for renderer: NativeListRendererKey) -> String { + "NativeListCell.\(renderer.rawValue)" + } private let rootStack = UIStackView() private let mediaVisualWrapper = UIView() diff --git a/native-views/react-native-native-list/ios/NativeListModels.swift b/native-views/react-native-native-list/ios/NativeListModels.swift index b120f309a..fce2ad0b2 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -1,6 +1,12 @@ import Foundation import UIKit +// Message is the first migrated renderer. Other templates still use the +// compatible legacy tree; style, content and row keys do not affect reuse. +enum NativeListRendererKey: String, CaseIterable { + case legacy, message +} + struct NativeListItem { let key: String let type: String @@ -9,6 +15,10 @@ struct NativeListItem { let data: [String: Any] let content: String + var rendererKey: NativeListRendererKey { + type == "message" ? .message : .legacy + } + var styledHeight: CGFloat? { (data.dictionary("style")?.dictionary("container")?["height"] as? Double).map { CGFloat($0) } } diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index ae2fb4712..b47282888 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -129,7 +129,9 @@ final class NativeListView: UIView { override init(frame: CGRect) { super.init(frame: frame) - collectionView.register(NativeListCell.self, forCellWithReuseIdentifier: NativeListCell.reuseIdentifier) + for renderer in NativeListRendererKey.allCases { + collectionView.register(NativeListCell.self, forCellWithReuseIdentifier: NativeListCell.reuseIdentifier(for: renderer)) + } collectionView.backgroundColor = .clear collectionView.delegate = self collectionView.dragDelegate = self @@ -228,7 +230,7 @@ final class NativeListView: UIView { guard let self, let item = self.itemsByKey[key], let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: NativeListCell.reuseIdentifier, + withReuseIdentifier: NativeListCell.reuseIdentifier(for: item.rendererKey), for: indexPath ) as? NativeListCell else { return nil } cell.onAction = { [weak self] item, action, target, origin in @@ -374,7 +376,13 @@ final class NativeListView: UIView { }) changedKeys.formUnion(deferredReorderReconfigureKeys) deferredReorderReconfigureKeys.removeAll() - snapshot.reconfigureItems(changedKeys.filter { itemsByKey[$0] != nil }) + let retainedKeys = Set(dataSource.snapshot().itemIdentifiers).intersection(keys) + let changedRetainedKeys = changedKeys.filter { retainedKeys.contains($0) } + // Reconfigure must keep its existing reuse identifier. A same-key renderer + // change needs reload so UIKit dequeues from the new structural pool. + let replacedKeys = changedRetainedKeys.filter { oldItems[$0]?.rendererKey != itemsByKey[$0]?.rendererKey } + snapshot.reloadItems(Array(replacedKeys)) + snapshot.reconfigureItems(Array(changedRetainedKeys.subtracting(replacedKeys))) dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in guard let self else { return } self.collectionView.layoutIfNeeded() From 6a94b01d1fd3fea0da87e927a632627f49192eec Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 20:35:12 +0800 Subject: [PATCH 21/71] docs(native-list): record structural reuse boundaries and acceptance --- .../react-native-native-list/docs/DESIGN.md | 39 ++++++++++++++----- .../docs/STYLE_SPEC.md | 11 ++++-- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index b05a963bd..9782ef809 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -91,19 +91,31 @@ Migration begins with `message`, one platform per commit. Its current boundary: | Android | Content binding and direct title/body/time style targets in `NativeListMessageRenderer.kt` | View allocation, default typography in `applySize`, box styles, measurement and reset | | iOS | Content binding, direct title/body/time style targets and existing measured height in `NativeListMessageRenderer.swift` | View allocation, box styles, reset and image lifecycle | -This first extraction deliberately keeps existing platform defaults and the -native monolithic hosts. It does **not** establish a new registry, structural -reuse pool, renderer-owned view allocation or complete resolved-style pipeline. +The migration still keeps existing platform defaults and the native monolithic +hosts. It does **not** yet establish a renderer registry, renderer-owned native +view allocation or complete resolved-style pipeline. The callback parameters are temporary adapters to the existing image/text primitives, not a public plugin API. Shared legacy slot maps still serve unmigrated templates and will be retired as ownership moves. -The next Message step replaces those temporary adapters with shared primitives -and a renderer-owned view tree, then connects structural reuse and update -classification. On iOS, a template change must reload a cell rather than merely -reconfigure a cell from an incompatible reuse class. On Android, the same change -must select a different `getItemViewType`; ordinary style changes keep the same -type. Preserve Market quote and selection update paths throughout migration. +Structural reuse is now partitioned into two internal families on all platforms: +`message` and `legacy`. Unmigrated templates retain their existing shared tree. +Neither `row.key`, content, style, height nor placement changes the family. +Web selects a compatible wrapper pool even when the row at a mounted index +changes family. iOS registers separate reuse identifiers and reloads retained +keys whose family changed; compatible updates still reconfigure, and newly +inserted keys are not marked for reload/reconfigure. Android uses the family as +`getItemViewType`; DiffUtil identifies data by `row.key`, allowing RecyclerView +to replace an incompatible holder. Market quote and selection payloads retain +their existing full/partial update decisions. Footer and nested-row hosts remain +outside these scrolling pools. + +This is a reuse boundary, not a separate native view implementation: both native +families still allocate the legacy cell/row class. The next Message step replaces +the temporary adapters with shared primitives and a renderer-owned view tree, +connects its factory to the existing reuse family, then moves update +classification into the renderer. Preserve Market quote and selection update +paths throughout migration. Move the remaining simple templates only after this lifecycle works on all three platforms; migrate Market/Identity/Header and composed wallet groups last. @@ -140,6 +152,15 @@ The first extraction was checked on 2026-09-22: These results cover the Message extraction, not native acceptance of every template or completion of the target architecture. +The structural reuse follow-up on 2026-09-22 passed 150 package tests, typecheck, +lint (zero errors), the Android build/seven unit tests and a full iOS build. +Chrome, Android API 36 and iOS 26.5 ran 41 alternating Message/Identity rows, +swapped every template while retaining keys, emptied/repopulated the list and +scrolled end/back. Style reset, image add/remove and fixed-footer placement were +checked again. Web additionally verifies wrapper identity in regression tests; +runtime checks included ten Web and five iOS mixed-template switching cycles. +No browser page errors, Android crashes or iOS runtime exceptions were observed. + `linear`, `sectioned`, `grid`, and `table` are observable native container semantics. Linear is a full-width stream. Sectioned adds a visual break before section-header rows and can pin those rows. Grid allocates native spans and diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index 379148d35..383495b27 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -199,7 +199,7 @@ Radius steps in use: `4` (chip), `8` (rail, small visual), `10` (visual `rounded ## 4. T2 — Template style surface `style.X` modifies the rendering of the **model field named `X`**, regardless of -which physical view carries it. This matters because the view pool is shared: on +which physical view carries it. This matters because legacy view slots are shared: on every platform `metricCard` renders its *value* through the title label and its *title* through the subtitle label, and the status label carries `rail.status`, `activity.status`, `message.time`, and `metricCard.trend`. Naming style keys after @@ -746,8 +746,8 @@ this change is not a wholesale redesign of those defaults. ## 7. Isolation rules -The view pool is shared across templates, so a styled row must not be able to -affect any other row. Four rules: +Message has its own reuse family; unmigrated templates share the legacy pool. +A styled row must not affect another row in either family. Four rules: 1. **Style types are per template.** `IdentityRowStyle` carries only identity's fields; `MetricCardRowStyle` only metricCard's. A key that the row type does not @@ -831,7 +831,10 @@ for views shared by different templates. Device reuse checks remain required. Message is the first incremental renderer extraction: its native title/body/time styles now target the renderer's typed view bundle directly, while box styles and restoration still run through the existing host. Web Message owns its DOM -structure and measurement in a separate module. See +structure and measurement in a separate module. All three scrolling hosts now +partition Message from legacy reuse; style changes keep the same family, while +cross-family template changes replace the host. Native view allocation remains +in the existing classes. See [DESIGN.md](DESIGN.md#incremental-renderer-migration) for the current ownership, remaining migration steps and acceptance scope. From 8f5005567846f8dfc3e9467fa6371517eed5272c Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 20:50:22 +0800 Subject: [PATCH 22/71] docs(native-list): track the six-stage renderer migration contract --- .../react-native-native-list/README.md | 3 +- .../react-native-native-list/docs/SPEC.md | 95 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 native-views/react-native-native-list/docs/SPEC.md diff --git a/native-views/react-native-native-list/README.md b/native-views/react-native-native-list/README.md index 8c9da9435..8a7425265 100644 --- a/native-views/react-native-native-list/README.md +++ b/native-views/react-native-native-list/README.md @@ -402,7 +402,8 @@ use Instruments or Android Studio Profiler for native frame and memory data. the React component. It mirrors the mobile data and imperative APIs, but its performance must still be measured independently from iOS and Android. -See [docs/DESIGN.md](docs/DESIGN.md) for the architecture and ownership model, and +Start with [docs/SPEC.md](docs/SPEC.md) for the behavioral contract and migration +status. See [docs/DESIGN.md](docs/DESIGN.md) for the architecture and ownership model, and [docs/STYLE_SPEC.md](docs/STYLE_SPEC.md) for the shared style vocabulary: design tokens, local layout-safety rules, common list capabilities and current implementation gaps. [docs/ROW_TEMPLATES.md](docs/ROW_TEMPLATES.md) defines all 12 row types with diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md new file mode 100644 index 000000000..ebe25aaac --- /dev/null +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -0,0 +1,95 @@ +# NativeList contract + +This is the entry point for the package's behavioral contract. The linked +documents are part of this spec; implementation status is separate from target +architecture and runtime evidence. + +## Purpose and non-goals + +NativeList renders bounded data with fixed row templates on iOS, Android and +Web. It does not render arbitrary React children. Integrators own overflow +prevention; there is no automatic fitting or shrinking pass. + +## Definitions and ownership + +- `rowTemplate` defines structure, semantic fields and supported variants. + [ROW_TEMPLATES.md](ROW_TEMPLATES.md) defines and illustrates every template. +- `rowStyle` adjusts the template's named text, image, spacing and container + properties. It can set row height but cannot replace the template structure. + [STYLE_SPEC.md](STYLE_SPEC.md) defines the common property vocabulary. +- The list owns placement, sections, scrolling, indexed bar, selection and + reorder. These capabilities do not belong to individual row templates. +- The renderer owns template views, defaults, binding and measurement. A shared + host owns row appearance, events and binding invalidation. This ownership is a + migration target, not a claim that the current native hosts are already split. + +## Public API and defaults + +[README.md](../README.md) documents the snapshot, patch, selection, events and +imperative API; [STYLE_SPEC.md](STYLE_SPEC.md) defines normalization, omitted +style defaults and platform differences. Height precedence is +`style.container.height` → `row.height` → template measurement/default. +Removing a style restores the current template's defaults, not the previous +row's appearance. This migration does not change the TypeScript/Nitro protocol. + +## Lifecycle and concurrency + +Full binding must work without a preceding recycle callback, including +style set/change/clear and content A → B. A binding epoch invalidates stale +image callbacks and action anchors. Recycling/disposal releases pending work +through the existing image/host lifecycle. UI mutation remains on the platform +UI thread; [DESIGN.md](DESIGN.md) describes update and event ownership. + +## Data, cache and identity + +`row.key` identifies data; renderer reuse identity describes compatible view +structure. Content, styles, height and placement must not enter reuse identity. +The implemented families are `message` and `legacy`; the latter serves all +unmigrated templates. Same-key family changes must replace the incompatible +host. OneKeyImage owns image caches; NativeList owns row/slot binding identity. + +## Platform contract + +All platforms expose the same bounded style properties and public semantics. +Existing geometry/typography differences are recorded in +[STYLE_SPEC.md](STYLE_SPEC.md); structural extraction must not silently change +them. iOS uses UICollectionView, Android RecyclerView, and Web a windowed DOM +engine. See [DESIGN.md](DESIGN.md) for their current ownership boundaries. + +## Failures, fallback and limits + +Snapshot validation rejects malformed/duplicate-key input without partial +application. Template field/array bounds and image fallback behavior are defined +in [ROW_TEMPLATES.md](ROW_TEMPLATES.md), [README.md](../README.md) and +[DESIGN.md](DESIGN.md). Existing normalization remains authoritative; this +migration adds no new input limits, retry policy or automatic layout correction. + +## Performance and resources + +Snapshots and patches cross the bridge in batches. Visible rows use platform +recycling/windowing; shared primitives own image cancellation. The Message +pilot still allocates the legacy native host, so renderer extraction alone is +not evidence of lower allocations or faster scrolling. Performance claims need +separate workload and measurements. + +## Conformance and six-stage migration + +| Stage | Current status | Exit condition | +| --- | --- | --- | +| 1. Baseline | Partial: representative Message/runtime cases exist; implicit key/default/update inventory is incomplete | Preserve each existing rule or explicitly migrate its caller | +| 2. Message pilot | In progress: three-platform binding extraction and structural reuse families implemented | Renderer-owned lightweight views, resolved styling/measurement, update classification and lifecycle acceptance | +| 3. Simple templates | Not started | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | +| 4. Complex templates | Not started | Migrate Market, Identity and SectionHeader while preserving quote, selection and header behavior | +| 5. WalletGroup | Not started | Independent member renderers; verify member events/styles, compact dragging and height restoration | +| 6. Cleanup | Not started | Remove migrated key inference and global style maps; container no longer manipulates template internals | + +The next Message increment moves its text subtree and typography into the +renderer. Shared leading/thumbnail image slots and generic host lifecycle stay +in place until their primitives are extracted. The legacy host remains allocated. + +Source boundaries and dated runtime evidence are in the incremental migration +section of [DESIGN.md](DESIGN.md). Acceptance covers style set/change/clear, +binding without recycle, same-key template changes, image add/remove, scrolling +away/back, explicit height restoration and fixed-footer placement. Unit/type +checks are not native runtime verification, and Message acceptance does not +complete the remaining stages. From 67e2c01d8ef6c03e15a45a688260e282654e19ad Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 20:59:13 +0800 Subject: [PATCH 23/71] refactor(native-list): let iOS Message own its text subtree --- .../ios/NativeListCell.swift | 61 +++------------- .../ios/NativeListMessageRenderer.swift | 72 +++++++++++++++---- .../ios/NativeListTextLabel.swift | 43 +++++++++++ 3 files changed, 110 insertions(+), 66 deletions(-) create mode 100644 native-views/react-native-native-list/ios/NativeListTextLabel.swift diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index 442990dd1..4a325cf48 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -193,47 +193,6 @@ private final class NativeListAccessoryButton: UIButton { } } -private class NativeListTextLabel: UILabel { - var rowVerticalAlignment: String? { didSet { setNeedsDisplay() } } - var rowOffsetY: CGFloat = 0 { didSet { setNeedsDisplay() } } - - override func drawText(in rect: CGRect) { - var target = rect - if let alignment = rowVerticalAlignment { - let height = super.textRect(forBounds: rect, limitedToNumberOfLines: numberOfLines).height - target.origin.y += alignment == "top" ? 0 : alignment == "bottom" ? rect.height - height : (rect.height - height) / 2 - target.size.height = height - } - target.origin.y += rowOffsetY - super.drawText(in: target) - } -} - -private final class NativeListInsetLabel: NativeListTextLabel { - var horizontalInset: CGFloat = 0 - var topInset: CGFloat = 0 - var bottomInset: CGFloat = 0 - - override var intrinsicContentSize: CGSize { - let size = super.intrinsicContentSize - return CGSize( - width: size.width + horizontalInset * 2, - height: size.height + topInset + bottomInset - ) - } - - override func drawText(in rect: CGRect) { - super.drawText(in: rect.inset( - by: UIEdgeInsets( - top: topInset, - left: horizontalInset, - bottom: bottomInset, - right: horizontalInset - ) - )) - } -} - private final class NativeListDottedUnderlineLabel: NativeListTextLabel { var showsDottedUnderline = false { didSet { setNeedsLayout() } @@ -2234,22 +2193,18 @@ final class NativeListCell: UICollectionViewCell { } } - private var messageViews: NativeListMessageRenderer.Views { - NativeListMessageRenderer.Views( - root: rootStack, column: mainStack, title: titleLabel, body: subtitleLabel, - time: statusLabel, unread: unreadDot, thumbnail: secondaryImage, - top: rootTopConstraint, bottom: rootBottomConstraint, - leadingWidth: leadingWidth, leadingHeight: leadingHeight, - thumbnailWidth: secondaryWidth, thumbnailHeight: secondaryHeight - ) - } + private lazy var messageViews = NativeListMessageRenderer.Views( + root: rootStack, unread: unreadDot, thumbnail: secondaryImage, + top: rootTopConstraint, bottom: rootBottomConstraint, + leadingWidth: leadingWidth, leadingHeight: leadingHeight, + thumbnailWidth: secondaryWidth, thumbnailHeight: secondaryHeight + ) private func bindMessage(_ item: NativeListItem, theme: [String: Any]?) { NativeListMessageRenderer.bind( messageViews, item: item, theme: theme, show: show, lineHeight: { self.setLineHeight($0, text: $1, lineHeight: $2) }, - timeInset: { self.statusLabel.topInset = $0 }, leading: { self.addLeading($0, key: item.key) }, image: { self.bindImage($0, into: $1, token: item.key, slot: 0, variant: "generic") } ) @@ -2448,9 +2403,9 @@ final class NativeListCell: UICollectionViewCell { } if style["lineGap"] != nil, item.type != "walletGroup", item.type != "dataRow" { let gap = CGFloat(style.double("lineGap")) - styleGap(mainStack, gap) + styleGap(item.type == "message" ? messageViews.column : mainStack, gap) if item.type == "message" { - let label = statusLabel + let label = messageViews.time let inset = label.topInset styledTextRestorations.append { label.topInset = inset } label.topInset = 0 diff --git a/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift b/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift index 87260536a..5aad63ebf 100644 --- a/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift @@ -1,15 +1,15 @@ import UIKit import OneKeyImage -// Template-owned binding and semantic text targets. The existing cell owns -// allocation/reset until the row-host migration replaces the legacy view tree. +// The renderer owns its text subtree. Image slots and row constraints remain +// borrowed from the legacy host until the shared image primitive is extracted. enum NativeListMessageRenderer { - struct Views { + final class Views { let root: UIStackView - let column: UIStackView - let title: UILabel - let body: UILabel - let time: UILabel + let column = UIStackView() + let title = NativeListTextLabel() + let body = NativeListTextLabel() + let time = NativeListInsetLabel() let unread: UIView let thumbnail: OneKeyImageReusableView let top: NSLayoutConstraint @@ -18,16 +18,67 @@ enum NativeListMessageRenderer { let leadingHeight: NSLayoutConstraint let thumbnailWidth: NSLayoutConstraint let thumbnailHeight: NSLayoutConstraint + + init( + root: UIStackView, unread: UIView, thumbnail: OneKeyImageReusableView, + top: NSLayoutConstraint, bottom: NSLayoutConstraint, + leadingWidth: NSLayoutConstraint, leadingHeight: NSLayoutConstraint, + thumbnailWidth: NSLayoutConstraint, thumbnailHeight: NSLayoutConstraint + ) { + self.root = root + self.unread = unread + self.thumbnail = thumbnail + self.top = top + self.bottom = bottom + self.leadingWidth = leadingWidth + self.leadingHeight = leadingHeight + self.thumbnailWidth = thumbnailWidth + self.thumbnailHeight = thumbnailHeight + column.axis = .vertical + column.alignment = .fill + column.spacing = 2 + column.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal) + column.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + // Keep the existing title line's layout priorities without Market badges. + let titleLine = UIStackView(arrangedSubviews: [title]) + titleLine.axis = .horizontal + titleLine.alignment = .center + titleLine.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleLine.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + title.setContentHuggingPriority(.defaultLow, for: .horizontal) + title.setContentCompressionResistancePriority(.required, for: .horizontal) + [titleLine, body, time].forEach(column.addArrangedSubview) + } + + func resetText(theme: [String: Any]?) { + for label in [title, body, time] { + label.attributedText = nil + label.text = nil + label.isHidden = true + label.textAlignment = .natural + label.lineBreakMode = .byTruncatingTail + label.rowVerticalAlignment = nil + label.rowOffsetY = 0 + } + title.font = nativeListFont(ofSize: 14, weight: .semibold) + body.font = nativeListFont(ofSize: 14) + time.font = nativeListFont(ofSize: 12) + title.textColor = nativeListColor(theme, "primaryText", "#202020") + body.textColor = nativeListColor(theme, "secondaryText", "#646464") + time.textColor = nativeListColor(theme, "disabledText", "#8D8D8D") + time.lineBreakMode = .byWordWrapping + time.topInset = 2 + } } static func bind( _ views: Views, item: NativeListItem, theme: [String: Any]?, show: (UILabel, String, Int) -> Void, lineHeight: (UILabel, String, CGFloat) -> Void, - timeInset: (CGFloat) -> Void, leading: ([String: Any]) -> Void, image: ([String: Any], OneKeyImageReusableView) -> Void ) { + views.resetText(theme: theme) views.root.alignment = .top views.top.constant = 16 views.bottom.constant = -16 @@ -37,15 +88,10 @@ enum NativeListMessageRenderer { views.unread.isHidden = !item.data.bool("unread") views.root.addArrangedSubview(views.column) show(views.title, item.data.string("title"), 2) - views.title.font = nativeListFont(ofSize: 14, weight: .semibold) lineHeight(views.title, item.data.string("title"), 20) show(views.body, item.data.string("body"), min(3, max(1, item.data.int("bodyLines", default: 3)))) - views.body.font = nativeListFont(ofSize: 14) lineHeight(views.body, item.data.string("body"), 20) show(views.time, item.data.string("time"), 1) - views.time.font = nativeListFont(ofSize: 12) - views.time.textColor = nativeListColor(theme, "disabledText", "#8D8D8D") - timeInset(2) lineHeight(views.time, item.data.string("time"), 16) if let source = item.data.dictionary("thumbnail") { views.root.addArrangedSubview(views.thumbnail) diff --git a/native-views/react-native-native-list/ios/NativeListTextLabel.swift b/native-views/react-native-native-list/ios/NativeListTextLabel.swift new file mode 100644 index 000000000..7b4162b75 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListTextLabel.swift @@ -0,0 +1,43 @@ +import UIKit + +class NativeListTextLabel: UILabel { + var rowVerticalAlignment: String? { didSet { setNeedsDisplay() } } + var rowOffsetY: CGFloat = 0 { didSet { setNeedsDisplay() } } + + override func drawText(in rect: CGRect) { + var target = rect + if let alignment = rowVerticalAlignment { + let height = super.textRect(forBounds: rect, limitedToNumberOfLines: numberOfLines).height + target.origin.y += alignment == "top" ? 0 : alignment == "bottom" ? rect.height - height : (rect.height - height) / 2 + target.size.height = height + } + target.origin.y += rowOffsetY + super.drawText(in: target) + } +} + +final class NativeListInsetLabel: NativeListTextLabel { + var horizontalInset: CGFloat = 0 + var topInset: CGFloat = 0 + var bottomInset: CGFloat = 0 + + override var intrinsicContentSize: CGSize { + let size = super.intrinsicContentSize + return CGSize( + width: size.width + horizontalInset * 2, + height: size.height + topInset + bottomInset + ) + } + + override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset( + by: UIEdgeInsets( + top: topInset, + left: horizontalInset, + bottom: bottomInset, + right: horizontalInset + ) + )) + } +} + From 0fa4881ff14c55b6144dbd3ed8907fc3c5ba4330 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 20:59:13 +0800 Subject: [PATCH 24/71] refactor(native-list): let Android Message own its text subtree --- .../nativelist/NativeListMessageRenderer.kt | 58 ++++++++++++++++--- .../onekey/nativelist/NativeListRowView.kt | 33 +++-------- .../onekey/nativelist/NativeListTextView.kt | 26 +++++++++ 3 files changed, 84 insertions(+), 33 deletions(-) create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListTextView.kt diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt index 72bcf52e1..c525c4f74 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt @@ -1,5 +1,6 @@ package com.margelo.nitro.nativelist +import android.content.Context import android.graphics.drawable.Drawable import android.view.Gravity import android.view.View @@ -10,17 +11,56 @@ import androidx.core.widget.TextViewCompat import com.margelo.nitro.onekeyimage.OneKeyImageReusableView import org.json.JSONObject -// Stateless template binding. The legacy row host still owns allocation and reset -// during migration; asset and text primitives retain their existing lifecycle. +// Text allocation and defaults belong to the renderer. Image slots retain the +// legacy host lifecycle until the shared image primitive is extracted. internal object NativeListMessageRenderer { - data class Views( - val column: LinearLayout, - val title: TextView, - val body: TextView, - val time: TextView, + class Views( + context: Context, val unread: View, val thumbnail: OneKeyImageReusableView, - ) + ) { + val column = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_VERTICAL + } + val title = NativeListTextView(context) + val body = NativeListTextView(context) + val time = NativeListTextView(context) + + init { + val titleLine = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(title, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + } + column.addView(titleLine, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) + column.addView(body) + column.addView(time) + } + + fun resetText(theme: JSONObject?, sp: (Float) -> Float, color: (JSONObject?, String, String) -> Int) { + listOf(title, body, time).forEach { view -> + view.text = "" + view.visibility = View.GONE + view.includeFontPadding = false + view.fontFeatureSettings = "tnum" + view.typeface = NativeListFonts.regular(view.context) + view.setLineSpacing(0f, 1f) + view.letterSpacing = 0f + view.gravity = Gravity.START or Gravity.TOP + view.ellipsize = null + view.setHorizontallyScrolling(false) + view.opticalOffsetY = 0f + } + title.textSize = sp(14f) + title.typeface = NativeListFonts.semibold(title.context) + body.textSize = sp(14f) + time.textSize = sp(12f) + title.setTextColor(color(theme, "primaryText", "#000000DF")) + body.setTextColor(color(theme, "secondaryText", "#0000009B")) + time.setTextColor(color(theme, "disabledText", "#00000072")) + } + } fun bind( root: LinearLayout, @@ -28,12 +68,14 @@ internal object NativeListMessageRenderer { item: NativeListItem, theme: JSONObject?, dp: (Int) -> Int, + sp: (Float) -> Float, color: (JSONObject?, String, String) -> Int, showText: (TextView, String, Int) -> Unit, leading: (JSONObject, Int) -> Unit, thumbnailBorder: (Int, Float) -> Drawable, image: (JSONObject, OneKeyImageReusableView) -> Unit, ) = with(views) { + resetText(theme, sp, color) root.gravity = Gravity.TOP root.setPadding(dp(12), dp(16), dp(12), dp(16)) item.json.optJSONObject("leading")?.let { leading(it, 28) } diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index c09f4ff1e..2c6f7a8bf 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -187,27 +187,6 @@ private class SelectorLineHeightSpan(private val lineHeight: Int) : android.text } } -private open class NativeListTextView(context: android.content.Context) : TextView(context) { - var rowHorizontallyScrolling = false - private set - - override fun setHorizontallyScrolling(whether: Boolean) { - rowHorizontallyScrolling = whether - super.setHorizontallyScrolling(whether) - } - - var opticalOffsetY = 0f - set(value) { field = value; invalidate() } - - override fun onDraw(canvas: Canvas) { - if (opticalOffsetY == 0f) { super.onDraw(canvas); return } - val checkpoint = canvas.save() - canvas.translate(0f, opticalOffsetY) - super.onDraw(canvas) - canvas.restoreToCount(checkpoint) - } -} - private class DottedUnderlineTextView(context: android.content.Context) : NativeListTextView(context) { var useSourceScale = false private fun scaledDp(value: Float) = if (useSourceScale) value * resources.displayMetrics.density else NativeListScale.dp(resources, value) @@ -1169,7 +1148,11 @@ internal class NativeListRowView( setPadding(paddingLeft, inset, paddingRight, inset) } if (style.has("lineGap") && item.type != "walletGroup" && item.type != "dataRow") { - val target = if (item.type == "metricCard" && item.json.optString("variant") in setOf("activity", "performance")) this else mainColumn + val target = when { + item.type == "message" -> messageViews.column + item.type == "metricCard" && item.json.optString("variant") in setOf("activity", "performance") -> this + else -> mainColumn + } styleGap(target, styleDp(style.optDouble("lineGap"))) } if (style.has("leadingGap") && leadingFrame.parent != null) { @@ -2346,12 +2329,13 @@ internal class NativeListRowView( } private val messageViews by lazy { - NativeListMessageRenderer.Views(mainColumn, title, subtitle, status, unreadDot, secondaryImage) + NativeListMessageRenderer.Views(context, unreadDot, secondaryImage) } private fun bindMessage(item: NativeListItem, theme: JSONObject?) { NativeListMessageRenderer.bind(this, messageViews, item, theme, dp = ::dp, + sp = ::sp, color = ::color, showText = ::showText, leading = ::addLeading, @@ -4269,7 +4253,6 @@ internal class NativeListRowView( 12f } else { when (item.type) { - "message" -> 14f "sectionHeader" -> when { isNetworkSelectorSection && item.json.optString("variant") != "summary" -> 14f item.json.optString("variant") == "gallery" -> 18f @@ -4300,7 +4283,7 @@ internal class NativeListRowView( else -> NativeListFonts.semibold(context) } "system" -> NativeListFonts.regular(context) - "message", "metricCard" -> NativeListFonts.semibold(context) + "metricCard" -> NativeListFonts.semibold(context) else -> NativeListFonts.medium(context) } } diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListTextView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListTextView.kt new file mode 100644 index 000000000..e6f82dcc6 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListTextView.kt @@ -0,0 +1,26 @@ +package com.margelo.nitro.nativelist + +import android.graphics.Canvas +import android.widget.TextView + +internal open class NativeListTextView(context: android.content.Context) : TextView(context) { + var rowHorizontallyScrolling = false + private set + + override fun setHorizontallyScrolling(whether: Boolean) { + rowHorizontallyScrolling = whether + super.setHorizontallyScrolling(whether) + } + + var opticalOffsetY = 0f + set(value) { field = value; invalidate() } + + override fun onDraw(canvas: Canvas) { + if (opticalOffsetY == 0f) { super.onDraw(canvas); return } + val checkpoint = canvas.save() + canvas.translate(0f, opticalOffsetY) + super.onDraw(canvas) + canvas.restoreToCount(checkpoint) + } +} + From a75666930c1eb47b47d3e0c265b80958eed73937 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 21:01:40 +0800 Subject: [PATCH 25/71] docs(native-list): record Message text ownership and runtime acceptance --- .../react-native-native-list/docs/DESIGN.md | 40 ++++++++++++++----- .../react-native-native-list/docs/SPEC.md | 9 +++-- .../docs/STYLE_SPEC.md | 12 +++--- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index 9782ef809..f44b67c0e 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -88,12 +88,17 @@ Migration begins with `message`, one platform per commit. Its current boundary: | Platform | Extracted | Still owned by the existing host | | --- | --- | --- | | Web | DOM structure, semantic text roles, estimated measurement in `src/web/templates/MessageRowRenderer.ts` | Shared style/asset primitives; wrapper pooling and body replacement | -| Android | Content binding and direct title/body/time style targets in `NativeListMessageRenderer.kt` | View allocation, default typography in `applySize`, box styles, measurement and reset | -| iOS | Content binding, direct title/body/time style targets and existing measured height in `NativeListMessageRenderer.swift` | View allocation, box styles, reset and image lifecycle | +| Android | Text subtree allocation, default typography/reset, content binding and direct title/body/time style targets in `NativeListMessageRenderer.kt` | Legacy host allocation, image slots, box styles, measurement and style restoration | +| iOS | Text subtree allocation, default typography/reset, content binding, semantic text targets and existing measured height in `NativeListMessageRenderer.swift` | Legacy host allocation, image slots, box styles, style restoration and image lifecycle | The migration still keeps existing platform defaults and the native monolithic -hosts. It does **not** yet establish a renderer registry, renderer-owned native -view allocation or complete resolved-style pipeline. +hosts. It does **not** yet establish a renderer registry, lightweight native +hosts or complete resolved-style pipeline. Native Message text views are now +allocated lazily by the renderer and no longer borrow the legacy title/body/time +slots. Their text primitives are shared with the legacy templates; line-gap +styling targets the renderer's own column. The legacy host still allocates its +unused text views, so this intermediate extraction makes no allocation savings +claim. [SPEC.md](SPEC.md#conformance-and-six-stage-migration) tracks all six stages. The callback parameters are temporary adapters to the existing image/text primitives, not a public plugin API. Shared legacy slot maps still serve unmigrated templates and will be retired as ownership moves. @@ -110,12 +115,12 @@ to replace an incompatible holder. Market quote and selection payloads retain their existing full/partial update decisions. Footer and nested-row hosts remain outside these scrolling pools. -This is a reuse boundary, not a separate native view implementation: both native -families still allocate the legacy cell/row class. The next Message step replaces -the temporary adapters with shared primitives and a renderer-owned view tree, -connects its factory to the existing reuse family, then moves update -classification into the renderer. Preserve Market quote and selection update -paths throughout migration. +Both native families still allocate the legacy cell/row class. The next Message +step extracts the remaining shared image primitives and replaces that class with +a lightweight host for the renderer-owned tree, connecting its factory to the +existing reuse family. Resolved styles and update classification still need to +move into the renderer. Preserve Market quote and selection update paths +throughout migration. Move the remaining simple templates only after this lifecycle works on all three platforms; migrate Market/Identity/Header and composed wallet groups last. @@ -161,6 +166,21 @@ checked again. Web additionally verifies wrapper identity in regression tests; runtime checks included ten Web and five iOS mixed-template switching cycles. No browser page errors, Android crashes or iOS runtime exceptions were observed. +The Message text ownership follow-up on 2026-09-22 merged main `52849b86f`, +regenerated the ignored Image/List bindings and rebuilt both real native apps. +The 150 package tests, typecheck, lint (zero errors), Android build/seven unit +tests and full iOS build passed. Both native runtimes preserved the sampled +pre-extraction title/body/time geometry. They passed style and height clearing, +same-key template switching, content replacement, image add/remove, scrolling +end/back, mixed-family replacement and empty/repopulation with the fixed footer. +Additional cases set font size/weight, lineGap, horizontal/vertical alignment, +optical offset and line limits, then cleared them or rebound empty body/time +without recycling; no stale text remained and default geometry was restored. +Chrome desktop and 390px RTL passed the same applicable Message cases. Runtime +screenshots, accessibility trees and an iOS recording were retained outside the +repository. These checks cover this text-subtree increment, not completion of +stage 2 or full native acceptance of every template. + `linear`, `sectioned`, `grid`, and `table` are observable native container semantics. Linear is a full-width stream. Sectioned adds a visual break before section-header rows and can pin those rows. Grid allocates native spans and diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index ebe25aaac..d1f3689e6 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -77,15 +77,16 @@ separate workload and measurements. | Stage | Current status | Exit condition | | --- | --- | --- | | 1. Baseline | Partial: representative Message/runtime cases exist; implicit key/default/update inventory is incomplete | Preserve each existing rule or explicitly migrate its caller | -| 2. Message pilot | In progress: three-platform binding extraction and structural reuse families implemented | Renderer-owned lightweight views, resolved styling/measurement, update classification and lifecycle acceptance | +| 2. Message pilot | In progress: three-platform binding/reuse families and renderer-owned text subtrees implemented; Message runtime cases verified | Lightweight native hosts, resolved styling/measurement, update classification and lifecycle acceptance | | 3. Simple templates | Not started | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | | 4. Complex templates | Not started | Migrate Market, Identity and SectionHeader while preserving quote, selection and header behavior | | 5. WalletGroup | Not started | Independent member renderers; verify member events/styles, compact dragging and height restoration | | 6. Cleanup | Not started | Remove migrated key inference and global style maps; container no longer manipulates template internals | -The next Message increment moves its text subtree and typography into the -renderer. Shared leading/thumbnail image slots and generic host lifecycle stay -in place until their primitives are extracted. The legacy host remains allocated. +Native Message now owns its text subtree and typography; Web owns its DOM +structure and still uses shared CSS defaults. Shared leading/thumbnail image +slots and generic native host lifecycle stay in place +until their primitives are extracted. The legacy host remains allocated. Source boundaries and dated runtime evidence are in the incremental migration section of [DESIGN.md](DESIGN.md). Acceptance covers style set/change/clear, diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index 383495b27..541caa692 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -828,13 +828,15 @@ state before the normal template reset. This includes text metrics, color and alignment; iOS also restores attributed text. This avoids guessing one baseline for views shared by different templates. Device reuse checks remain required. -Message is the first incremental renderer extraction: its native title/body/time -styles now target the renderer's typed view bundle directly, while box styles -and restoration still run through the existing host. Web Message owns its DOM +Message is the first incremental renderer extraction: its renderer allocates and +resets the native title/body/time subtree, and text styles and line gaps target +that subtree directly. Box styles and style restoration still run through the +existing host. Web Message owns its DOM structure and measurement in a separate module. All three scrolling hosts now partition Message from legacy reuse; style changes keep the same family, while -cross-family template changes replace the host. Native view allocation remains -in the existing classes. See +cross-family template changes replace the host. The legacy native host and image +slots are still allocated; lightweight hosts and resolved style inputs shared +with measurement remain pending. See [DESIGN.md](DESIGN.md#incremental-renderer-migration) for the current ownership, remaining migration steps and acceptance scope. From 754e7642b32fc217e5f9d12a8c0343c3466d509b Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 21:08:27 +0800 Subject: [PATCH 26/71] docs(native-list): complete migration baseline and Message exit contract --- .../docs/MIGRATION_BASELINE.md | 72 +++++++++++++++++++ .../react-native-native-list/docs/SPEC.md | 17 ++++- 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 native-views/react-native-native-list/docs/MIGRATION_BASELINE.md diff --git a/native-views/react-native-native-list/docs/MIGRATION_BASELINE.md b/native-views/react-native-native-list/docs/MIGRATION_BASELINE.md new file mode 100644 index 000000000..54f6f6f8b --- /dev/null +++ b/native-views/react-native-native-list/docs/MIGRATION_BASELINE.md @@ -0,0 +1,72 @@ +# Renderer migration baseline + +Inventory captured at `a75666930` on 2026-09-22. This records existing behavior, +not a new public API. Keep each rule until its owning migration explicitly +replaces the caller contract. Renaming keys must not be an accidental side effect +of the Message extraction. + +## Complete implicit key inventory + +The audit covered the TS wrapper/validation/selection/scrolling, Web engine, +Swift models/cell/container, and Kotlin models/row/adapter/container. Searches +included key/sectionKey/column-key comparisons, prefix/substring tests and named +sets. Generated bindings and examples do not define renderer semantics. + +| Rule | Existing behavior and source | Disposition | +| --- | --- | --- | +| `history-` prefix | iOS `NativeListCell.bindSectionHeader` and `RNCNativeListView.rowHeight`: row key **or** section key implies history title/height. Web `estimateWebRowHeight`: same two keys imply 16-unit header height; its rendering still follows the explicit variant. Android `bindSectionHeader`/`applySize`: only **section key** implies uppercase 12/16 title, 0 padding and 16-unit minimum height. Explicit `variant: history` works independently. | Preserve the platform differences in legacy renderers. Stage 4 must migrate callers to explicit `variant: history`; stage 6 can remove these fallbacks after that audit. | +| `token-`, `balance-token-`, exact `linear-custom-token` | Android `NativeListRowView.bind` suppresses `separator: true` for these keys across row types. iOS/Web have no equivalent key exception. | Preserve as an explicitly named Android legacy container policy, including Message. Do not embed it in Message style resolution. Stage 6 requires caller-owned `separator: false` before removal. | +| exact section keys `linear-tokens`, `action-tokens` | Android `bindSectionHeader`/`applySize` applies token-manager 14/20 regular typography, horizontal 12/top 10/bottom 0 padding and 30-unit minimum height (subject to earlier explicit presentation/variant branches). No matching iOS/Web rule. | Preserve until stage 4 moves the caller to explicit style/height; then remove in stage 6. | +| exact tail keys `market-loading-more`, `market-load-more-retry`, `market-end` | Android `NativeListView.isMarketPaginationUpdate` drops those trailing rows before checking a stable Market prefix when either snapshot has loadMore; this keeps the scroll anchor during pagination. It is a container update optimization, not a row renderer. | Preserve in the container. Replace with explicit structural-row recognition in a separately validated pagination change. | +| exact column key `asset` | Web `createDataRow` adds row badges only to that table column. Native table binders attach row badges to the first column instead. | Preserve until stage 3 audits DataRow callers; do not silently broaden every column's badge behavior. | + +There are no other business-key-dependent decisions in the audited production +paths. Equality for identity/diffing, selection targets, section membership, +scroll targets, reorder membership, action lookup, image recycling keys and +stale-callback guards is intentional identity usage, not a business-key rule. +Keyboard event keys and style-property whitelist keys are also unrelated. + +## Default geometry and precedence + +[STYLE_SPEC.md sections 8–9](STYLE_SPEC.md) inventory all twelve templates' row +heights, typography, spacing, image sizes and existing platform differences. +Preserve those unstyled defaults when extracting view ownership. Explicit +`style.container.height` wins over `row.height`; compact WalletGroup reorder +height remains a temporary container override. Style removal restores defaults. +No overflow fitting or font shrinking is introduced. + +Message baseline: native 28-unit leading/64-unit thumbnail, title/body 14/20, +time 12/16; iOS column gaps 2 plus a 2-unit timestamp inset, Android body/time +margins 2/4. Web retains its existing horizontal timestamp, 40-unit leading, +64-unit thumbnail, 15/20 title, 13/18 body, 11/16 timestamp and 8/12 padding. +Explicit native style values use logical units; Android legacy defaults/model +heights retain NativeListScale, while explicit styles use display density. + +The old iOS/Web Message estimators used different fallback metrics from their +views (including 20 horizontal padding and 14/20 time). Stage 2 must correct this +as an explicit measurement change, separate from view extraction; explicit +heights and the unstyled visual defaults above stay unchanged. + +## Special update and lifecycle inventory + +| Path | Required invariant | +| --- | --- | +| Full snapshot/patch | Stable key identifies data; incompatible renderer family replaces the view. Bind A → B is correct without recycling. | +| Message | Renderer classifies no-op, content/style, asset and replacement work. Unchanged image descriptors retain requests/views; changed or removed assets cancel old callbacks/retries. | +| Market quote | Preserve price/priceSegments/change/accessibility partial updates and explicit rich text styles. Theme/layout/non-quote changes still require full binding. | +| Selection echo/summary | Preserve native immediate feedback, latest descriptor/action context and fixed-height summary updates without resetting images. Unknown/mixed payloads keep full binding. | +| Async image completion | Request identity and binding lifetime reject stale writes; bounded retries/fallbacks cannot cross recycle/disposal. | +| Action anchors | Rebind/recycle invalidates old epoch-owned source anchors. Selection-only presentation updates retain their current owner. | +| WalletGroup/reorder | Keep member events, compact 68-unit drag appearance, expansion restoration and bounded member-host retention. Message extraction does not change these paths. | +| Containers | Header/footer, sections/index, scrolling, table/grid/horizontal allocation and pagination remain list-owned. | + +## Acceptance baseline + +The existing 150 package tests and seven Android tests, together with the dated +Message runtime cases in [DESIGN.md](DESIGN.md), establish the pre-migration +baseline. Native title/body/time bounds were captured before text extraction. +The Message fixture covers explicit/model height, style set/change/clear, blank +body/time, same-key Message/Identity changes, images, end/top scrolling, 41-row +mixed swaps, empty/repopulate and the fixed footer on all three platforms. +Full native acceptance of the later templates remains a gate for stages 3–5, +not an implied result of finishing this inventory. diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index d1f3689e6..341347da5 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -76,7 +76,7 @@ separate workload and measurements. | Stage | Current status | Exit condition | | --- | --- | --- | -| 1. Baseline | Partial: representative Message/runtime cases exist; implicit key/default/update inventory is incomplete | Preserve each existing rule or explicitly migrate its caller | +| 1. Baseline | Complete: [implicit keys, defaults, special updates and acceptance baseline](MIGRATION_BASELINE.md) inventoried | Preserve each existing rule or explicitly migrate its caller | | 2. Message pilot | In progress: three-platform binding/reuse families and renderer-owned text subtrees implemented; Message runtime cases verified | Lightweight native hosts, resolved styling/measurement, update classification and lifecycle acceptance | | 3. Simple templates | Not started | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | | 4. Complex templates | Not started | Migrate Market, Identity and SectionHeader while preserving quote, selection and header behavior | @@ -88,6 +88,21 @@ structure and still uses shared CSS defaults. Shared leading/thumbnail image slots and generic native host lifecycle stay in place until their primitives are extracted. The legacy host remains allocated. +Stage 2 completion requires registered Message factories to create lightweight +hosts on both native platforms and a persistent Web Message body. The renderer +resolves text/spacing/image inputs from data/theme/styles, classifies updates, +and binds/measures using that resolved model. Unchanged assets keep their slot +and request identity; changed/removed assets invalidate stale callbacks and +retries. The legacy Message binding and slot-map branches must be removed. + +Message intrinsic measurement will use its actual resolved view metrics rather +than the historical estimator-only defaults listed in MIGRATION_BASELINE.md. +This can change **automatic** Message heights; explicit row/container heights +and unstyled drawing defaults remain unchanged. Native measurement must account +for the allocated column width, line limits, gaps and optional images. Web uses +the same resolved geometry with a conservative text estimate, then corrects the +visible intrinsic height from its rendered DOM; it does not auto-fit content. + Source boundaries and dated runtime evidence are in the incremental migration section of [DESIGN.md](DESIGN.md). Acceptance covers style set/change/clear, binding without recycle, same-key template changes, image add/remove, scrolling From 987bcdbd475aeea26a570c373b86aeabd263bae7 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 22:00:12 +0800 Subject: [PATCH 27/71] refactor(native-list): complete Android Message renderer host --- .../onekey/nativelist/NativeListAdapter.kt | 8 +- .../onekey/nativelist/NativeListImageSlot.kt | 147 +++++ .../nativelist/NativeListLeadingVisual.kt | 360 ++++++++++++ .../nativelist/NativeListMessageRenderer.kt | 517 +++++++++++++++--- .../com/onekey/nativelist/NativeListModels.kt | 10 +- .../nativelist/NativeListRendererRegistry.kt | 25 + .../nativelist/NativeListResolvedText.kt | 136 +++++ .../onekey/nativelist/NativeListRowHost.kt | 59 ++ .../onekey/nativelist/NativeListRowView.kt | 118 +--- .../com/onekey/nativelist/NativeListView.kt | 2 +- .../nativelist/NativeListStyleSlotTest.kt | 5 +- 11 files changed, 1197 insertions(+), 190 deletions(-) create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListImageSlot.kt create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowHost.kt diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt index 516d87668..9d5d0af79 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt @@ -44,7 +44,7 @@ internal class NativeListAdapter( private var marketSourceItems: List? = null private var marketSourceEdges = DoubleArray(0) private val createdRows = Collections.newSetFromMap( - WeakHashMap(), + WeakHashMap(), ) var usesSelectorSourceScale = false var theme: JSONObject? = null @@ -65,7 +65,7 @@ internal class NativeListAdapter( var checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String = { _, _, fallback -> fallback } var onRowPress: ((NativeListItem, NativeListActionOrigin) -> Unit)? = null var onAction: ((NativeListItem, String, NativeSelectionTarget?, NativeListActionOrigin?) -> Unit)? = null - var onBindingInvalidated: ((NativeListRowView, Long) -> Unit)? = null + var onBindingInvalidated: ((NativeListRowHost, Long) -> Unit)? = null override fun getItemCount(): Int = reorderItems?.size ?: differ.currentList.size @@ -73,7 +73,7 @@ internal class NativeListAdapter( requireNotNull(itemAt(position)).rendererKey.ordinal override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NativeListViewHolder { - val view = NativeListRowView(context) + val view = NativeListRendererRegistry.create(context, viewType) view.layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, @@ -212,7 +212,7 @@ internal class NativeListAdapter( marketSourceEdges = DoubleArray(0) reorderItems = null suppressDifferUpdates = false - createdRows.forEach(NativeListRowView::dispose) + createdRows.forEach(NativeListRowHost::dispose) createdRows.clear() } diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListImageSlot.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListImageSlot.kt new file mode 100644 index 000000000..969871117 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListImageSlot.kt @@ -0,0 +1,147 @@ +package com.margelo.nitro.nativelist + +import com.facebook.react.uimanager.ThemedReactContext +import com.margelo.nitro.onekeyimage.OneKeyImageReusableView +import org.json.JSONArray +import org.json.JSONObject + +internal fun nativeListCanonical(value: Any?): String = + when (value) { + is JSONObject -> + value.keys().asSequence().sorted().joinToString(",", "{", "}") { + JSONObject.quote(it) + ":" + nativeListCanonical(value.opt(it)) + } + is JSONArray -> + (0 until value.length()).joinToString(",", "[", "]") { nativeListCanonical(value.opt(it)) } + is String -> JSONObject.quote(value) + else -> value?.toString() ?: "null" + } + +/** A request survives text-only binding; source/row/slot changes invalidate it. */ +internal class NativeListImageSlot(context: ThemedReactContext) { + val view = OneKeyImageReusableView(context) + private var identity: String? = null + private var epoch = 0 + private var retry: Runnable? = null + private var completion: ((Boolean) -> Unit)? = null + private var result: Boolean? = null + + fun bind( + source: JSONObject, + key: String, + variant: String = "generic", + fit: String? = null, + placeholder: String = "#0000000F", + round: Boolean = false, + completion: ((Boolean) -> Unit)? = null, + ) { + this.completion = completion + val next = + nativeListCanonical( + JSONObject() + .put("source", source) + .put("key", key) + .put("variant", variant) + .put("fit", fit ?: source.optString("contentFit", "cover")) + .put("placeholder", placeholder) + .put("round", round) + ) + if (identity == next) { + result?.let { completion?.invoke(it) } + return + } + recycle() + this.completion = completion + identity = next + configure(source, key, variant, fit, placeholder, round, 0, epoch) + } + + private fun configure( + source: JSONObject, + key: String, + variant: String, + fit: String?, + placeholder: String, + round: Boolean, + attempt: Int, + token: Int, + ) { + if (epoch != token) return + var completed = false + view.configure( + source.optString("uri").trim(), + source.optJSONObject("headers")?.toString(), + variant, + fit ?: source.optString("contentFit", "cover"), + source.optString("cachePolicy", "memory-disk"), + source.optBoolean("autoplay"), + if (attempt == 0) key else "$key:retry:$attempt", + attempt == 0 && source.optBoolean("optimizeTos", true), + source.optDouble("overscan", 1.1), + source.optString("loadingStrategy", "none"), + placeholder, + round = round, + onLoad = { + if (epoch == token && !completed) { + completed = true + retry?.let(view::removeCallbacks) + retry = null + result = true + completion?.invoke(true) + } + }, + onError = { + if (epoch == token && !completed) { + completed = true + result = false + completion?.invoke(false) + if (attempt < source.optInt("retryTimes") && retry == null) { + val task = Runnable { + if (epoch == token) { + retry = null + configure(source, key, variant, fit, placeholder, round, attempt + 1, token) + } + } + retry = task + view.postDelayed(task, (0..2).random() * 1000L) + } + } + }, + ) + } + + fun recycle() { + epoch++ + retry?.let(view::removeCallbacks) + retry = null + identity = null + completion = null + result = null + view.prepareForReuse() + } + + fun dispose() { + recycle() + view.dispose() + } +} + +internal object NativeListSourceFallbackState { + private const val CACHE_LIMIT = 128 + private val sources = LinkedHashMap(CACHE_LIMIT, 0.75f, true) + + fun has(key: String): Boolean = synchronized(sources) { sources[key] != null } + + fun remember(key: String) { + synchronized(sources) { + sources[key] = Unit + while (sources.size > CACHE_LIMIT) { + sources.remove(sources.entries.first().key) + } + } + } + + fun forget(key: String) { + synchronized(sources) { sources.remove(key) } + } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt new file mode 100644 index 000000000..1a96e60a4 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt @@ -0,0 +1,360 @@ +package com.margelo.nitro.nativelist + +import android.graphics.Color +import android.graphics.Outline +import android.graphics.drawable.GradientDrawable +import android.view.Gravity +import android.view.View +import android.view.ViewOutlineProvider +import android.widget.FrameLayout +import android.widget.TextView +import com.facebook.react.uimanager.ThemedReactContext +import kotlin.math.roundToInt +import org.json.JSONObject + +/** Fixed visual slots; no row-template or business-key branching. */ +internal class NativeListLeadingVisual(private val reactContext: ThemedReactContext) : + FrameLayout(reactContext) { + private val fallback = + TextView(context).apply { + gravity = Gravity.CENTER + typeface = NativeListFonts.bold(context) + } + private val icon = OneKeyIconView(context) + private val unread = View(context) + private val networkBackdrop = View(context) + private val corner = OneKeyIconView(context) + private val cornerBackdrop = View(context) + private val slots = mutableListOf() + private val overlaySlots = mutableListOf() + private val overlays = mutableListOf>() + private var visual = JSONObject() + private var style = JSONObject() + private var sources = emptyList>() + private var sourceScale = false + + private fun dp(value: Int) = + if (sourceScale) (value * resources.displayMetrics.density).roundToInt() + else NativeListScale.dp(resources, value) + + private fun color(value: String, fallback: Int = Color.TRANSPARENT) = + runCatching { parseNativeListColor(value) }.getOrDefault(fallback) + + private fun fill(value: Int, radius: Float) = + GradientDrawable().apply { + setColor(value) + cornerRadius = radius + } + + init { + listOf(fallback, icon, networkBackdrop, cornerBackdrop, corner, unread).forEach { addView(it) } + clipChildren = false + clipToPadding = false + } + + fun bind( + visual: JSONObject, + style: JSONObject, + key: String, + theme: JSONObject?, + isUnread: Boolean, + sourceScale: Boolean, + ) { + this.visual = visual + this.style = style + this.sourceScale = sourceScale + icon.useSourceScale = sourceScale + corner.useSourceScale = sourceScale + val kind = visual.optString("kind") + val variant = + when (kind) { + "token" -> "token" + "network" -> "network" + "account", + "wallet" -> "avatar" + else -> "generic" + } + sources = + if (kind == "stackedImages") + visual + .optJSONArray("images") + ?.let { images -> + (0 until minOf(3, images.length())).mapNotNull { + images.optJSONObject(it)?.let { image -> image to "generic" } + } + } + .orEmpty() + else if (kind == "icon") emptyList() + else visual.optJSONObject("image")?.let { listOf(it to variant) }.orEmpty() + if (kind == "token" && sources.isNotEmpty()) + visual.optJSONObject("networkImage")?.let { sources = sources + (it to "network") } + while (slots.size < sources.size) { + val slot = NativeListImageSlot(reactContext) + slots.add(slot) + addView(slot.view, indexOfChild(if (slots.size == 1) networkBackdrop else cornerBackdrop)) + } + val placeholder = theme?.optString("strongBackground", "#0000000F") ?: "#0000000F" + val fallbackIcon = visual.optJSONObject("fallbackIcon") + val sourceFallback = + sources.firstOrNull()?.first?.optString("loadingStrategy", "none")?.let { it != "none" } == + true && (visual.has("fallbackText") || fallbackIcon != null) + val fallbackKey = + if (sourceFallback) + sources.firstOrNull()?.first?.let { source -> + nativeListSourceFallbackStateKey( + source.optString("uri"), + source + .optJSONObject("headers") + ?.let { headers -> + headers.keys().asSequence().associateWith { headers.optString(it) } + } + .orEmpty(), + ) + } + else null + val restored = fallbackKey?.let(NativeListSourceFallbackState::has) == true + fallback.textSize = if (sourceScale) 13f else NativeListScale.font(resources, 13f) + fallback.text = visual.optString("fallbackText").take(2) + fallback.setTextColor(color("#00000072")) + fallback.visibility = + if (kind != "icon" && fallbackIcon == null && (sources.isEmpty() || restored)) VISIBLE + else GONE + val glyph = if (kind == "icon") visual else fallbackIcon + icon.iconName = glyph?.optString("name") ?: "" + icon.tintColor = color(glyph?.optString("tintColor", "#0000009B") ?: "#0000009B") + icon.visibility = + if (kind == "icon" || (sources.isEmpty() || restored) && fallbackIcon != null) VISIBLE + else GONE + val visualBackground = + color( + visual.optString("backgroundColor", if (sources.isEmpty()) placeholder else "#00000000") + ) + val backgroundColor = if (sourceFallback) color(placeholder) else visualBackground + background = + fill(backgroundColor, 0f).apply { + if (kind == "icon") setStroke(1, color("#0000001F")) + if (visual.optString("borderStyle") == "dashed") + setStroke( + dp(2), + color(visual.optString("borderColor", "#00000072")), + dp(4).toFloat(), + dp(4).toFloat(), + ) + } + slots.forEachIndexed { index, slot -> + if (index >= sources.size) { + slot.recycle() + slot.view.visibility = GONE + } else { + slot.view.visibility = if (index > 0 || sourceFallback) INVISIBLE else VISIBLE + slot.bind( + sources[index].first, + "$key:leading:$index", + sources[index].second, + if (index == 0) style.optString("contentFit").takeIf { it.isNotEmpty() } else null, + placeholder, + round = + visual.optString("shape", if (kind == "image") "rounded" else "circle") == "circle" && + !style.has("shape") && + !style.has("cornerRadius"), + ) { loaded -> + slot.view.visibility = + if (!loaded && (index > 0 || sourceFallback)) INVISIBLE else VISIBLE + if (index == 0 && sourceFallback) { + fallbackKey?.let { + if (loaded) NativeListSourceFallbackState.forget(it) + else NativeListSourceFallbackState.remember(it) + } + (background as? GradientDrawable)?.setColor( + if (loaded) visualBackground else color(placeholder) + ) + fallback.visibility = if (!loaded && fallbackIcon == null) VISIBLE else GONE + icon.visibility = if (!loaded && fallbackIcon != null) VISIBLE else GONE + } + } + } + } + networkBackdrop.visibility = if (kind == "token" && sources.size > 1) VISIBLE else GONE + networkBackdrop.background = + fill(color(theme?.optString("rowBackground", "#FFFFFF") ?: "#FFFFFF"), dp(10).toFloat()) + val badge = visual.optJSONObject("cornerIcon") + corner.visibility = if (badge == null) GONE else VISIBLE + cornerBackdrop.visibility = corner.visibility + corner.iconName = badge?.optString("name") ?: "" + corner.tintColor = color(badge?.optString("tintColor", "#0000009B") ?: "#0000009B") + cornerBackdrop.background = + fill( + color( + badge?.optString( + "backgroundColor", + theme?.optString("rowBackground", "#FFFFFF") ?: "#FFFFFF", + ) ?: "#FFFFFF" + ), + dp(10).toFloat(), + ) + unread.visibility = if (isUnread) VISIBLE else GONE + unread.background = fill(color("#E5484D"), dp(4).toFloat()) + val previousOverlays = overlays.toList() + overlays.clear() + val descriptors = + visual + .optJSONArray("overlays") + ?.let { values -> (0 until minOf(2, values.length())).map { values.getJSONObject(it) } } + .orEmpty() + while (overlaySlots.size < descriptors.size) overlaySlots.add(NativeListImageSlot(reactContext)) + descriptors.forEachIndexed { index, data -> + val frame = previousOverlays.getOrNull(index)?.first ?: FrameLayout(context) + frame.background = + fill( + color(data.optString("backgroundColor", "#00000000")), + minOf( + dp(data.optInt("width", data.optInt("size", 20))), + dp(data.optInt("height", data.optInt("size", 20))), + ) / 2f, + ) + frame.outlineProvider = ViewOutlineProvider.BACKGROUND + frame.clipToOutline = true + val child: View = + data.optJSONObject("image")?.let { image -> + val slot = overlaySlots[index] + slot.view.visibility = INVISIBLE + slot.bind(image, "$key:overlay:$index", placeholder = placeholder) { + slot.view.visibility = if (it) VISIBLE else INVISIBLE + } + slot.view + } + ?: if (data.optString("text").isNotEmpty()) { + overlaySlots[index].recycle() + ((frame.getChildAt(0) as? TextView) ?: TextView(context)).apply { + text = data.optString("text") + gravity = Gravity.CENTER + includeFontPadding = false + textSize = NativeListScale.font(resources, 10f) + typeface = NativeListFonts.medium(context) + setTextColor(color(data.optString("tintColor", "#0000009B"))) + } + } else { + overlaySlots[index].recycle() + ((frame.getChildAt(0) as? OneKeyIconView) ?: OneKeyIconView(context)).apply { + useSourceScale = sourceScale + iconName = data.optString("name") + tintColor = color(data.optString("tintColor", "#0000009B")) + } + } + val inset = data.optInt("padding", 0) + frame.setPadding(dp(inset), dp(inset), dp(inset), dp(inset)) + if (child.parent !== frame) { + frame.removeAllViews() + frame.addView(child, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + } + if (frame.parent == null) addView(frame) + overlays.add(frame to data) + } + previousOverlays.drop(descriptors.size).forEach { removeView(it.first) } + overlaySlots.drop(descriptors.size).forEach { it.recycle() } + unread.bringToFront() + requestLayout() + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + fun place(view: View, x: Int, y: Int, w: Int, h: Int) { + val left = if (layoutDirection == LAYOUT_DIRECTION_RTL) width - x - w else x + view.measure( + MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY), + ) + view.layout(left, y, left + w, y + h) + } + val w = width + val h = height + val kind = visual.optString("kind") + val shape = + style.optString( + "shape", + visual.optString("shape", if (kind == "image") "rounded" else "circle"), + ) + val radius = + if (style.has("cornerRadius")) + (style.optDouble("cornerRadius") * resources.displayMetrics.density).toFloat() + else + when (shape) { + "square" -> 0f + "rounded" -> + if (style.has("shape")) 10 * resources.displayMetrics.density + else minOf(dp(10).toFloat(), h / 4f) + else -> minOf(w, h) / 2f + } + (background as? GradientDrawable)?.apply { + this.shape = + if (style.optString("shape") == "circle" && !style.has("cornerRadius")) + GradientDrawable.OVAL + else GradientDrawable.RECTANGLE + cornerRadius = radius + } + place(fallback, 0, 0, w, h) + val iconSize = + if (sources.isNotEmpty() && visual.optJSONObject("fallbackIcon") != null) + (minOf(w, h) * + if (visual.optJSONObject("fallbackIcon")?.optString("name") == "GlobusOutline") 1.2f + else 1f) + .roundToInt() + else dp(18) + place(icon, (w - iconSize) / 2, (h - iconSize) / 2, iconSize, iconSize) + val tokenPair = kind == "token" && sources.size > 1 + slots.take(sources.size).forEachIndexed { index, slot -> + val size = + if (tokenPair && index == 1) dp(16) + else if (sources.size == 1 || tokenPair) h else (h * 0.72f).roundToInt() + val x = + if (tokenPair && index == 1) w - size + dp(2) + else if (sources.size == 1 || tokenPair) 0 + else (index * (w - size).toFloat() / maxOf(1, sources.size - 1)).roundToInt() + val y = if (tokenPair && index == 1) h - size + dp(2) else (h - size) / 2 + val imageWidth = if (sources.size == 1 || tokenPair && index == 0) w else size + slot.view.measure( + MeasureSpec.makeMeasureSpec(imageWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY), + ) + place(slot.view, x, y, imageWidth, size) + val r = if (index == 0 && (sources.size == 1 || tokenPair)) radius else size / 2f + slot.view.outlineProvider = + object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + if (index == 0 && style.optString("shape") == "circle" && !style.has("cornerRadius")) + outline.setOval(0, 0, view.width, view.height) + else outline.setRoundRect(0, 0, view.width, view.height, r) + } + } + slot.view.clipToOutline = true + } + place(networkBackdrop, w - dp(16), h - dp(16), dp(20), dp(20)) + place(cornerBackdrop, w - dp(16), h - dp(16), dp(20), dp(20)) + place(corner, w - dp(15), h - dp(15), dp(18), dp(18)) + place(unread, w - dp(8), 0, dp(8), dp(8)) + overlays.forEach { (frame, data) -> + val size = data.optInt("size", 20) + val ow = dp(data.optInt("width", size)) + val oh = dp(data.optInt("height", size)) + val x = dp(data.optInt("offsetX", data.optInt("offset", 2))) + val y = dp(data.optInt("offsetY", data.optInt("offset", 2))) + val ox = if (data.optString("position") == "topLeft") -x else w - ow + x + val oy = if (data.optString("position") == "topLeft") -y else h - oh + y + frame.measure( + MeasureSpec.makeMeasureSpec(ow, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(oh, MeasureSpec.EXACTLY), + ) + place(frame, ox, oy, ow, oh) + } + } + + fun recycle() { + slots.forEach { it.recycle() } + overlaySlots.forEach { it.recycle() } + } + + fun dispose() { + slots.forEach { it.dispose() } + overlaySlots.forEach { it.dispose() } + } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt index c525c4f74..ff7567b76 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt @@ -1,106 +1,465 @@ package com.margelo.nitro.nativelist -import android.content.Context -import android.graphics.drawable.Drawable +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Outline +import android.graphics.Paint +import android.graphics.drawable.GradientDrawable import android.view.Gravity +import android.view.MotionEvent import android.view.View +import android.view.ViewOutlineProvider import android.widget.LinearLayout -import android.widget.LinearLayout.LayoutParams -import android.widget.TextView -import androidx.core.widget.TextViewCompat -import com.margelo.nitro.onekeyimage.OneKeyImageReusableView +import com.facebook.react.uimanager.ThemedReactContext +import kotlin.math.roundToInt import org.json.JSONObject -// Text allocation and defaults belong to the renderer. Image slots retain the -// legacy host lifecycle until the shared image primitive is extracted. internal object NativeListMessageRenderer { - class Views( - context: Context, - val unread: View, - val thumbnail: OneKeyImageReusableView, - ) { - val column = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER_VERTICAL - } + enum class Update { + UNCHANGED, + CONTENT, + ASSETS, + REPLACE, + } + + data class Resolved( + val title: NativeListResolvedText, + val body: NativeListResolvedText, + val time: NativeListResolvedText, + val horizontalPadding: Int, + val verticalPadding: Int, + val bodyGap: Int, + val timeGap: Int, + val leadingGap: Int, + val imageWidth: Int, + val imageHeight: Int, + val imageStyle: JSONObject, + val leading: JSONObject?, + val thumbnail: JSONObject?, + val unread: Boolean, + val gravity: Int, + ) + + fun resolve( + context: ThemedReactContext, + item: NativeListItem, + theme: JSONObject?, + sourceScale: Boolean, + ): Resolved { + val style = item.json.optJSONObject("style") ?: JSONObject() + val density = context.resources.displayMetrics.density + fun dp(value: Int) = + if (sourceScale) (value * density).roundToInt() + else NativeListScale.dp(context.resources, value) + fun dimension(source: JSONObject, name: String, default: Int) = + if (source.has(name)) (source.optDouble(name) * density).roundToInt() else dp(default) + fun color(name: String, fallback: String) = + parseNativeListColor(theme?.optString(name, fallback) ?: fallback) + fun text(name: String, size: Float, weight: String, color: Int, height: Int, lines: Int) = + NativeListResolvedText.resolve( + context, + item.json.optString(name), + style.optJSONObject(name), + size, + weight, + color, + height, + lines, + sourceScale, + ) + val image = style.optJSONObject("image") ?: JSONObject() + return Resolved( + text("title", 14f, "semibold", color("primaryText", "#000000DF"), 20, 2), + text( + "body", + 14f, + "regular", + color("secondaryText", "#0000009B"), + 20, + item.json.optInt("bodyLines", 3), + ), + text("time", 12f, "regular", color("disabledText", "#00000072"), 16, 1), + dimension(style, "horizontalPadding", 12), + dimension(style, "verticalPadding", 16), + dimension(style, "lineGap", 2), + dimension(style, "lineGap", 4), + dimension(style, "leadingGap", 12), + dimension(image, "width", 28), + dimension(image, "height", 28), + image, + item.json.optJSONObject("leading"), + item.json.optJSONObject("thumbnail"), + item.json.optBoolean("unread"), + when (style.optJSONObject("container")?.optString("contentVerticalAlignment")) { + "bottom" -> Gravity.BOTTOM + "center" -> Gravity.CENTER_VERTICAL + else -> Gravity.TOP + }, + ) + } + + fun update(old: NativeListItem?, next: NativeListItem): Update { + if (old == null || old.key != next.key || old.rendererKey != next.rendererKey) + return Update.REPLACE + if (old.content == next.content) return Update.UNCHANGED + if ( + listOf("leading", "thumbnail").any { + nativeListCanonical(old.json.opt(it)) != nativeListCanonical(next.json.opt(it)) + } + ) + return Update.ASSETS + return Update.CONTENT + } + + class Views(private val context: ThemedReactContext) { + val column = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } val title = NativeListTextView(context) val body = NativeListTextView(context) val time = NativeListTextView(context) + private var leading: NativeListLeadingVisual? = null + private var thumbnail: NativeListImageSlot? = null init { - val titleLine = LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(title, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + listOf(title, body, time).forEach { + column.addView( + it, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ), + ) + } + } + + fun bind( + root: LinearLayout, + resolved: Resolved, + item: NativeListItem, + theme: JSONObject?, + sourceScale: Boolean, + ) { + resolved.title.bind(title) + resolved.body.bind(body) + resolved.time.bind(time) + root.gravity = resolved.gravity + root.clipChildren = + !(resolved.leading?.optString("kind") == "token" && resolved.leading.has("networkImage")) + root.setPadding( + resolved.horizontalPadding, + resolved.verticalPadding, + resolved.horizontalPadding, + resolved.verticalPadding, + ) + resolved.leading?.let { visual -> + val host = leading ?: NativeListLeadingVisual(context).also { leading = it } + host.bind(visual, resolved.imageStyle, item.key, theme, resolved.unread, sourceScale) + val params = + LinearLayout.LayoutParams(resolved.imageWidth, resolved.imageHeight).apply { + marginEnd = resolved.leadingGap + } + if (host.parent == null) root.addView(host, 0, params) else host.layoutParams = params + } + ?: leading?.let { + it.recycle() + root.removeView(it) + } + if (column.parent == null) + root.addView( + column, + LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f), + ) + body.layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ) + .apply { topMargin = resolved.bodyGap } + time.layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ) + .apply { topMargin = resolved.timeGap } + resolved.thumbnail?.let { image -> + val slot = thumbnail ?: NativeListImageSlot(context).also { thumbnail = it } + val dp: (Int) -> Int = { + if (sourceScale) (it * context.resources.displayMetrics.density).roundToInt() + else NativeListScale.dp(context.resources, it) + } + slot.view.outlineProvider = + object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect(0, 0, view.width, view.height, dp(6).toFloat()) + } + } + slot.view.clipToOutline = true + slot.view.foreground = + GradientDrawable().apply { + setColor(Color.TRANSPARENT) + cornerRadius = dp(6).toFloat() + setStroke( + 1, + parseNativeListColor(theme?.optString("strongBackground", "#0000000F") ?: "#0000000F"), + ) + } + val params = LinearLayout.LayoutParams(dp(64), dp(64)).apply { marginStart = dp(12) } + if (slot.view.parent == null) root.addView(slot.view, params) + else slot.view.layoutParams = params + slot.bind( + image, + "${item.key}:thumbnail", + placeholder = theme?.optString("strongBackground", "#0000000F") ?: "#0000000F", + ) + } + ?: thumbnail?.let { + it.recycle() + root.removeView(it.view) + } + } + + fun recycle() { + leading?.recycle() + thumbnail?.recycle() + } + + fun dispose() { + leading?.dispose() + thumbnail?.dispose() + } + } +} + +internal class NativeListMessageRowView(private val reactContext: ThemedReactContext) : + NativeListRowHost(reactContext) { + private val views = NativeListMessageRenderer.Views(reactContext) + private var current: NativeListItem? = null + private var theme: JSONObject? = null + private var listLayout = "linear" + private var selected = false + private var sourceScale = false + private var inputSignature = "" + private var explicitHeight: Int? = null + private var touchPressed = false + private val separator = Paint(Paint.ANTI_ALIAS_FLAG) + + private fun dp(value: Int) = + if (sourceScale) (value * resources.displayMetrics.density).roundToInt() + else NativeListScale.dp(resources, value) + + private fun stylePx(value: Double) = (value * resources.displayMetrics.density).roundToInt() + + private fun color(value: String, fallback: Int = Color.TRANSPARENT) = + runCatching { parseNativeListColor(value) }.getOrDefault(fallback) + + init { + orientation = HORIZONTAL + setOnClickListener { + current + ?.takeIf { it.isRowPressEnabled } + ?.let { onRowPress?.invoke(it, NativeListActionOrigin(this, this, bindingEpoch, "row")) } + } + setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> touchPressed = current?.isRowPressEnabled == true + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> touchPressed = false } - column.addView(titleLine, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) - column.addView(body) - column.addView(time) + appearance() + false } + } - fun resetText(theme: JSONObject?, sp: (Float) -> Float, color: (JSONObject?, String, String) -> Int) { - listOf(title, body, time).forEach { view -> - view.text = "" - view.visibility = View.GONE - view.includeFontPadding = false - view.fontFeatureSettings = "tnum" - view.typeface = NativeListFonts.regular(view.context) - view.setLineSpacing(0f, 1f) - view.letterSpacing = 0f - view.gravity = Gravity.START or Gravity.TOP - view.ellipsize = null - view.setHorizontallyScrolling(false) - view.opticalOffsetY = 0f + override fun bind( + item: NativeListItem, + theme: JSONObject?, + layout: String, + listOrientation: String, + itemIndex: Int?, + selected: Boolean, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + useSourceScale: Boolean, + ) { + val update = NativeListMessageRenderer.update(current, item) + val signature = + nativeListCanonical( + JSONObject() + .put("theme", theme) + .put("layout", layout) + .put("orientation", listOrientation) + .put("sourceScale", useSourceScale) + .put("listStyle", listStyle) + ) + sourceScale = useSourceScale + if (update != NativeListMessageRenderer.Update.UNCHANGED || signature != inputSignature) { + if (current != null) onBindingInvalidated?.invoke(this, bindingEpoch) + bindingEpoch++ + if (update == NativeListMessageRenderer.Update.REPLACE) { + views.recycle() + touchPressed = false } - title.textSize = sp(14f) - title.typeface = NativeListFonts.semibold(title.context) - body.textSize = sp(14f) - time.textSize = sp(12f) - title.setTextColor(color(theme, "primaryText", "#000000DF")) - body.setTextColor(color(theme, "secondaryText", "#0000009B")) - time.setTextColor(color(theme, "disabledText", "#00000072")) + views.bind( + this, + NativeListMessageRenderer.resolve(reactContext, item, theme, sourceScale), + item, + theme, + sourceScale, + ) + inputSignature = signature } + current = item + tag = item + this.theme = theme + listLayout = layout + this.selected = selected + explicitHeight = + item.styledHeight?.let(::stylePx) + ?: if (item.json.has("height")) + when (item.json.optString("heightRounding")) { + "floor" -> (item.json.optDouble("height") * resources.displayMetrics.density).toInt() + "nearest" -> stylePx(item.json.optDouble("height")) + else -> dp(item.json.optInt("height")) + } + else null + minimumHeight = + if (explicitHeight == null && item.json.optString("size") == "large") dp(12) else 0 + layoutParams = + (layoutParams + ?: android.view.ViewGroup.LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.WRAP_CONTENT, + )) + .apply { + width = if (listOrientation == "horizontal") dp(280) else LayoutParams.MATCH_PARENT + height = LayoutParams.WRAP_CONTENT + } + contentDescription = item.json.optString("accessibilityLabel", item.json.optString("title")) + setTag( + com.facebook.react.R.id.react_test_id, + item.json.optString("testID").takeIf { it.isNotEmpty() }, + ) + isEnabled = !item.json.optBoolean("disabled") + isClickable = item.isWholeRowInteractive + isFocusable = isClickable + if (!isEnabled) touchPressed = false + appearance() + requestLayout() } - fun bind( - root: LinearLayout, - views: Views, + override fun bindSelection( item: NativeListItem, theme: JSONObject?, - dp: (Int) -> Int, - sp: (Float) -> Float, - color: (JSONObject?, String, String) -> Int, - showText: (TextView, String, Int) -> Unit, - leading: (JSONObject, Int) -> Unit, - thumbnailBorder: (Int, Float) -> Drawable, - image: (JSONObject, OneKeyImageReusableView) -> Unit, - ) = with(views) { - resetText(theme, sp, color) - root.gravity = Gravity.TOP - root.setPadding(dp(12), dp(16), dp(12), dp(16)) - item.json.optJSONObject("leading")?.let { leading(it, 28) } - unread.visibility = if (item.json.optBoolean("unread", false)) View.VISIBLE else View.GONE - root.addView(column, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) - showText(title, item.json.optString("title"), 2) - showText(body, item.json.optString("body"), item.json.optInt("bodyLines", 3).coerceIn(1, 3)) - showText(time, item.json.optString("time"), 1) - TextViewCompat.setLineHeight(title, dp(20)) - TextViewCompat.setLineHeight(body, dp(20)) - TextViewCompat.setLineHeight(time, dp(16)) - body.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { topMargin = dp(2) } - time.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { topMargin = dp(4) } - time.setTextColor(color(theme, "disabledText", "#00000072")) - item.json.optJSONObject("thumbnail")?.let { source -> - thumbnail.visibility = View.VISIBLE - thumbnail.foreground = thumbnailBorder(color(theme, "strongBackground", "#0000000F"), 6f) - root.addView(thumbnail, LayoutParams(dp(64), dp(64)).apply { marginStart = dp(12) }) - image(source, thumbnail) + layout: String, + itemIndex: Int?, + selected: Boolean, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + if (current?.key != item.key) return + current = item + tag = item + this.theme = theme + this.selected = selected + listLayout = layout + appearance() + } + + private fun appearance() { + val item = current ?: return + val container = item.json.optJSONObject("style")?.optJSONObject("container") ?: JSONObject() + val showSelected = selected && (listLayout != "sectioned" || item.json.optBoolean("selected")) + val backgroundName = + if (touchPressed) "rowPressedBackground" + else if (showSelected) "rowSelectedBackground" else "rowBackground" + val backgroundFallback = + if (touchPressed) "#00000017" else if (showSelected) "#0000000F" else "#FFFFFF" + var fill = color(theme?.optString(backgroundName, backgroundFallback) ?: backgroundFallback) + if (!touchPressed) { + if (item.json.has("backgroundColor")) + fill = color(item.json.optString("backgroundColor"), fill) + if (container.has("backgroundColor")) + fill = color(container.optString("backgroundColor"), fill) + } + val position = item.json.optString("groupPosition") + val radius = + if (container.has("cornerRadius")) stylePx(container.optDouble("cornerRadius")).toFloat() + else if (listStyle?.has("groupCornerRadius") == true) + stylePx(listStyle!!.optDouble("groupCornerRadius")).toFloat() + else dp(12).toFloat() + val top = container.has("cornerRadius") || position in setOf("first", "single") + val bottom = container.has("cornerRadius") || position in setOf("last", "single") + val radii = FloatArray(8) { if (it < 4 && top || it >= 4 && bottom) radius else 0f } + outlineProvider = ViewOutlineProvider.BACKGROUND + clipToOutline = container.has("cornerRadius") + background = + GradientDrawable().apply { + setColor(fill) + cornerRadii = radii + } + foreground = + if (container.has("borderWidth")) + GradientDrawable().apply { + setColor(Color.TRANSPARENT) + cornerRadii = radii + setStroke( + stylePx(container.optDouble("borderWidth")), + color(container.optString("borderColor", "#00000000")), + ) + } + else null + alpha = + container.optDouble("opacity", item.json.optDouble("opacity", 1.0)).toFloat() * + if (isEnabled) 1f else 0.5f + separator.color = + color( + listStyle + ?.optJSONObject("separator") + ?.optString("color", theme?.optString("separator", "#0000001F") ?: "#0000001F") + ?: theme?.optString("separator", "#0000001F") + ?: "#0000001F" + ) + separator.strokeWidth = 1f + invalidate() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + // Android's native measurement consumes the exact resolved text/image views. + super.onMeasure( + widthMeasureSpec, + explicitHeight?.let { MeasureSpec.makeMeasureSpec(it, MeasureSpec.EXACTLY) } + ?: heightMeasureSpec, + ) + } + + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + current?.takeIf(::nativeListLegacyShowsSeparator)?.let { + val start = + listStyle + ?.optJSONObject("separator") + ?.takeIf { it.has("inset") } + ?.let { stylePx(it.optDouble("inset")) } ?: dp(12) + canvas.drawLine(start.toFloat(), height - 1f, width.toFloat(), height - 1f, separator) } } - fun applyTextStyles(views: Views, style: JSONObject, apply: (TextView, JSONObject) -> Unit) { - style.optJSONObject("title")?.let { apply(views.title, it) } - style.optJSONObject("body")?.let { apply(views.body, it) } - style.optJSONObject("time")?.let { apply(views.time, it) } + override fun onInitializeAccessibilityNodeInfo( + info: android.view.accessibility.AccessibilityNodeInfo + ) { + super.onInitializeAccessibilityNodeInfo(info) + info.viewIdResourceName = getTag(com.facebook.react.R.id.react_test_id) as? String + } + + override fun recycle() { + if (current != null) onBindingInvalidated?.invoke(this, bindingEpoch) + bindingEpoch++ + current = null + tag = null + inputSignature = "" + touchPressed = false + views.recycle() + } + + override fun dispose() { + recycle() + views.dispose() } } diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index d955894d6..ac26986ed 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -30,7 +30,7 @@ internal fun isNativeListWholeRowInteractive( * The view pool is shared across templates and the mapping is not one to one: * metricCard renders `value` through the title view and its own `title` through * the subtitle view, and one status view carries rail.status, activity.status, - * message.time and metricCard.trend. See docs/STYLE_SPEC.md section 4. + * metricCard.trend. See docs/STYLE_SPEC.md section 4. * * Returns null when the template does not render that field, so an unmapped key * is ignored rather than reaching an unrelated view. @@ -52,12 +52,6 @@ internal fun nativeListStyleSlot(type: String, variant: String, field: String): "secondaryAmount" -> "valueSecondary" else -> null } - "message" -> when (field) { - "title" -> "title" - "body" -> "subtitle" - "time" -> "status" - else -> null - } "dataRow" -> when (field) { "columns" -> "dataPrimary" "columnSecondary" -> "dataSecondary" @@ -132,7 +126,7 @@ internal data class NativeListItem( ) { val content: String = json.toString() val rendererKey: NativeListRendererKey - get() = if (type == "message") NativeListRendererKey.MESSAGE else NativeListRendererKey.LEGACY + get() = NativeListRendererRegistry.key(type) val styledHeight: Double? get() = json.optJSONObject("style")?.optJSONObject("container") ?.takeIf { it.has("height") }?.optDouble("height") diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt new file mode 100644 index 000000000..01881d1bb --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt @@ -0,0 +1,25 @@ +package com.margelo.nitro.nativelist + +import com.facebook.react.uimanager.ThemedReactContext + +internal object NativeListRendererRegistry { + private val factories: Map NativeListRowHost> = + mapOf( + NativeListRendererKey.LEGACY to ::NativeListRowView, + NativeListRendererKey.MESSAGE to ::NativeListMessageRowView, + ) + + fun key(type: String) = + if (type == "message") NativeListRendererKey.MESSAGE else NativeListRendererKey.LEGACY + + fun create(context: ThemedReactContext, viewType: Int): NativeListRowHost = + factories.getValue(NativeListRendererKey.entries[viewType])(context) +} + +// Compatibility belongs to list chrome, never to Message style resolution. +// docs/MIGRATION_BASELINE.md records the callers that must migrate before removal. +internal fun nativeListLegacyShowsSeparator(item: NativeListItem): Boolean = + item.json.optBoolean("separator") && + !item.key.startsWith("token-") && + !item.key.startsWith("balance-token-") && + item.key != "linear-custom-token" diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt new file mode 100644 index 000000000..8df98666c --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt @@ -0,0 +1,136 @@ +package com.margelo.nitro.nativelist + +import android.content.Context +import android.graphics.Paint +import android.graphics.Typeface +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.TextUtils +import android.text.style.LineHeightSpan +import android.util.TypedValue +import android.view.Gravity +import kotlin.math.roundToInt +import org.json.JSONObject + +internal class NativeListLineHeightSpan(private val height: Int) : LineHeightSpan { + override fun chooseHeight( + text: CharSequence, + start: Int, + end: Int, + spanstartv: Int, + v: Int, + fm: Paint.FontMetricsInt, + ) { + val leading = height - (fm.descent - fm.ascent) + fm.ascent -= kotlin.math.ceil(leading / 2.0).toInt() + fm.descent += kotlin.math.floor(leading / 2.0).toInt() + if (start == 0) fm.top = fm.ascent + if (end == text.length) fm.bottom = fm.descent + } +} + +internal data class NativeListResolvedText( + val text: String, + val fontSize: Float, + val typeface: Typeface, + val color: Int, + val lineHeight: Int, + val explicitLineHeight: Boolean, + val lines: Int, + val explicitTruncation: Boolean, + val clip: Boolean, + val gravity: Int, + val offset: Float, +) { + fun bind(view: NativeListTextView) { + view.includeFontPadding = false + view.fontFeatureSettings = "tnum" + view.setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize) + view.typeface = typeface + view.setTextColor(color) + view.letterSpacing = 0f + view.gravity = gravity + view.opticalOffsetY = offset + view.maxLines = lines + view.setHorizontallyScrolling(lines == 1 && explicitTruncation) + view.ellipsize = if (explicitTruncation && !clip) TextUtils.TruncateAt.END else null + view.setLineSpacing(0f, 1f) + if (explicitLineHeight) { + view.text = + SpannableStringBuilder(text).apply { + setSpan( + NativeListLineHeightSpan(lineHeight), + 0, + length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + } else { + view.text = text + androidx.core.widget.TextViewCompat.setLineHeight(view, lineHeight) + } + view.visibility = if (text.isEmpty()) android.view.View.GONE else android.view.View.VISIBLE + } + + companion object { + fun resolve( + context: Context, + text: String, + style: JSONObject?, + size: Float, + weight: String, + color: Int, + lineHeight: Int, + lines: Int, + sourceScale: Boolean, + ): NativeListResolvedText { + val density = context.resources.displayMetrics.density + val maxLines = (style?.optInt("lines", lines) ?: lines).coerceIn(1, 3) + val truncation = style?.let { it.has("lines") || it.has("truncate") } == true + val fontSize = + if (style?.has("fontSize") == true) (style.optDouble("fontSize") * density).toFloat() + else + (if (sourceScale) size else NativeListScale.font(context.resources, size)) * + context.resources.displayMetrics.scaledDensity + val face = + when (style?.optString("fontWeight", weight) ?: weight) { + "medium" -> NativeListFonts.medium(context) + "semibold" -> NativeListFonts.semibold(context) + "bold" -> NativeListFonts.bold(context) + else -> NativeListFonts.regular(context) + } + val line = + if (style?.has("lineHeight") == true) (style.optDouble("lineHeight") * density).roundToInt() + else if (sourceScale) (lineHeight * density).roundToInt() + else NativeListScale.dp(context.resources, lineHeight) + val horizontal = + when (style?.optString("alignment")) { + "center" -> Gravity.CENTER_HORIZONTAL + "end" -> Gravity.END + else -> Gravity.START + } + val vertical = + when (style?.optString("verticalAlignment")) { + "center" -> Gravity.CENTER_VERTICAL + "bottom" -> Gravity.BOTTOM + else -> Gravity.TOP + } + return NativeListResolvedText( + if (maxLines == 1 && truncation) text.replace(Regex("\\r\\n|[\\r\\n]"), " ") else text, + fontSize, + face, + style + ?.optString("color") + ?.takeIf { it.isNotEmpty() } + ?.let { runCatching { parseNativeListColor(it) }.getOrDefault(color) } ?: color, + line, + style?.has("lineHeight") == true, + maxLines, + truncation, + style?.optString("truncate") == "clip", + horizontal or vertical, + ((style?.optDouble("offsetY") ?: 0.0) * density).toFloat(), + ) + } + } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowHost.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowHost.kt new file mode 100644 index 000000000..5413cdd9b --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowHost.kt @@ -0,0 +1,59 @@ +package com.margelo.nitro.nativelist + +import android.animation.TimeInterpolator +import android.widget.LinearLayout +import com.facebook.react.uimanager.ThemedReactContext +import org.json.JSONObject + +/** List-facing lifecycle shared by lightweight and legacy row implementations. */ +internal abstract class NativeListRowHost(context: ThemedReactContext) : LinearLayout(context) { + var bindingEpoch: Long = 0 + protected set + + var listStyle: JSONObject? = null + var onRowPress: ((NativeListItem, NativeListActionOrigin) -> Unit)? = null + var onAction: + ((NativeListItem, String, NativeSelectionTarget?, NativeListActionOrigin?) -> Unit)? = + null + var onBindingInvalidated: ((NativeListRowHost, Long) -> Unit)? = null + + abstract fun bind( + item: NativeListItem, + theme: JSONObject?, + layout: String, + listOrientation: String, + itemIndex: Int?, + selected: Boolean, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + useSourceScale: Boolean = false, + ) + + abstract fun bindSelection( + item: NativeListItem, + theme: JSONObject?, + layout: String, + itemIndex: Int?, + selected: Boolean, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) + + open fun bindMarketQuote(item: NativeListItem, theme: JSONObject?) {} + + open fun bindStableSummary(item: NativeListItem) {} + + abstract fun recycle() + + abstract fun dispose() + + open fun setReorderActive(active: Boolean) {} + + open fun canStartWalletGroupReorder(localY: Float) = true + + open fun finishWalletGroupReorder( + durationMs: Long, + interpolator: TimeInterpolator, + completion: (() -> Unit)? = null, + ) { + completion?.invoke() + } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index 2c6f7a8bf..b64fea8ac 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -116,7 +116,7 @@ private class NativeListMarketSkeleton(context: android.content.Context, backgro internal data class NativeListActionOrigin( val sourceView: View, - val ownerRowView: NativeListRowView, + val ownerRowView: NativeListRowHost, val bindingEpoch: Long, val source: String, val slot: Int? = null, @@ -176,17 +176,6 @@ internal object NativeListFonts { internal data class NativeSelectionTarget(val scope: String, val key: String?) -// OneKey patch: match React Native's CustomLineHeightSpan for first/last line bounds. -private class SelectorLineHeightSpan(private val lineHeight: Int) : android.text.style.LineHeightSpan { - override fun chooseHeight(text: CharSequence, start: Int, end: Int, spanstartv: Int, v: Int, fm: Paint.FontMetricsInt) { - val leading = lineHeight - (fm.descent - fm.ascent) - fm.ascent -= kotlin.math.ceil(leading / 2.0).toInt() - fm.descent += kotlin.math.floor(leading / 2.0).toInt() - if (start == 0) fm.top = fm.ascent - if (end == text.length) fm.bottom = fm.descent - } -} - private class DottedUnderlineTextView(context: android.content.Context) : NativeListTextView(context) { var useSourceScale = false private fun scaledDp(value: Float) = if (useSourceScale) value * resources.displayMetrics.density else NativeListScale.dp(resources, value) @@ -419,33 +408,9 @@ private class NativeListTableColumnView(context: android.content.Context) : Line private fun sp(value: Float): Float = NativeListScale.font(resources, value) } -private object NativeListSourceFallbackState { - private const val CACHE_LIMIT = 128 - private val sources = LinkedHashMap(CACHE_LIMIT, 0.75f, true) - - fun has(key: String): Boolean = synchronized(sources) { - sources[key] != null - } - - fun remember(key: String) { - synchronized(sources) { - sources[key] = Unit - while (sources.size > CACHE_LIMIT) { - sources.remove(sources.entries.first().key) - } - } - } - - fun forget(key: String) { - synchronized(sources) { - sources.remove(key) - } - } -} - internal class NativeListRowView( private val reactContext: ThemedReactContext, -) : LinearLayout(reactContext) { +) : NativeListRowHost(reactContext) { private var marketLeadingUsesSourceClip = false private val leadingFrame = object : FrameLayout(context) { override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean { @@ -530,8 +495,6 @@ internal class NativeListRowView( private var boundKey: String? = null // OneKey patch: delayed retries cannot survive cell rebinding or recycling. private val selectorImageRetries = mutableMapOf() - var bindingEpoch: Long = 0 - private set private var boundCheckboxData: JSONObject? = null private var currentLayout = "linear" private var restingRowBackground: Drawable? = null @@ -571,16 +534,6 @@ internal class NativeListRowView( private val separatorPaint = Paint(Paint.ANTI_ALIAS_FLAG) private var showsSeparator = false - /** - * docs/STYLE_SPEC.md section 5. Set by the adapter before bind, so the binder and - * groupedBackground can read it without another parameter. - */ - var listStyle: JSONObject? = null - - var onRowPress: ((NativeListItem, NativeListActionOrigin) -> Unit)? = null - var onAction: ((NativeListItem, String, NativeSelectionTarget?, NativeListActionOrigin?) -> Unit)? = null - var onBindingInvalidated: ((NativeListRowView, Long) -> Unit)? = null - init { gravity = Gravity.CENTER_VERTICAL isClickable = true @@ -945,7 +898,7 @@ internal class NativeListRowView( } } - fun bind( + override fun bind( item: NativeListItem, theme: JSONObject?, layout: String, @@ -953,7 +906,7 @@ internal class NativeListRowView( itemIndex: Int?, selected: Boolean, checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - useSourceScale: Boolean = false, + useSourceScale: Boolean, ) { val reusesImageIdentity = boundKey == item.key && (tag as? NativeListItem)?.type == item.type val shouldRestorePressed = touchPressed && boundKey == item.key @@ -1036,10 +989,7 @@ internal class NativeListRowView( color(theme, "separator", "#0000001F"), ) separatorPaint.strokeWidth = 1f - showsSeparator = item.json.optBoolean("separator", false) && - !item.key.startsWith("token-") && - !item.key.startsWith("balance-token-") && - item.key != "linear-custom-token" + showsSeparator = nativeListLegacyShowsSeparator(item) invalidate() trailingViews.forEach { it.setTextColor(primary) } isEnabled = !item.json.optBoolean("disabled", false) @@ -1057,7 +1007,6 @@ internal class NativeListRowView( "identity" -> bindIdentity(item, theme, selected, checkboxState) "rail" -> bindRail(item, theme) "activity" -> bindActivity(item, theme) - "message" -> bindMessage(item, theme) "dataRow" -> bindDataRow(item, theme, checkboxState) "market" -> bindMarket(item, theme) "mediaTile" -> bindMediaTile(item, theme) @@ -1149,7 +1098,6 @@ internal class NativeListRowView( } if (style.has("lineGap") && item.type != "walletGroup" && item.type != "dataRow") { val target = when { - item.type == "message" -> messageViews.column item.type == "metricCard" && item.json.optString("variant") in setOf("activity", "performance") -> this else -> mainColumn } @@ -1223,10 +1171,7 @@ internal class NativeListRowView( } } if (item.type == "dataRow") tableDataColumns.forEach { it.applyStyle(style, ::applyStyledText) } - if (item.type == "message") { - NativeListMessageRenderer.applyTextStyles(messageViews, style, ::applyStyledText) - return - } + val variant = item.json.optString("variant") if (item.type == "metricCard" && variant in setOf("activity", "performance")) { style.optJSONObject("title")?.let { titleStyle -> @@ -1340,8 +1285,8 @@ internal class NativeListRowView( } if (style.has("lineHeight")) { val text = SpannableStringBuilder(view.text) - text.getSpans(0, text.length, SelectorLineHeightSpan::class.java).forEach(text::removeSpan) - text.setSpan(SelectorLineHeightSpan(styleDp(style.optDouble("lineHeight"))), 0, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + text.getSpans(0, text.length, NativeListLineHeightSpan::class.java).forEach(text::removeSpan) + text.setSpan(NativeListLineHeightSpan(styleDp(style.optDouble("lineHeight"))), 0, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) view.setLineSpacing(0f, 1f) view.text = text } @@ -1389,7 +1334,7 @@ internal class NativeListRowView( } } - fun recycle() { + override fun recycle() { cancelMarketLongPress() marketLongPressFired = false touchPressed = false @@ -1409,7 +1354,7 @@ internal class NativeListRowView( if (!reorderActive && walletGroupExpandAnimator?.isRunning != true) trimWalletGroupRows(0) } - fun bindSelection( + override fun bindSelection( item: NativeListItem, theme: JSONObject?, layout: String, @@ -1471,7 +1416,7 @@ internal class NativeListRowView( applyRowStyle(item) } - fun bindStableSummary(item: NativeListItem) { + override fun bindStableSummary(item: NativeListItem) { if ( boundKey != item.key || item.type != "sectionHeader" || @@ -1487,7 +1432,7 @@ internal class NativeListRowView( applySelectorTypography(item) } - fun dispose() { + override fun dispose() { cancelMarketLongPress() invalidateCurrentBinding() restoreRestingBackground() @@ -1543,8 +1488,8 @@ internal class NativeListRowView( } if (sourceTypography && view.text.isNotEmpty()) { val text = SpannableStringBuilder(view.text) - text.getSpans(0, text.length, SelectorLineHeightSpan::class.java).forEach(text::removeSpan) - text.setSpan(SelectorLineHeightSpan(selectorLineHeight), 0, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + text.getSpans(0, text.length, NativeListLineHeightSpan::class.java).forEach(text::removeSpan) + text.setSpan(NativeListLineHeightSpan(selectorLineHeight), 0, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) view.setLineSpacing(0f, 1f) view.text = text } @@ -1827,7 +1772,7 @@ internal class NativeListRowView( leadingFrame.alpha = 1f } - fun setReorderActive(active: Boolean) { + override fun setReorderActive(active: Boolean) { if ((tag as? NativeListItem)?.type == "walletGroup" && reorderActive == active) return reorderActive = active if ((tag as? NativeListItem)?.type == "walletGroup") { @@ -1851,7 +1796,7 @@ internal class NativeListRowView( restoreRestingBackground() } - fun canStartWalletGroupReorder(localY: Float): Boolean { + override fun canStartWalletGroupReorder(localY: Float): Boolean { if ((tag as? NativeListItem)?.type != "walletGroup") return true walletGroupRows.forEach { row -> if (localY >= row.top && localY < row.bottom) { @@ -1861,10 +1806,10 @@ internal class NativeListRowView( return true } - fun finishWalletGroupReorder( + override fun finishWalletGroupReorder( durationMs: Long, interpolator: TimeInterpolator, - completion: (() -> Unit)? = null, + completion: (() -> Unit)?, ) { if ((tag as? NativeListItem)?.type != "walletGroup") { setReorderActive(false) @@ -2328,22 +2273,6 @@ internal class NativeListRowView( } } - private val messageViews by lazy { - NativeListMessageRenderer.Views(context, unreadDot, secondaryImage) - } - - private fun bindMessage(item: NativeListItem, theme: JSONObject?) { - NativeListMessageRenderer.bind(this, messageViews, item, theme, - dp = ::dp, - sp = ::sp, - color = ::color, - showText = ::showText, - leading = ::addLeading, - thumbnailBorder = ::roundedHairlineStroke, - image = { source, view -> bindImage(source, view, item.key, 0, "generic") }, - ) - } - private fun bindDataRow( item: NativeListItem, theme: JSONObject?, @@ -2451,10 +2380,10 @@ internal class NativeListRowView( } val text = SpannableStringBuilder(view.text) if (style.has("fontSize")) text.getSpans(0, text.length, AbsoluteSizeSpan::class.java).forEach(text::removeSpan) - text.getSpans(0, text.length, SelectorLineHeightSpan::class.java).forEach(text::removeSpan) + text.getSpans(0, text.length, NativeListLineHeightSpan::class.java).forEach(text::removeSpan) if (style.has("lineHeight")) { val lineHeight = styleDp(style.optDouble("lineHeight")) - text.setSpan(SelectorLineHeightSpan(lineHeight), 0, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + text.setSpan(NativeListLineHeightSpan(lineHeight), 0, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) view.setLineSpacing(0f, 1f) } view.text = text @@ -2698,7 +2627,7 @@ internal class NativeListRowView( ?.let { actionKey -> onAction?.invoke(item, actionKey, null, null) } } - fun bindMarketQuote(item: NativeListItem, theme: JSONObject?) { + override fun bindMarketQuote(item: NativeListItem, theme: JSONObject?) { if (boundKey != item.key || item.type != "market") return tag = item val style = item.json.optJSONObject("style") @@ -4366,7 +4295,6 @@ internal class NativeListRowView( else -> when (item.type) { "rail" -> 28 "activity" -> if ((item.json.optJSONArray("footerActions")?.length() ?: 0) > 0) 104 else 60 - "message" -> 0 "mediaTile" -> 0 "metricCard" -> when (item.json.optString("variant")) { "activity" -> 0 @@ -4654,7 +4582,7 @@ internal class NativeListRowView( } -private class OneKeyIconView(context: android.content.Context) : View(context) { +internal class OneKeyIconView(context: android.content.Context) : View(context) { var useSourceScale = false var iconName: String = "" set(value) { @@ -4837,5 +4765,5 @@ private class OneKeyCheckboxView(context: android.content.Context) : View(contex } } -internal class NativeListViewHolder(val rowView: NativeListRowView) : +internal class NativeListViewHolder(val rowView: NativeListRowHost) : androidx.recyclerview.widget.RecyclerView.ViewHolder(rowView) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt index 9818e4d38..dff5bde32 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt @@ -1590,7 +1590,7 @@ class NativeListView( return false } - private fun handleBindingInvalidated(row: NativeListRowView, epoch: Long) { + private fun handleBindingInvalidated(row: NativeListRowHost, epoch: Long) { val anchor = actionAnchor ?: return if (anchor.ownerRowView.get() === row && anchor.bindingEpoch == epoch) { invalidateActionAnchor("rebind") diff --git a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt index 22e67a9e2..4b119acdd 100644 --- a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt +++ b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt @@ -23,10 +23,10 @@ class NativeListStyleSlotTest { // identity keeps its own names. Case(type = "identity", field = "title", expected = "title"), Case(type = "identity", field = "valueSecondary", expected = "valueSecondary"), - // One status view carries four different model fields. + // Legacy status views only serve unmigrated templates. Case(type = "rail", field = "status", expected = "status"), Case(type = "activity", field = "status", expected = "status"), - Case(type = "message", field = "time", expected = "status"), + Case(type = "message", field = "time", expected = null), // Amounts, indices and values share the two trailing views. Case(type = "activity", field = "primaryAmount", expected = "value"), Case(type = "activity", field = "secondaryAmount", expected = "valueSecondary"), @@ -59,7 +59,6 @@ class NativeListStyleSlotTest { Triple("identity", "", listOf("title", "subtitle", "tertiary", "badge", "value", "valueSecondary")), Triple("rail", "", listOf("title", "badge", "status")), Triple("activity", "", listOf("title", "description", "status", "primaryAmount", "secondaryAmount")), - Triple("message", "", listOf("title", "body", "time")), Triple("dataRow", "", listOf("columns", "columnSecondary", "index")), Triple("mediaTile", "", listOf("title", "subtitle", "badge")), Triple("metricCard", "", listOf("title", "value", "subtitle", "trend")), From 8eb6f45fc8377d83a0868abef84f599e3e3e593e Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 22:00:12 +0800 Subject: [PATCH 28/71] refactor(native-list): retain Web Message views and resolve layout --- .../src/__tests__/NativeList.web.test.ts | 74 +++- .../src/web/NativeListWebEngine.ts | 118 +++-- .../src/web/templates/MessageRowRenderer.ts | 405 +++++++++++++++--- .../src/web/templates/RowRendererRegistry.ts | 20 + 4 files changed, 522 insertions(+), 95 deletions(-) create mode 100644 native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index f8ae08642..2626b7e4a 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -635,7 +635,7 @@ describe('web row style', () => { expect( message.querySelector('[data-nl-slot="body"]')!.style .maxHeight - ).toBe('3lh'); + ).toBe('54px'); const warning = render({ type: 'system', key: 'warning', @@ -902,7 +902,7 @@ describe('web row style', () => { expect( estimateWebRowHeight(multi, config, 400) - estimateWebRowHeight(single, config, 400) - ).toBe(44); + ).toBe(28); // Time is beside the 38px title/body column: max(66,38)-max(22,38). expect( estimateWebRowHeight( { ...multi, style: { ...multi.style, container: { height: 120 } } }, @@ -967,7 +967,7 @@ describe('web row style', () => { ]); expect(item().style.height).toBe('136px'); expect(body().textContent).toBe('One Two Three'); - expect(body().style.color).toBe(''); + expect(body().style.color).toBe('var(--nl-secondary)'); engine.applySnapshot( snapshot({ kind: 'sectioned' }, [ { @@ -1007,6 +1007,70 @@ describe('web row style', () => { } }); + it('retains Message text and image slots through content/style updates and cancels removed retries', () => { + const { document } = new JSDOM('').window; + const host = document.createElement('div'); + document.body.appendChild(host); + const message = { + type: 'message', + key: 'persistent', + title: 'First', + body: 'Body', + time: 'Now', + height: 136, + leading: { kind: 'image', image: { ...image, retryTimes: 2 } }, + thumbnail: image, + } as const; + const engine = new NativeListWebEngine( + host, + snapshot({ kind: 'sectioned' }, [message]), + {}, + false + ); + const row = () => + host.querySelector( + '[data-native-list-row-key="persistent"]' + )!; + try { + const body = row().firstElementChild!; + const title = body.querySelector('[data-nl-slot="title"]')!; + const images = Array.from(body.querySelectorAll('img')); + expect(images).toHaveLength(2); + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [ + { + ...message, + title: 'Updated', + style: { + image: { width: 56, height: 32, cornerRadius: 3 }, + title: { fontSize: 18 }, + }, + }, + ]) + ); + expect(row().firstElementChild).toBe(body); + expect(body.querySelector('[data-nl-slot="title"]')).toBe(title); + expect(title.textContent).toBe('Updated'); + expect(Array.from(body.querySelectorAll('img'))).toEqual(images); + engine.applySnapshot(snapshot({ kind: 'sectioned' }, [message])); + expect(Array.from(body.querySelectorAll('img'))).toEqual(images); + expect((title as HTMLElement).style.fontSize).toBe('15px'); + const source = images[0]!.src; + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [ + { ...message, leading: undefined, thumbnail: undefined }, + ]) + ); + images[0]!.dispatchEvent(new document.defaultView!.Event('error')); + expect(images[0]!.isConnected).toBe(false); + expect(images[0]!.src).toBe(source); + expect(body.querySelector('img')).toBeNull(); + expect(body.querySelector('[data-nl-slot="title"]')).toBe(title); + } finally { + engine.destroy(); + } + }); + it('reuses only compatible row hosts after removal, insertion and index changes', () => { const { document } = new JSDOM('').window; const host = document.createElement('div'); @@ -1056,7 +1120,7 @@ describe('web row style', () => { expect( messageHost.querySelector('[data-nl-slot="body"]')!.style .color - ).toBe(''); + ).toBe('var(--nl-secondary)'); expect(messageHost.textContent).toContain('Fresh'); expect(messageHost.textContent).not.toContain('Old body'); engine.applySnapshot( @@ -1172,7 +1236,7 @@ describe('web row style', () => { expect( body.querySelector('[data-nl-slot="title"]')?.style .color ?? '' - ).toBe(''); + ).toBe(row.type === 'message' ? 'var(--nl-primary)' : ''); } }); diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index 1a442e418..fbd3c9915 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -1,7 +1,9 @@ import { - createMessageRow, - measureMessageRow, -} from './templates/MessageRowRenderer'; + rowRenderer, + rowRendererKey, + recycleRowBody, + type RowRendererKey, +} from './templates/RowRendererRegistry'; import type { ActionAnchorInvalidatedEvent, CheckboxState, @@ -537,6 +539,13 @@ export function estimateWebRowHeight( if (row.type === 'identity' && row.presentation === 'accountSelector') return 58; + const registered = rowRenderer(row); + if (registered) + return Math.max( + 0, + registered.renderer.measure(registered.row, availableWidth) + + sizeModifier(row) + ); let base: number; switch (row.type) { case 'rail': @@ -545,9 +554,6 @@ export function estimateWebRowHeight( case 'activity': base = row.footerActions?.length ? 100 : 60; break; - case 'message': - base = measureMessageRow(row, availableWidth); - break; case 'mediaTile': base = 244; break; @@ -3880,7 +3886,7 @@ function applyTextStyleToSlot( */ export function applyRowStyle(body: HTMLElement, row: RowModel): void { applyRowContainerStyle(body, row); - if (row.type === 'market') return; + if (row.type === 'market' || rowRenderer(row)) return; const style = (row as { style?: Record }).style; if (!style) return; const box = style as RowBoxStyle; @@ -4059,10 +4065,28 @@ export function applyRowStyle(body: HTMLElement, row: RowModel): void { }); } +function rendererPrimitives(context: RenderContext) { + return { + visual: (source: LeadingVisual | undefined) => + createVisual(context, source), + thumbnail: (source: ImageSource) => + createImage(context, source, 'ok-native-list-thumbnail'), + textStyle: applyTextStyleToSlot, + dispose: disposeWebImageRetries, + }; +} + export function createRowBody( context: RenderContext, row: RowModel ): HTMLElement { + const registered = rowRenderer(row); + if (registered) + return registered.renderer.create( + context.document, + registered.row, + rendererPrimitives(context) + ); switch (row.type) { case 'walletGroup': return createWalletGroupRow(context, row); @@ -4085,22 +4109,8 @@ export function createRowBody( case 'identity': case 'activity': return createIdentityOrActivityRow(context, row); - case 'message': - return createMessageRow(context.document, row, { - visual: (source) => createVisual(context, source), - thumbnail: (source) => - createImage(context, source, 'ok-native-list-thumbnail'), - textLayout: applyTextLayout, - }); } -} - -// Only Message has begun renderer migration. The other templates still share -// the legacy view tree; data keys and style values never partition reuse. -type RowRendererKey = 'legacy' | 'message'; - -function rowRendererKey(row: RowModel): RowRendererKey { - return row.type === 'message' ? 'message' : 'legacy'; + throw new Error('No renderer registered for ' + row.type); } export class NativeListWebEngine { @@ -4186,6 +4196,10 @@ export class NativeListWebEngine { private sectionIndexAlignStart = false; // OneKey patch: warning banners are measured after normal browser text wrapping. private measuredWarningHeights = new Map(); + private measuredRendererHeights = new Map< + string, + { signature: string; height: number } + >(); constructor( host: HTMLElement, @@ -4595,6 +4609,7 @@ export class NativeListWebEngine { ) { this.cancelMarketPointer(); this.measuredWarningHeights.clear(); + this.measuredRendererHeights.clear(); this.snapshot = snapshot; this.rows = effectiveRows(snapshot); this.selectedKeys = @@ -4693,6 +4708,7 @@ export class NativeListWebEngine { ) { this.invalidateActionAnchor('layout'); this.measuredWarningHeights.clear(); + this.measuredRendererHeights.clear(); } this.lastViewportWidth = viewportWidth; this.lastViewportHeight = viewportHeight; @@ -4700,11 +4716,20 @@ export class NativeListWebEngine { const measuredSnapshot: NativeListSnapshot = { ...this.snapshot, rows: this.snapshot.rows.map((row) => - row.type === 'system' && - row.variant === 'warning' && + rowRenderer(row) && row.style?.container?.height === undefined && row.height === undefined && - this.measuredWarningHeights.has(row.key) + this.measuredRendererHeights.get(row.key)?.signature === + webRowRenderSignature(row) + ? { + ...row, + height: this.measuredRendererHeights.get(row.key)!.height, + } + : row.type === 'system' && + row.variant === 'warning' && + row.style?.container?.height === undefined && + row.height === undefined && + this.measuredWarningHeights.has(row.key) ? { ...row, height: this.measuredWarningHeights.get(row.key) } : row ), @@ -4806,7 +4831,10 @@ export class NativeListWebEngine { ) { this.invalidateActionAnchorForElement(element); this.mounted.delete(index); - if (disposeWebImageRetries(element)) + const recycled = element.firstElementChild + ? recycleRowBody(element.firstElementChild as HTMLElement) + : false; + if (disposeWebImageRetries(element) || recycled) element.removeAttribute('data-render-signature'); element.remove(); if (rendererKey) this.pool[rendererKey].push(element); @@ -4840,6 +4868,26 @@ export class NativeListWebEngine { let measuredWarningChanged = false; this.mounted.forEach((element, index) => { const row = this.rows[index]; + const registered = row && rowRenderer(row); + if (registered && element.firstElementChild) { + const height = registered.renderer.measureRendered( + element.firstElementChild as HTMLElement, + registered.row + ); + if ( + height !== undefined && + (height !== this.measuredRendererHeights.get(row.key)?.height || + webRowRenderSignature(row) !== + this.measuredRendererHeights.get(row.key)?.signature) + ) { + this.measuredRendererHeights.set(row.key, { + signature: webRowRenderSignature(row), + height, + }); + measuredWarningChanged = true; + } + return; + } if ( row?.type !== 'system' || row.variant !== 'warning' || @@ -4877,7 +4925,13 @@ export class NativeListWebEngine { overlay = false ) { this.invalidateActionAnchorForElement(element); - disposeWebImageRetries(element); + const registered = rowRenderer(row); + const existingBody = element.firstElementChild as HTMLElement | null; + const reusableBody = + registered && existingBody?.dataset.nlRenderer === registered.renderer.key + ? existingBody + : undefined; + if (!reusableBody) disposeWebImageRetries(element); const bindingEpoch = String(++this.bindingEpochCounter); element.className = overlay ? 'ok-native-list-item ok-native-list-sticky' @@ -4925,7 +4979,13 @@ export class NativeListWebEngine { selectedKeys: this.selectedKeys, itemIndex: index, }; - const body = createRowBody(context, row); + const body = reusableBody ?? createRowBody(context, row); + if (reusableBody && registered) + registered.renderer.bind( + body, + registered.row, + rendererPrimitives(context) + ); applySelectorTabularNumbers(body, row); // OneKey patch: explicit selector fields preserve original page geometry. element.style.contain = row.backgroundFullWidth ? 'layout style' : ''; @@ -5006,7 +5066,7 @@ export class NativeListWebEngine { } // Template and presentation defaults must precede caller overrides. applyRowStyle(body, row); - element.replaceChildren(body); + if (body.parentElement !== element) element.replaceChildren(body); if ( row.type === 'market' && row.diagnostics?.imageBindActionKey && diff --git a/native-views/react-native-native-list/src/web/templates/MessageRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/MessageRowRenderer.ts index 617f8a989..7525704d5 100644 --- a/native-views/react-native-native-list/src/web/templates/MessageRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/MessageRowRenderer.ts @@ -5,90 +5,373 @@ import type { NativeListTextStyle, } from '../../models'; -// Asset loading and text styling remain shared; the template owns its structure. -type MessagePrimitives = Readonly<{ +export type MessagePrimitives = Readonly<{ visual: (source: LeadingVisual | undefined) => HTMLElement | undefined; thumbnail: (source: ImageSource) => HTMLElement | undefined; - textLayout: (element: HTMLElement, style: NativeListTextStyle) => void; + textStyle: (element: HTMLElement, style: NativeListTextStyle) => void; + dispose: (element: HTMLElement) => void; }>; +type Text = Readonly<{ + value: string; + style: Required< + Pick + > & + NativeListTextStyle; +}>; +export function resolveMessageRow(row: MessageRow) { + const text = ( + value: string, + style: NativeListTextStyle | undefined, + fontSize: number, + lineHeight: number, + lines: 1 | 2 | 3, + color: string, + fontWeight: NativeListTextStyle['fontWeight'] = 'regular' + ): Text => ({ + value, + style: { + fontSize, + lineHeight, + lines, + color, + fontWeight, + alignment: 'start', + ...style, + }, + }); + return { + title: text( + row.title, + row.style?.title, + 15, + 20, + 2, + 'var(--nl-primary)', + 'semibold' + ), + body: text( + row.body, + row.style?.body, + 13, + 18, + row.bodyLines ?? 3, + 'var(--nl-secondary)' + ), + time: text( + row.time ?? '', + row.style?.time, + 11, + 16, + 1, + 'var(--nl-secondary)' + ), + horizontalPadding: row.style?.horizontalPadding ?? 12, + verticalPadding: row.style?.verticalPadding ?? 8, + lineGap: row.style?.lineGap ?? 0, + leadingGap: row.style?.leadingGap ?? 12, + imageWidth: + row.style?.image?.width ?? + (row.leading?.kind === 'stackedImages' ? 48 : 40), + imageHeight: row.style?.image?.height ?? 40, + image: row.style?.image, + automaticHeight: (row.style?.container?.height ?? row.height) === undefined, + }; +} + +type Views = { + column: HTMLElement; + title: HTMLElement; + body: HTMLElement; + time: HTMLElement; + unread: HTMLElement; + leading?: HTMLElement; + thumbnail?: HTMLElement; + leadingIdentity: string; + thumbnailIdentity: string; + // Factory defaults are captured before any caller style is applied. They are + // never inferred from the previous bound row's styled state. + visualDefaults: Map>; + row?: MessageRow; + primitives: MessagePrimitives; +}; +const visualStyleKeys = [ + 'width', + 'height', + 'flex-basis', + 'border-radius', + 'overflow', + 'object-fit', + 'margin-inline-end', + 'align-self', +]; +const viewsByBody = new WeakMap(); +const identity = (key: string, source: unknown) => + JSON.stringify([key, source]); + +export function classifyMessageUpdate( + old: MessageRow | undefined, + row: MessageRow +) { + if (!old || old.key !== row.key) return 'replace'; + if (JSON.stringify(old) === JSON.stringify(row)) return 'unchanged'; + if ( + identity(row.key, old.leading) !== identity(row.key, row.leading) || + identity(row.key, old.thumbnail) !== identity(row.key, row.thumbnail) + ) + return 'assets'; + return 'content'; +} + +export function bindMessageRow( + body: HTMLElement, + row: MessageRow, + primitives: MessagePrimitives +): void { + const views = viewsByBody.get(body); + if (!views) throw new Error('Message renderer received an incompatible body'); + const resolved = resolveMessageRow(row); + views.primitives = primitives; + const update = classifyMessageUpdate(views.row, row); + if (update === 'unchanged') return; + body.style.cssText = ''; + body.removeAttribute('data-nl-container-background'); + body.removeAttribute('data-nl-container-border'); + body.style.padding = `${resolved.verticalPadding}px ${resolved.horizontalPadding}px`; + body.style.height = resolved.automaticHeight ? 'auto' : '100%'; + views.column.style.cssText = ''; + views.column.style.rowGap = `${resolved.lineGap}px`; + for (const [view, text] of [ + [views.title, resolved.title], + [views.body, resolved.body], + [views.time, resolved.time], + ] as const) { + view.style.cssText = ''; + view.textContent = text.value; + primitives.textStyle(view, text.style); + } + views.unread.style.cssText = ''; + if (row.body) { + if (!views.body.parentElement) views.column.appendChild(views.body); + } else views.body.remove(); + if (row.unread) { + if (!views.unread.parentElement) + body.insertBefore(views.unread, views.column); + } else views.unread.remove(); + const leadingIdentity = identity(row.key, row.leading); + if (leadingIdentity !== views.leadingIdentity) { + if (views.leading) { + primitives.dispose(views.leading); + views.leading.remove(); + } + views.leading = primitives.visual(row.leading); + views.leadingIdentity = leadingIdentity; + views.visualDefaults.clear(); + if (views.leading) { + body.insertBefore(views.leading, body.firstChild); + [ + views.leading, + ...views.leading.querySelectorAll('*'), + ].forEach((view) => + views.visualDefaults.set( + view, + Object.fromEntries( + visualStyleKeys.map((key) => [ + key, + view.style.getPropertyValue(key), + ]) + ) + ) + ); + } + } + if (views.leading) { + views.visualDefaults.forEach((style, view) => { + visualStyleKeys.forEach((key) => { + view.style.removeProperty(key); + if (style[key]) view.style.setProperty(key, style[key]!); + }); + }); + const leading = views.leading; + leading.style.width = `${resolved.imageWidth}px`; + leading.style.height = `${resolved.imageHeight}px`; + leading.style.flexBasis = `${resolved.imageWidth}px`; + leading.style.marginInlineEnd = `${resolved.leadingGap - 12}px`; + const image = resolved.image; + const radius = + image?.cornerRadius !== undefined + ? `${image.cornerRadius}px` + : image?.shape === 'circle' + ? '50%' + : image?.shape === 'square' + ? '0px' + : image?.shape === 'rounded' + ? '10px' + : undefined; + if (radius !== undefined) { + leading.style.setProperty('border-radius', radius, 'important'); + leading.style.overflow = leading.matches('img') ? 'hidden' : 'visible'; + } + const images = leading.matches('img') + ? [leading] + : leading.querySelectorAll( + ':scope > img, :scope > .ok-native-list-visual-fallback' + ); + images.forEach((bitmap) => { + if (bitmap !== leading) { + if (image?.width !== undefined) bitmap.style.width = '100%'; + if (image?.height !== undefined) bitmap.style.height = '100%'; + } + if (radius !== undefined) bitmap.style.borderRadius = radius; + if (image?.contentFit) + bitmap.style.objectFit = + image.contentFit === 'center' ? 'none' : image.contentFit; + }); + } + const thumbnailIdentity = identity(row.key, row.thumbnail); + if (thumbnailIdentity !== views.thumbnailIdentity) { + if (views.thumbnail) { + primitives.dispose(views.thumbnail); + views.thumbnail.remove(); + } + views.thumbnail = row.thumbnail + ? primitives.thumbnail(row.thumbnail) + : undefined; + views.thumbnailIdentity = thumbnailIdentity; + if (views.thumbnail) body.appendChild(views.thumbnail); + } + if (views.thumbnail) views.thumbnail.style.alignSelf = ''; + views.row = row; +} + export function createMessageRow( document: Document, row: MessageRow, primitives: MessagePrimitives ): HTMLElement { - const span = (className: string, text?: string, role?: string) => { - const element = document.createElement('span'); - element.className = className; - if (text !== undefined) element.textContent = text; - if (role) element.dataset.nlSlot = role; - return element; + const span = (className: string, role?: string) => { + const view = document.createElement('span'); + view.className = className; + if (role) view.dataset.nlSlot = role; + return view; }; const body = document.createElement('div'); body.className = 'ok-native-list-row ok-native-list-standard'; - const leading = primitives.visual(row.leading); - if (leading) body.appendChild(leading); - if (row.unread) body.appendChild(span('ok-native-list-unread')); + body.dataset.nlRenderer = 'message'; const column = span('ok-native-list-flex'); - const title = span('ok-native-list-title', row.title, 'title'); - primitives.textLayout(title, { lines: row.style?.title?.lines ?? 2 }); - column.appendChild(title); - if (row.body) { - const text = span('ok-native-list-secondary', row.body, 'body'); - primitives.textLayout(text, { - lines: row.style?.body?.lines ?? row.bodyLines ?? 3, - }); - column.appendChild(text); + const title = span('ok-native-list-title', 'title'); + const text = span('ok-native-list-secondary', 'body'); + const time = span('ok-native-list-time', 'time'); + column.append(title, text); + body.append(column, time); + viewsByBody.set(body, { + column, + title, + body: text, + time, + unread: span('ok-native-list-unread'), + leadingIdentity: '', + thumbnailIdentity: '', + visualDefaults: new Map(), + primitives, + }); + bindMessageRow(body, row, primitives); + return body; +} + +export function recycleMessageRow(body: HTMLElement): void { + const views = viewsByBody.get(body); + if (!views) return; + if (views.leading) { + views.primitives.dispose(views.leading); + views.leading.remove(); } - body.appendChild(column); - body.appendChild(span('ok-native-list-time', row.time, 'time')); - if (row.thumbnail) { - const thumbnail = primitives.thumbnail(row.thumbnail); - if (thumbnail) body.appendChild(thumbnail); + if (views.thumbnail) { + views.primitives.dispose(views.thumbnail); + views.thumbnail.remove(); } - return body; + views.leading = undefined; + views.thumbnail = undefined; + views.row = undefined; + views.leadingIdentity = ''; + views.thumbnailIdentity = ''; + views.visualDefaults.clear(); } -export function measureMessageRow(row: MessageRow, availableWidth: number) { - const style = row.style; - const leadingWidth = row.leading - ? (style?.image?.width ?? 40) + (style?.leadingGap ?? 12) - : 0; - const thumbnailWidth = row.thumbnail ? 88 : 0; - const textWidth = Math.max( - 1, - availableWidth - - leadingWidth - - thumbnailWidth - - (style?.horizontalPadding ?? 20) * 2 - ); - const height = ( - text: string, - textStyle: NativeListTextStyle | undefined, - fallbackLines: number - ) => { - const size = textStyle?.fontSize ?? 14; - const charactersPerLine = Math.max( - textStyle ? 1 : 18, - Math.floor(textWidth / (size / 2)) - ); - const measured = text +export function measureMessageRow( + row: MessageRow, + availableWidth: number +): number { + const r = resolveMessageRow(row); + const estimate = (text: Text, width: number) => { + if (!text.value) return 0; + const value = + text.style.lines === 1 + ? text.value.replace(/\r\n|[\r\n]/g, ' ') + : text.value; + const lines = value .split(/\r\n|[\r\n]/) .reduce( (total, line) => - total + Math.max(1, Math.ceil(line.length / charactersPerLine)), + total + + Math.max( + 1, + Math.ceil( + Array.from(line).reduce( + (sum, char) => + sum + + (char.charCodeAt(0) > 255 + ? text.style.fontSize + : text.style.fontSize / 2), + 0 + ) / Math.max(1, width) + ) + ), 0 ); - return ( - Math.min(textStyle?.lines ?? fallbackLines, measured) * - (textStyle?.lineHeight ?? 20) - ); + return Math.min(text.style.lines, lines) * text.style.lineHeight; }; + const timeWidth = Math.min( + availableWidth / 3, + Math.max(0, (r.time.value.length * r.time.style.fontSize) / 2) + ); + const textWidth = + availableWidth - + r.horizontalPadding * 2 - + (row.leading ? r.imageWidth + r.leadingGap : 0) - + (row.thumbnail ? 76 : 0) - + (row.unread ? 19 : 0) - + timeWidth - + 12; + const columnHeight = + estimate(r.title, textWidth) + + estimate(r.body, textWidth) + + (row.body ? r.lineGap : 0); return ( - (style?.verticalPadding ?? 16) * 2 + - height(row.title, style?.title, 2) + - height(row.body, style?.body, row.bodyLines ?? 3) + - height(row.time, style?.time, 1) + - (style?.lineGap ?? 1) * 2 + r.verticalPadding * 2 + + Math.max( + columnHeight, + estimate(r.time, timeWidth), + row.leading ? r.imageHeight : 0, + row.thumbnail ? 64 : 0 + ) ); } + +export const messageRowRenderer = { + key: 'message' as const, + create: createMessageRow, + bind: bindMessageRow, + recycle: recycleMessageRow, + measure: measureMessageRow, + measureRendered: (body: HTMLElement, row: MessageRow) => { + if (!resolveMessageRow(row).automaticHeight) return undefined; + const height = body.getBoundingClientRect().height; + return height > 0 + ? Math.max( + 0, + height + (row.size === 'small' ? -8 : row.size === 'large' ? 12 : 0) + ) + : undefined; + }, +}; diff --git a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts new file mode 100644 index 000000000..58b1dc59e --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts @@ -0,0 +1,20 @@ +import type { RowModel } from '../../models'; +import { messageRowRenderer } from './MessageRowRenderer'; + +// Closed registration: only Message is migrated. Legacy templates keep their +// existing binders, updates and default geometry until their own migration. +export function rowRenderer(row: RowModel) { + return row.type === 'message' + ? { renderer: messageRowRenderer, row } + : undefined; +} +export type RowRendererKey = 'legacy' | typeof messageRowRenderer.key; +export function rowRendererKey(row: RowModel): RowRendererKey { + return rowRenderer(row)?.renderer.key ?? 'legacy'; +} + +export function recycleRowBody(body: HTMLElement): boolean { + if (body.dataset.nlRenderer !== 'message') return false; + messageRowRenderer.recycle(body); + return true; +} From 22a02783812db954d010bc00e182d1f0d32c4b8f Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 22:02:46 +0800 Subject: [PATCH 29/71] refactor(native-list): complete iOS Message renderer host --- .../ios/NativeListCell.swift | 119 +----- .../ios/NativeListImageSlot.swift | 140 +++++++ .../ios/NativeListLeadingVisual.swift | 274 +++++++++++++ .../ios/NativeListMessageRenderer.swift | 385 ++++++++++++------ .../ios/NativeListModels.swift | 2 +- .../ios/NativeListRendererRegistry.swift | 27 ++ .../ios/NativeListResolvedText.swift | 87 ++++ .../ios/NativeListRowHost.swift | 32 ++ .../ios/RNCNativeListView.swift | 61 ++- 9 files changed, 875 insertions(+), 252 deletions(-) create mode 100644 native-views/react-native-native-list/ios/NativeListImageSlot.swift create mode 100644 native-views/react-native-native-list/ios/NativeListLeadingVisual.swift create mode 100644 native-views/react-native-native-list/ios/NativeListRendererRegistry.swift create mode 100644 native-views/react-native-native-list/ios/NativeListResolvedText.swift create mode 100644 native-views/react-native-native-list/ios/NativeListRowHost.swift diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index 4a325cf48..5d3b833cd 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -5,26 +5,6 @@ import CryptoKit import OneKeyImage import UIKit -private enum NativeListSourceFallbackState { - private static let sources: NSCache = { - let cache = NSCache() - cache.countLimit = 128 - return cache - }() - - static func has(_ key: String) -> Bool { - sources.object(forKey: key as NSString) != nil - } - - static func remember(_ key: String) { - sources.setObject(NSNumber(value: true), forKey: key as NSString) - } - - static func forget(_ key: String) { - sources.removeObject(forKey: key as NSString) - } -} - // Market/TokenListSkeleton: source geometry and the native Skeleton's 3s shimmer. private final class NativeListMarketSkeleton: UIView { private let marks = (0..<5).map { _ in UIView() } @@ -94,7 +74,7 @@ private final class NativeListMarketSkeleton: UIView { final class NativeListActionOrigin { weak var sourceView: UIView? - weak var ownerCell: NativeListCell? + weak var ownerCell: NativeListRowHost? let bindingEpoch: Int let source: String let slot: Int? @@ -104,7 +84,7 @@ final class NativeListActionOrigin { init( sourceView: UIView, - ownerCell: NativeListCell, + ownerCell: NativeListRowHost, bindingEpoch: Int, source: String, slot: Int? = nil, @@ -454,11 +434,7 @@ private final class NativeListTableColumnView: UIStackView { } } -final class NativeListCell: UICollectionViewCell { - static func reuseIdentifier(for renderer: NativeListRendererKey) -> String { - "NativeListCell.\(renderer.rawValue)" - } - +final class NativeListCell: NativeListRowHost { private let rootStack = UIStackView() private let mediaVisualWrapper = UIView() private var styledCircleViews: [UIView] = [] @@ -572,15 +548,6 @@ final class NativeListCell: UICollectionViewCell { private var currentItemIndex: Int? // OneKey patch: delayed image retries belong to the current reusable cell binding. private var selectorImageRetries: [ObjectIdentifier: DispatchWorkItem] = [:] - private(set) var bindingEpoch = 0 - - /// docs/STYLE_SPEC.md §5. Set by the list before `bind`, so the row binder and - /// `applyGroupPosition` can read it without another parameter. - var listStyle: [String: Any]? - - var onAction: ((NativeListItem, String, NativeSelectionTarget?, NativeListActionOrigin?) -> Void)? - var onBindingInvalidated: ((NativeListCell, Int) -> Void)? - override var isHighlighted: Bool { didSet { updateBackgroundColor() } } @@ -925,7 +892,7 @@ final class NativeListCell: UICollectionViewCell { mediaNetworkImage.prepareForReuse() } - func bind( + override func bind( item: NativeListItem, theme: [String: Any]?, layout: String, @@ -1028,7 +995,6 @@ final class NativeListCell: UICollectionViewCell { case "identity": bindIdentity(item, theme: theme, selected: selected, checkboxState) case "rail": bindRail(item, theme: theme) case "activity": bindActivity(item, theme: theme) - case "message": bindMessage(item, theme: theme) case "dataRow": bindDataRow(item, theme: theme, checkboxState) case "market": bindMarket(item, theme: theme) case "mediaTile": bindMediaTile(item, theme: theme) @@ -1052,7 +1018,7 @@ final class NativeListCell: UICollectionViewCell { } } - func updateSelection( + override func updateSelection( item: NativeListItem, selected: Bool, checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String @@ -1633,7 +1599,7 @@ final class NativeListCell: UICollectionViewCell { } } - func setPressed(_ pressed: Bool) { + override func setPressed(_ pressed: Bool) { if currentItem?.type == "walletGroup", walletGroupCompactAppearanceActive { walletGroupCompactCell?.setPressed(pressed) isHighlighted = false @@ -1642,7 +1608,7 @@ final class NativeListCell: UICollectionViewCell { isHighlighted = pressed && isUserInteractionEnabled } - func canStartWalletGroupReorder(at point: CGPoint) -> Bool { + override func canStartWalletGroupReorder(at point: CGPoint) -> Bool { guard currentItem?.type == "walletGroup" else { return true } let groupPoint = rootStack.convert(point, from: self) for (index, cell) in walletGroupCells.prefix(walletGroupMembers.count).enumerated() @@ -1652,7 +1618,7 @@ final class NativeListCell: UICollectionViewCell { return true } - func setWalletGroupReorderCompact(_ compact: Bool) { + override func setWalletGroupReorderCompact(_ compact: Bool) { guard currentItem?.type == "walletGroup" else { return } walletGroupCompactAppearanceActive = compact rootStack.isHidden = compact @@ -1668,7 +1634,7 @@ final class NativeListCell: UICollectionViewCell { } } - func prepareWalletGroupReorderExpansion() { + override func prepareWalletGroupReorderExpansion() { guard currentItem?.type == "walletGroup" else { return } walletGroupCompactAppearanceActive = false rootStack.isHidden = false @@ -1678,13 +1644,13 @@ final class NativeListCell: UICollectionViewCell { restoreWalletGroupOuterAppearance() } - func animateWalletGroupReorderExpansion() { + override func animateWalletGroupReorderExpansion() { guard currentItem?.type == "walletGroup" else { return } rootStack.alpha = 1 walletGroupCompactContainer.alpha = 0 } - func finishWalletGroupReorderExpansion() { + override func finishWalletGroupReorderExpansion() { guard currentItem?.type == "walletGroup" else { return } rootStack.isHidden = false rootStack.alpha = 1 @@ -2193,23 +2159,6 @@ final class NativeListCell: UICollectionViewCell { } } - private lazy var messageViews = NativeListMessageRenderer.Views( - root: rootStack, unread: unreadDot, thumbnail: secondaryImage, - top: rootTopConstraint, bottom: rootBottomConstraint, - leadingWidth: leadingWidth, leadingHeight: leadingHeight, - thumbnailWidth: secondaryWidth, thumbnailHeight: secondaryHeight - ) - - private func bindMessage(_ item: NativeListItem, theme: [String: Any]?) { - NativeListMessageRenderer.bind( - messageViews, item: item, theme: theme, - show: show, - lineHeight: { self.setLineHeight($0, text: $1, lineHeight: $2) }, - leading: { self.addLeading($0, key: item.key) }, - image: { self.bindImage($0, into: $1, token: item.key, slot: 0, variant: "generic") } - ) - } - private func bindDataRow( _ item: NativeListItem, theme: [String: Any]?, @@ -2312,13 +2261,6 @@ final class NativeListCell: UICollectionViewCell { case "secondaryAmount": return "valueSecondary" default: return nil } - case "message": - switch field { - case "title": return "title" - case "body": return "subtitle" - case "time": return "status" - default: return nil - } case "dataRow": switch field { case "columns": return "dataPrimary" @@ -2403,13 +2345,7 @@ final class NativeListCell: UICollectionViewCell { } if style["lineGap"] != nil, item.type != "walletGroup", item.type != "dataRow" { let gap = CGFloat(style.double("lineGap")) - styleGap(item.type == "message" ? messageViews.column : mainStack, gap) - if item.type == "message" { - let label = messageViews.time - let inset = label.topInset - styledTextRestorations.append { label.topInset = inset } - label.topInset = 0 - } + styleGap(mainStack, gap) if item.type == "metricCard", ["activity", "performance"].contains(item.data.string("variant")) { styleGap(metricCompositeStack, gap) if item.data.string("variant") == "performance", let summary = metricCompositeStack.arrangedSubviews.first as? UIStackView { styleGap(summary, gap) } @@ -2465,10 +2401,7 @@ final class NativeListCell: UICollectionViewCell { if item.type == "dataRow" { tableDataColumns.forEach { $0.applyStyle(style, text: applyStyledText) } } - if item.type == "message" { - NativeListMessageRenderer.applyTextStyles(messageViews, style: style, apply: applyStyledText) - return - } + let variant = item.data.string("variant") if item.type == "metricCard" && ["activity", "performance"].contains(variant) { // Composite cards render the heading, not the standard card's value slot. @@ -3011,7 +2944,7 @@ final class NativeListCell: UICollectionViewCell { updateMarketQuote(item, theme: theme) } - func updateMarketQuote(_ item: NativeListItem, theme: [String: Any]?) { + override func updateMarketQuote(_ item: NativeListItem, theme: [String: Any]?) { guard currentItem?.key == item.key, item.type == "market" else { return } currentItem = item NSLayoutConstraint.deactivate(accessorySizeConstraints) @@ -3905,7 +3838,7 @@ final class NativeListCell: UICollectionViewCell { showsSourcePlaceholder && (visual["fallbackText"] != nil || fallbackIconData != nil) let sourceFallbackKey = handlesSourceFallback - ? sources.first.flatMap { sourceFallbackStateKey($0.data) } + ? sources.first.flatMap { nativeListSourceFallbackStateKey($0.data) } : nil let restoresSourceFallback = sourceFallbackKey.map(NativeListSourceFallbackState.has) ?? false fallbackLabel.text = handlesSourceFallback && !restoresSourceFallback ? nil : fallbackText @@ -4158,26 +4091,6 @@ final class NativeListCell: UICollectionViewCell { return sources } - private func sourceFallbackStateKey(_ source: [String: Any]) -> String? { - let uri = source.string("uri").trimmingCharacters(in: .whitespacesAndNewlines) - guard !uri.isEmpty else { return nil } - // The fallback-state cache is process-wide, so key it by a digest of the request identity - // instead of retaining raw header values, which can carry credentials such as Authorization. - let headers = source.dictionary("headers") ?? [:] - let fields: [(name: String, value: String)] = headers.map { key, value in - (name: key.lowercased(), value: String(describing: value)) - } - let sortedFields = fields.sorted { lhs, rhs in - lhs.name == rhs.name ? lhs.value < rhs.value : lhs.name < rhs.name - } - var canonicalValue = "\(uri.utf8.count):\(uri)" - for field in sortedFields { - canonicalValue += "\(field.name.utf8.count):\(field.name)\(field.value.utf8.count):\(field.value)" - } - let digest = SHA256.hash(data: Data(canonicalValue.utf8)) - return digest.map { String(format: "%02x", $0) }.joined() - } - private func leadingConstraints( _ imageView: UIView, index: Int, @@ -4755,7 +4668,7 @@ final class NativeListCell: UICollectionViewCell { ) } - func rowActionOrigin() -> NativeListActionOrigin { + override func rowActionOrigin() -> NativeListActionOrigin { actionOrigin(sourceView: contentView, source: "row") } diff --git a/native-views/react-native-native-list/ios/NativeListImageSlot.swift b/native-views/react-native-native-list/ios/NativeListImageSlot.swift new file mode 100644 index 000000000..5c9740d6e --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListImageSlot.swift @@ -0,0 +1,140 @@ +import CryptoKit +import OneKeyImage +import UIKit + +// Request lifetime is independent of row action epochs. An unchanged descriptor +// can outlive a text-only rebind; a new row/slot/source always invalidates it. +final class NativeListImageSlot { + let view = OneKeyImageReusableView(frame: .zero) + private var identity: String? + private var epoch = 0 + private var retry: DispatchWorkItem? + private var completion: ((Bool) -> Void)? + private var result: Bool? + + static func signature(_ value: [String: Any]) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]) + else { return "" } + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + func bind( + _ source: [String: Any], key: String, variant: String = "generic", + fit: String? = nil, placeholder: String = "#0000000F", completion: ((Bool) -> Void)? = nil + ) { + self.completion = completion + let next = Self.signature([ + "source": source, "key": key, "variant": variant, + "fit": fit ?? source.string("contentFit", default: "cover"), "placeholder": placeholder, + ]) + if identity == next { + if let result { completion?(result) } + return + } + recycle() + self.completion = completion + identity = next + configure( + source, key: key, variant: variant, fit: fit, placeholder: placeholder, attempt: 0, + token: epoch) + } + + private func configure( + _ source: [String: Any], key: String, variant: String, fit: String?, placeholder: String, + attempt: Int, token: Int + ) { + guard token == self.epoch else { return } + var completed = false + let headers = source.dictionary("headers").flatMap { + try? JSONSerialization.data(withJSONObject: $0, options: [.sortedKeys]) + } + self.view.configure( + sourceUri: source.string("uri").trimmingCharacters(in: .whitespacesAndNewlines), + sourceHeadersJson: headers.flatMap { String(data: $0, encoding: .utf8) }, + variant: variant, contentFit: fit ?? source.string("contentFit", default: "cover"), + cachePolicy: source.string("cachePolicy", default: "memory-disk"), + autoplay: source.bool("autoplay"), + recyclingKey: attempt == 0 ? key : "\(key):retry:\(attempt)", + optimizeTos: attempt == 0 && source.bool("optimizeTos", default: true), + overscan: source.double("overscan", default: 1.1), + loadingStrategy: source.string("loadingStrategy", default: "none"), + placeholderColor: placeholder, + onLoad: { [weak self] in + guard let self, self.epoch == token, !completed else { return } + completed = true + self.retry?.cancel() + self.retry = nil + self.result = true + self.completion?(true) + }, + onError: { [weak self] in + guard let self, self.epoch == token, !completed else { return } + completed = true + self.result = false + self.completion?(false) + guard attempt < source.int("retryTimes"), self.retry == nil else { return } + let work = DispatchWorkItem { [weak self] in + guard let self, self.epoch == token else { return } + self.retry = nil + self.configure( + source, key: key, variant: variant, fit: fit, placeholder: placeholder, + attempt: attempt + 1, token: token) + } + self.retry = work + DispatchQueue.main.asyncAfter( + deadline: .now() + Double(Int.random(in: 0...2)), execute: work) + }) + } + + func recycle() { + epoch &+= 1 + retry?.cancel() + retry = nil + completion = nil + identity = nil + result = nil + view.prepareForReuse() + } + deinit { retry?.cancel() } +} + +enum NativeListSourceFallbackState { + private static let sources: NSCache = { + let cache = NSCache() + cache.countLimit = 128 + return cache + }() + + static func has(_ key: String) -> Bool { + sources.object(forKey: key as NSString) != nil + } + + static func remember(_ key: String) { + sources.setObject(NSNumber(value: true), forKey: key as NSString) + } + + static func forget(_ key: String) { + sources.removeObject(forKey: key as NSString) + } +} + +func nativeListSourceFallbackStateKey(_ source: [String: Any]) -> String? { + let uri = source.string("uri").trimmingCharacters(in: .whitespacesAndNewlines) + guard !uri.isEmpty else { return nil } + // The fallback-state cache is process-wide, so key it by a digest of the request identity + // instead of retaining raw header values, which can carry credentials such as Authorization. + let headers = source.dictionary("headers") ?? [:] + let fields: [(name: String, value: String)] = headers.map { key, value in + (name: key.lowercased(), value: String(describing: value)) + } + let sortedFields = fields.sorted { lhs, rhs in + lhs.name == rhs.name ? lhs.value < rhs.value : lhs.name < rhs.name + } + var canonicalValue = "\(uri.utf8.count):\(uri)" + for field in sortedFields { + canonicalValue += + "\(field.name.utf8.count):\(field.name)\(field.value.utf8.count):\(field.value)" + } + let digest = SHA256.hash(data: Data(canonicalValue.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() +} diff --git a/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift b/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift new file mode 100644 index 000000000..9e6a1d66f --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift @@ -0,0 +1,274 @@ +import UIKit + +// Bounded visual primitive: three source slots, two optional overlays. It knows +// visual descriptors and request lifetimes, never row types or business keys. +final class NativeListLeadingVisual: UIView { + private let fallback = UILabel() + private let icon = UIImageView() + private let unread = UIView() + private let networkBackdrop = UIView() + private let corner = UIImageView() + private let cornerBackdrop = UIView() + private var slots: [NativeListImageSlot] = [] + private var overlaySlots: [NativeListImageSlot] = [] + private var overlays: [(UIView, [String: Any])] = [] + private let dashedBorder = CAShapeLayer() + private var visual: [String: Any] = [:] + private var imageStyle: [String: Any] = [:] + private var sources: [([String: Any], String)] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + [fallback, icon, networkBackdrop, cornerBackdrop, corner, unread].forEach(addSubview) + fallback.textAlignment = .center + fallback.font = nativeListFont(ofSize: 13, weight: .bold) + icon.contentMode = .scaleAspectFit + corner.contentMode = .scaleAspectFit + unread.backgroundColor = UIColor(nativeListHex: "#E5484D", fallback: .red) + unread.layer.cornerRadius = 4 + networkBackdrop.layer.cornerRadius = 10 + cornerBackdrop.layer.cornerRadius = 10 + layer.addSublayer(dashedBorder) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func bind( + _ visual: [String: Any], style: [String: Any], key: String, theme: [String: Any]?, + isUnread: Bool + ) { + self.visual = visual + imageStyle = style + let kind = visual.string("kind") + let variant = + kind == "token" + ? "token" + : kind == "network" ? "network" : ["account", "wallet"].contains(kind) ? "avatar" : "generic" + sources = + kind == "stackedImages" + ? visual.dictionaries("images").prefix(3).map { ($0, "generic") } + : kind == "icon" ? [] : visual.dictionary("image").map { [($0, variant)] } ?? [] + if kind == "token", !sources.isEmpty, let network = visual.dictionary("networkImage") { + sources.append((network, "network")) + } + while slots.count < sources.count { + let slot = NativeListImageSlot() + slots.append(slot) + insertSubview(slot.view, belowSubview: slots.count == 1 ? networkBackdrop : cornerBackdrop) + } + let placeholder = theme?.string("strongBackground", default: "#0000000F") ?? "#0000000F" + let fallbackIcon = visual.dictionary("fallbackIcon") + let sourceFallback = + kind != "icon" && !sources.isEmpty + && sources[0].0.string("loadingStrategy", default: "none") != "none" + && (visual["fallbackText"] != nil || fallbackIcon != nil) + let fallbackKey = sourceFallback ? nativeListSourceFallbackStateKey(sources[0].0) : nil + let restored = fallbackKey.map(NativeListSourceFallbackState.has) ?? false + let fallbackData = kind == "icon" ? visual : fallbackIcon + fallback.text = String(visual.string("fallbackText").prefix(2)) + fallback.textColor = UIColor(nativeListHex: "#8D8D8D", fallback: .gray) + icon.image = fallbackData.map { nativeListIcon(named: $0.string("name")) } ?? nil + icon.tintColor = UIColor( + nativeListHex: fallbackData?.string("tintColor", default: "#646464") ?? "#646464", + fallback: .darkGray) + fallback.isHidden = kind == "icon" || fallbackIcon != nil || !sources.isEmpty && !restored + icon.isHidden = !(kind == "icon" || (sources.isEmpty || restored) && fallbackIcon != nil) + let visualBackground = UIColor( + nativeListHex: visual.string( + "backgroundColor", default: sources.isEmpty ? placeholder : "#00000000"), fallback: .clear) + backgroundColor = + sourceFallback ? UIColor(nativeListHex: placeholder, fallback: .clear) : visualBackground + layer.borderWidth = kind == "icon" ? 1 / UIScreen.main.scale : 0 + layer.borderColor = UIColor(nativeListHex: "#0000001F", fallback: .clear).cgColor + for (index, slot) in slots.enumerated() { + guard index < sources.count else { + slot.recycle() + slot.view.isHidden = true + continue + } + let source = sources[index] + slot.view.isHidden = index > 0 || sourceFallback + slot.bind( + source.0, key: "\(key):leading:\(index)", variant: source.1, + fit: index == 0 ? style["contentFit"] as? String : nil, placeholder: placeholder + ) { [weak self, weak slot] loaded in + slot?.view.isHidden = !loaded && (index > 0 || sourceFallback) + guard let self, index == 0, sourceFallback else { return } + if let fallbackKey { + if loaded { + NativeListSourceFallbackState.forget(fallbackKey) + } else { + NativeListSourceFallbackState.remember(fallbackKey) + } + } + self.backgroundColor = + loaded ? visualBackground : UIColor(nativeListHex: placeholder, fallback: .clear) + self.fallback.isHidden = loaded || fallbackIcon != nil + self.icon.isHidden = loaded || fallbackIcon == nil + } + } + networkBackdrop.isHidden = !(kind == "token" && sources.count > 1) + networkBackdrop.backgroundColor = nativeListColor(theme, "rowBackground", "#FFFFFF") + let badge = visual.dictionary("cornerIcon") + corner.isHidden = badge == nil + cornerBackdrop.isHidden = badge == nil + corner.image = badge.flatMap { nativeListIcon(named: $0.string("name")) } + corner.tintColor = UIColor( + nativeListHex: badge?.string("tintColor", default: "#646464") ?? "#646464", + fallback: .darkGray) + cornerBackdrop.backgroundColor = UIColor( + nativeListHex: badge?.string("backgroundColor", default: "#FFFFFF") ?? "#FFFFFF", + fallback: .white) + unread.isHidden = !isUnread + let previousOverlays = overlays + overlays.removeAll(keepingCapacity: true) + let descriptors = Array(visual.dictionaries("overlays").prefix(2)) + while overlaySlots.count < descriptors.count { overlaySlots.append(NativeListImageSlot()) } + for (index, data) in descriptors.enumerated() { + let frame = index < previousOverlays.count ? previousOverlays[index].0 : UIView() + frame.clipsToBounds = true + frame.backgroundColor = UIColor( + nativeListHex: data.string( + "backgroundColor", + default: theme?.string("rowBackground", default: "#00000000") ?? "#00000000"), + fallback: .clear) + let child: UIView + if let image = data.dictionary("image") { + let slot = overlaySlots[index] + child = slot.view + child.isHidden = true + slot.bind(image, key: "\(key):overlay:\(index)", placeholder: placeholder) { + [weak child] loaded in child?.isHidden = !loaded + } + } else if !data.string("text").isEmpty { + overlaySlots[index].recycle() + let label = frame.subviews.first as? UILabel ?? UILabel() + label.text = data.string("text") + label.textAlignment = .center + label.font = nativeListFont(ofSize: 10, weight: .medium) + label.textColor = UIColor( + nativeListHex: data.string("tintColor", default: "#646464"), fallback: .darkGray) + child = label + } else { + overlaySlots[index].recycle() + let glyph = frame.subviews.first as? UIImageView ?? UIImageView() + glyph.image = nativeListIcon(named: data.string("name")) + glyph.contentMode = .scaleAspectFit + glyph.tintColor = UIColor( + nativeListHex: data.string("tintColor", default: "#646464"), fallback: .darkGray) + child = glyph + } + if child.superview !== frame { + frame.subviews.forEach { $0.removeFromSuperview() } + frame.addSubview(child) + } + if frame.superview == nil { addSubview(frame) } + overlays.append((frame, data)) + } + previousOverlays.dropFirst(descriptors.count).forEach { $0.0.removeFromSuperview() } + for slot in overlaySlots.dropFirst(descriptors.count) { slot.recycle() } + bringSubviewToFront(unread) + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + let kind = visual.string("kind") + let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft + func logical(_ rect: CGRect) -> CGRect { + rtl + ? CGRect(x: bounds.width - rect.maxX, y: rect.minY, width: rect.width, height: rect.height) + : rect + } + let shape = imageStyle.string( + "shape", default: visual.string("shape", default: kind == "image" ? "rounded" : "circle")) + let radius = + imageStyle["cornerRadius"] != nil + ? CGFloat(imageStyle.double("cornerRadius")) + : shape == "square" + ? 0 + : shape == "rounded" + ? (imageStyle["shape"] != nil ? 10 : min(10, bounds.height / 4)) + : min(bounds.width, bounds.height) / 2 + layer.cornerRadius = radius + clipsToBounds = sources.count < 2 && overlays.isEmpty && visual.dictionary("cornerIcon") == nil + let ellipse = imageStyle.string("shape") == "circle" && imageStyle["cornerRadius"] == nil + if ellipse && clipsToBounds { + let mask = layer.mask as? CAShapeLayer ?? CAShapeLayer() + mask.path = UIBezierPath(ovalIn: bounds).cgPath + layer.mask = mask + } else { + layer.mask = nil + } + fallback.frame = bounds + let fallbackIcon = visual.dictionary("fallbackIcon") + let iconSize: CGFloat = + !sources.isEmpty && fallbackIcon != nil + ? min(bounds.width, bounds.height) + * (fallbackIcon?.string("name") == "GlobusOutline" ? 1.2 : 1) : 18 + icon.frame = CGRect( + x: (bounds.width - iconSize) / 2, y: (bounds.height - iconSize) / 2, width: iconSize, + height: iconSize) + let tokenPair = kind == "token" && sources.count > 1 + for (index, slot) in slots.prefix(sources.count).enumerated() { + let frame: CGRect + if sources.count == 1 || tokenPair && index == 0 { + frame = bounds + } else if tokenPair { + frame = CGRect(x: bounds.width - 14, y: bounds.height - 14, width: 16, height: 16) + } else { + let side = bounds.height * 0.72 + frame = CGRect( + x: CGFloat(index) * (bounds.width - side) / CGFloat(max(1, sources.count - 1)), + y: (bounds.height - side) / 2, width: side, height: side) + } + slot.view.frame = logical(frame) + slot.view.clipsToBounds = true + slot.view.layer.cornerRadius = + index == 0 && (sources.count == 1 || tokenPair) ? radius : frame.height / 2 + } + for (index, slot) in slots.enumerated() { + if ellipse && index == 0 { + let mask = slot.view.layer.mask as? CAShapeLayer ?? CAShapeLayer() + mask.path = UIBezierPath(ovalIn: slot.view.bounds).cgPath + slot.view.layer.mask = mask + } else { + slot.view.layer.mask = nil + } + } + networkBackdrop.frame = logical( + CGRect(x: bounds.width - 16, y: bounds.height - 16, width: 20, height: 20)) + cornerBackdrop.frame = networkBackdrop.frame + corner.frame = cornerBackdrop.frame.insetBy(dx: 1, dy: 1) + unread.frame = logical(CGRect(x: bounds.width - 8, y: 0, width: 8, height: 8)) + for (frame, data) in overlays { + let size = CGFloat(data.double("size", default: 20)) + let width = CGFloat(data.double("width", default: Double(size))) + let height = CGFloat(data.double("height", default: Double(size))) + let offset = data.double("offset", default: 2) + let x = CGFloat(data.double("offsetX", default: offset)) + let y = CGFloat(data.double("offsetY", default: offset)) + frame.frame = logical( + CGRect( + x: data.string("position") == "topLeft" ? -x : bounds.width - width + x, + y: data.string("position") == "topLeft" ? -y : bounds.height - height + y, width: width, + height: height)) + frame.layer.cornerRadius = min(width, height) / 2 + let inset = CGFloat(data.double("padding", default: 0)) + frame.subviews.first?.frame = frame.bounds.insetBy( + dx: inset, + dy: inset) + } + dashedBorder.isHidden = visual.string("borderStyle") != "dashed" + dashedBorder.strokeColor = + UIColor(nativeListHex: visual.string("borderColor", default: "#8D8D8D"), fallback: .gray) + .cgColor + dashedBorder.fillColor = UIColor.clear.cgColor + dashedBorder.lineWidth = 2 + dashedBorder.lineDashPattern = [4, 4] + dashedBorder.path = UIBezierPath(ovalIn: bounds.insetBy(dx: 1, dy: 1)).cgPath + } + func recycle() { + slots.forEach { $0.recycle() } + overlaySlots.forEach { $0.recycle() } + } +} diff --git a/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift b/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift index 5aad63ebf..bae67e809 100644 --- a/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift @@ -1,148 +1,299 @@ import UIKit -import OneKeyImage -// The renderer owns its text subtree. Image slots and row constraints remain -// borrowed from the legacy host until the shared image primitive is extracted. enum NativeListMessageRenderer { + enum Update { case unchanged, content, assets, replace } + struct Resolved { + let title: NativeListResolvedText + let body: NativeListResolvedText + let time: NativeListResolvedText + let horizontalPadding: CGFloat + let verticalPadding: CGFloat + let gap: CGFloat + let timeInset: CGFloat + let leadingGap: CGFloat + let imageWidth: CGFloat + let imageHeight: CGFloat + let imageStyle: [String: Any] + let leading: [String: Any]? + let thumbnail: [String: Any]? + let unread: Bool + let alignment: UIStackView.Alignment + + init(_ item: NativeListItem, theme: [String: Any]?, layout: String) { + let style = item.data.dictionary("style") ?? [:] + title = NativeListResolvedText( + item.data.string("title"), style: style.dictionary("title"), size: 14, weight: .semibold, + color: nativeListColor(theme, "primaryText", "#202020"), lineHeight: 20, lines: 2) + body = NativeListResolvedText( + item.data.string("body"), style: style.dictionary("body"), size: 14, + color: nativeListColor(theme, "secondaryText", "#646464"), lineHeight: 20, + lines: item.data.int("bodyLines", default: 3)) + time = NativeListResolvedText( + item.data.string("time"), style: style.dictionary("time"), size: 12, + color: nativeListColor(theme, "disabledText", "#8D8D8D"), lineHeight: 16, lines: 1, + breakMode: .byWordWrapping) + horizontalPadding = CGFloat( + style.double("horizontalPadding", default: layout == "table" ? 16 : 12)) + verticalPadding = CGFloat(style.double("verticalPadding", default: 16)) + gap = CGFloat(style.double("lineGap", default: 2)) + timeInset = style["lineGap"] == nil ? 2 : 0 + leadingGap = CGFloat(style.double("leadingGap", default: 12)) + imageStyle = style.dictionary("image") ?? [:] + imageWidth = CGFloat(imageStyle.double("width", default: 28)) + imageHeight = CGFloat(imageStyle.double("height", default: 28)) + leading = item.data.dictionary("leading") + thumbnail = item.data.dictionary("thumbnail") + unread = item.data.bool("unread") + switch style.dictionary("container")?.string("contentVerticalAlignment") { + case "center": alignment = .center + case "bottom": alignment = .bottom + default: alignment = .top + } + } + + func measure(width: CGFloat) -> CGFloat { + let textWidth = max( + 1, + width - horizontalPadding * 2 - (leading == nil ? 0 : imageWidth + leadingGap) + - (thumbnail == nil ? 0 : 76)) + let fields = [title, body, time].filter { !$0.text.isEmpty } + let textHeight = + fields.reduce(CGFloat(0)) { $0 + $1.measure(width: textWidth) } + + CGFloat(max(0, fields.count - 1)) * gap + (time.text.isEmpty ? 0 : timeInset) + return verticalPadding * 2 + + max(textHeight, leading == nil ? 0 : imageHeight, thumbnail == nil ? 0 : 64) + } + } + final class Views { - let root: UIStackView + let root = UIStackView() let column = UIStackView() let title = NativeListTextLabel() let body = NativeListTextLabel() let time = NativeListInsetLabel() - let unread: UIView - let thumbnail: OneKeyImageReusableView - let top: NSLayoutConstraint - let bottom: NSLayoutConstraint - let leadingWidth: NSLayoutConstraint - let leadingHeight: NSLayoutConstraint - let thumbnailWidth: NSLayoutConstraint - let thumbnailHeight: NSLayoutConstraint + private var leading: NativeListLeadingVisual? + private var thumbnail: NativeListImageSlot? + private var sizeConstraints: [NSLayoutConstraint] = [] - init( - root: UIStackView, unread: UIView, thumbnail: OneKeyImageReusableView, - top: NSLayoutConstraint, bottom: NSLayoutConstraint, - leadingWidth: NSLayoutConstraint, leadingHeight: NSLayoutConstraint, - thumbnailWidth: NSLayoutConstraint, thumbnailHeight: NSLayoutConstraint - ) { - self.root = root - self.unread = unread - self.thumbnail = thumbnail - self.top = top - self.bottom = bottom - self.leadingWidth = leadingWidth - self.leadingHeight = leadingHeight - self.thumbnailWidth = thumbnailWidth - self.thumbnailHeight = thumbnailHeight + init() { + root.axis = .horizontal + root.spacing = 12 column.axis = .vertical column.alignment = .fill - column.spacing = 2 column.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal) column.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - // Keep the existing title line's layout priorities without Market badges. - let titleLine = UIStackView(arrangedSubviews: [title]) - titleLine.axis = .horizontal - titleLine.alignment = .center - titleLine.setContentHuggingPriority(.defaultLow, for: .horizontal) - titleLine.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - title.setContentHuggingPriority(.defaultLow, for: .horizontal) title.setContentCompressionResistancePriority(.required, for: .horizontal) - [titleLine, body, time].forEach(column.addArrangedSubview) + [title, body, time].forEach(column.addArrangedSubview) + root.addArrangedSubview(column) } - func resetText(theme: [String: Any]?) { - for label in [title, body, time] { - label.attributedText = nil - label.text = nil - label.isHidden = true - label.textAlignment = .natural - label.lineBreakMode = .byTruncatingTail - label.rowVerticalAlignment = nil - label.rowOffsetY = 0 + func bind(_ resolved: Resolved, item: NativeListItem, theme: [String: Any]?) { + resolved.title.bind(title) + resolved.body.bind(body) + resolved.time.bind(time) + time.topInset = resolved.timeInset + time.invalidateIntrinsicContentSize() + column.spacing = resolved.gap + root.alignment = resolved.alignment + NSLayoutConstraint.deactivate(sizeConstraints) + sizeConstraints.removeAll(keepingCapacity: true) + if let data = resolved.leading { + let visual = leading ?? NativeListLeadingVisual(frame: .zero) + leading = visual + if visual.superview == nil { root.insertArrangedSubview(visual, at: 0) } + visual.bind( + data, style: resolved.imageStyle, key: item.key, theme: theme, isUnread: resolved.unread) + root.setCustomSpacing(resolved.leadingGap, after: visual) + sizeConstraints += [ + visual.widthAnchor.constraint(equalToConstant: resolved.imageWidth), + visual.heightAnchor.constraint(equalToConstant: resolved.imageHeight), + ] + } else if let leading { + leading.recycle() + root.removeArrangedSubview(leading) + leading.removeFromSuperview() + } + if let source = resolved.thumbnail { + let slot = thumbnail ?? NativeListImageSlot() + thumbnail = slot + if slot.view.superview == nil { root.addArrangedSubview(slot.view) } + slot.view.layer.cornerRadius = 6 + slot.view.layer.borderWidth = 1 / UIScreen.main.scale + slot.view.layer.borderColor = UIColor(nativeListHex: "#0000000F", fallback: .clear).cgColor + sizeConstraints += [ + slot.view.widthAnchor.constraint(equalToConstant: 64), + slot.view.heightAnchor.constraint(equalToConstant: 64), + ] + slot.bind( + source, key: "\(item.key):thumbnail", + placeholder: theme?.string("strongBackground", default: "#0000000F") ?? "#0000000F") + } else if let thumbnail { + thumbnail.recycle() + root.removeArrangedSubview(thumbnail.view) + thumbnail.view.removeFromSuperview() } - title.font = nativeListFont(ofSize: 14, weight: .semibold) - body.font = nativeListFont(ofSize: 14) - time.font = nativeListFont(ofSize: 12) - title.textColor = nativeListColor(theme, "primaryText", "#202020") - body.textColor = nativeListColor(theme, "secondaryText", "#646464") - time.textColor = nativeListColor(theme, "disabledText", "#8D8D8D") - time.lineBreakMode = .byWordWrapping - time.topInset = 2 + NSLayoutConstraint.activate(sizeConstraints) + } + func recycle() { + leading?.recycle() + thumbnail?.recycle() } } - static func bind( - _ views: Views, item: NativeListItem, theme: [String: Any]?, - show: (UILabel, String, Int) -> Void, - lineHeight: (UILabel, String, CGFloat) -> Void, - leading: ([String: Any]) -> Void, - image: ([String: Any], OneKeyImageReusableView) -> Void - ) { - views.resetText(theme: theme) - views.root.alignment = .top - views.top.constant = 16 - views.bottom.constant = -16 - views.leadingWidth.constant = 28 - views.leadingHeight.constant = 28 - if let visual = item.data.dictionary("leading") { leading(visual) } - views.unread.isHidden = !item.data.bool("unread") - views.root.addArrangedSubview(views.column) - show(views.title, item.data.string("title"), 2) - lineHeight(views.title, item.data.string("title"), 20) - show(views.body, item.data.string("body"), min(3, max(1, item.data.int("bodyLines", default: 3)))) - lineHeight(views.body, item.data.string("body"), 20) - show(views.time, item.data.string("time"), 1) - lineHeight(views.time, item.data.string("time"), 16) - if let source = item.data.dictionary("thumbnail") { - views.root.addArrangedSubview(views.thumbnail) - views.thumbnailWidth.constant = 64 - views.thumbnailHeight.constant = 64 - views.thumbnailWidth.isActive = true - views.thumbnailHeight.isActive = true - views.thumbnail.layer.borderWidth = 1 / UIScreen.main.scale - views.thumbnail.layer.borderColor = UIColor(nativeListHex: "#0000000F", fallback: .lightGray).cgColor - image(source, views.thumbnail) + static func update(from old: NativeListItem?, to new: NativeListItem) -> Update { + guard let old, old.key == new.key, old.rendererKey == new.rendererKey else { return .replace } + if old.content == new.content { return .unchanged } + for field in ["leading", "thumbnail"] { + if NativeListImageSlot.signature(old.data.dictionary(field) ?? [:]) + != NativeListImageSlot.signature(new.data.dictionary(field) ?? [:]) + { + return .assets + } } + return .content } +} + +final class NativeListMessageCell: NativeListRowHost { + private let views = NativeListMessageRenderer.Views() + private let separator = UIView() + private let fullWidthBackground = CALayer() + private var rootConstraints: [NSLayoutConstraint] = [] + private var item: NativeListItem? + private var theme: [String: Any]? + private var layout = "linear" + private var selectedState = false + private var inputSignature = "" - static func applyTextStyles(_ views: Views, style: [String: Any], apply: (UILabel, [String: Any]) -> Void) { - if let text = style.dictionary("title") { apply(views.title, text) } - if let text = style.dictionary("body") { apply(views.body, text) } - if let text = style.dictionary("time") { apply(views.time, text) } + override init(frame: CGRect) { + super.init(frame: frame) + views.root.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(views.root) + contentView.addSubview(separator) + rootConstraints = [ + views.root.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + views.root.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + views.root.topAnchor.constraint(equalTo: contentView.topAnchor), + views.root.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ] + NSLayoutConstraint.activate(rootConstraints) } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - private static func textWeight(_ style: [String: Any]?, fallback: NativeListFontWeight) -> NativeListFontWeight { - switch style?.string("fontWeight") { - case "regular": return .regular - case "medium": return .medium - case "semibold": return .semibold - case "bold": return .bold - default: return fallback + override func bind( + item: NativeListItem, theme: [String: Any]?, layout: String, itemIndex: Int? = nil, + selected: Bool, checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + let update = NativeListMessageRenderer.update(from: self.item, to: item) + let signature = NativeListImageSlot.signature([ + "theme": theme ?? [:], "layout": layout, "listStyle": listStyle ?? [:], + ]) + if update != .unchanged || signature != inputSignature { + if self.item != nil { onBindingInvalidated?(self, bindingEpoch) } + bindingEpoch &+= 1 + if update == .replace { + views.recycle() + isHighlighted = false + } + let resolved = NativeListMessageRenderer.Resolved(item, theme: theme, layout: layout) + views.bind(resolved, item: item, theme: theme) + rootConstraints[0].constant = resolved.horizontalPadding + rootConstraints[1].constant = -resolved.horizontalPadding + rootConstraints[2].constant = resolved.verticalPadding + rootConstraints[3].constant = -resolved.verticalPadding + inputSignature = signature } + self.item = item + self.theme = theme + self.layout = layout + self.selectedState = selected + accessibilityLabel = item.data.string("accessibilityLabel", default: item.data.string("title")) + accessibilityIdentifier = item.data["testID"] as? String + isUserInteractionEnabled = !item.data.bool("disabled") + if !isUserInteractionEnabled { isHighlighted = false } + applyAppearance() + setNeedsLayout() } - static func measure(_ item: NativeListItem, availableWidth: CGFloat, contentPaddingHorizontal: CGFloat) -> CGFloat { - let style = item.data.dictionary("style") - let horizontalInsets = contentPaddingHorizontal * 2 + CGFloat(style?.double("horizontalPadding", default: 20) ?? 20) * 2 - let leadingWidth: CGFloat = item.data.dictionary("leading") == nil ? 0 : CGFloat(style?.dictionary("image")?.double("width", default: 28) ?? 28) + CGFloat(style?.double("leadingGap", default: 12) ?? 12) - let thumbnailWidth: CGFloat = item.data.dictionary("thumbnail") == nil ? 0 : 76 - let textWidth = max(1, availableWidth - horizontalInsets - leadingWidth - thumbnailWidth) - func textHeight(_ key: String, lines: Int, weight: NativeListFontWeight) -> CGFloat { - let textStyle = style?.dictionary(key) - let lineHeight = CGFloat(textStyle?.double("lineHeight", default: 20) ?? 20) - let size = CGFloat(textStyle?.double("fontSize", default: 14) ?? 14) - let paragraph = NSMutableParagraphStyle() - paragraph.minimumLineHeight = lineHeight - paragraph.maximumLineHeight = lineHeight - let bounds = (item.data.string(key) as NSString).boundingRect(with: CGSize(width: textWidth, height: .greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: nativeListFont(ofSize: size, weight: textWeight(textStyle, fallback: weight)), .paragraphStyle: paragraph], context: nil) - let maximumLines = min(3, max(1, textStyle?.int("lines", default: lines) ?? lines)) - return CGFloat(min(maximumLines, max(1, Int(ceil(bounds.height / lineHeight))))) * lineHeight - } - return CGFloat(style?.double("verticalPadding", default: 16) ?? 16) * 2 - + textHeight("title", lines: 2, weight: .semibold) - + textHeight("body", lines: item.data.int("bodyLines", default: 3), weight: .regular) - + textHeight("time", lines: 1, weight: .regular) - + CGFloat(style?.double("lineGap", default: 1) ?? 1) * 2 + override func updateSelection( + item: NativeListItem, selected: Bool, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + guard self.item?.key == item.key else { return } + self.item = item + self.selectedState = selected + applyAppearance() } + override var isHighlighted: Bool { didSet { applyAppearance() } } + private func applyAppearance() { + guard let item else { return } + let container = item.data.dictionary("style")?.dictionary("container") ?? [:] + let showSelection = selectedState && (layout != "sectioned" || item.data.bool("selected")) + var background = nativeListColor( + theme, showSelection ? "rowSelectedBackground" : "rowBackground", + showSelection ? "#F0F0F0" : "#FFFFFF") + if let color = (container["backgroundColor"] ?? item.data["backgroundColor"]) as? String { + background = UIColor(nativeListHex: color, fallback: background) + } + contentView.backgroundColor = + isHighlighted ? nativeListColor(theme, "rowPressedBackground", "#E8E8E8") : background + contentView.alpha = + CGFloat(container.double("opacity", default: item.data.double("opacity", default: 1))) + * (item.data.bool("disabled") ? 0.5 : 1) + let position = item.data.string("groupPosition") + let grouped = ["first", "last", "single"].contains(position) + let radius = CGFloat( + container.double( + "cornerRadius", + default: grouped ? listStyle?.double("groupCornerRadius", default: 12) ?? 12 : 0)) + let top: CACornerMask = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + let bottom: CACornerMask = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + layer.maskedCorners = + container["cornerRadius"] != nil || position == "single" + ? top.union(bottom) : position == "first" ? top : position == "last" ? bottom : [] + layer.cornerRadius = radius + layer.masksToBounds = radius > 0 + contentView.layer.cornerRadius = radius + contentView.layer.maskedCorners = layer.maskedCorners + contentView.layer.borderWidth = CGFloat(container.double("borderWidth")) + contentView.layer.borderColor = + UIColor( + nativeListHex: container.string("borderColor", default: "#00000000"), fallback: .clear + ).cgColor + if item.data.bool("backgroundFullWidth"), + let fill = (container["backgroundColor"] ?? item.data["backgroundColor"]) as? String + { + fullWidthBackground.backgroundColor = UIColor(nativeListHex: fill, fallback: .clear).cgColor + contentView.layer.insertSublayer(fullWidthBackground, at: 0) + clipsToBounds = false + } else { + fullWidthBackground.removeFromSuperlayer() + } + separator.isHidden = !item.data.bool("separator") + separator.backgroundColor = UIColor( + nativeListHex: listStyle?.dictionary("separator")?.string( + "color", default: theme?.string("separator", default: "#E0E0E0") ?? "#E0E0E0") ?? theme? + .string("separator", default: "#E0E0E0") ?? "#E0E0E0", fallback: .lightGray) + } + override func layoutSubviews() { + super.layoutSubviews() + fullWidthBackground.frame = CGRect( + x: -frame.minX, y: 0, width: superview?.bounds.width ?? bounds.width, height: bounds.height) + let inset = CGFloat(listStyle?.dictionary("separator")?.double("inset", default: 12) ?? 12) + separator.frame = CGRect( + x: effectiveUserInterfaceLayoutDirection == .rightToLeft ? 0 : inset, + y: contentView.bounds.height - 1 / UIScreen.main.scale, + width: max(0, contentView.bounds.width - inset), height: 1 / UIScreen.main.scale) + } + override func prepareForReuse() { + super.prepareForReuse() + if item != nil { onBindingInvalidated?(self, bindingEpoch) } + bindingEpoch &+= 1 + item = nil + inputSignature = "" + views.recycle() + fullWidthBackground.removeFromSuperlayer() + isHighlighted = false + } } diff --git a/native-views/react-native-native-list/ios/NativeListModels.swift b/native-views/react-native-native-list/ios/NativeListModels.swift index fce2ad0b2..ff76660e5 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -16,7 +16,7 @@ struct NativeListItem { let content: String var rendererKey: NativeListRendererKey { - type == "message" ? .message : .legacy + NativeListRendererRegistry.key(for: type) } var styledHeight: CGFloat? { diff --git a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift new file mode 100644 index 000000000..ac1ebece7 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift @@ -0,0 +1,27 @@ +import UIKit + +// Closed internal registry. Legacy templates keep their existing implementation +// until their own migration; no public plugin or bridge surface is introduced. +enum NativeListRendererRegistry { + private static let hosts: [NativeListRendererKey: NativeListRowHost.Type] = [ + .legacy: NativeListCell.self, .message: NativeListMessageCell.self, + ] + static func key(for type: String) -> NativeListRendererKey { + type == "message" ? .message : .legacy + } + static func reuseIdentifier(for key: NativeListRendererKey) -> String { + "NativeList.\(key.rawValue)" + } + static func register(in collectionView: UICollectionView) { + for (key, host) in hosts { + collectionView.register(host, forCellWithReuseIdentifier: reuseIdentifier(for: key)) + } + } + static func measure(_ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String) + -> CGFloat? + { + guard item.rendererKey == .message else { return nil } + return NativeListMessageRenderer.Resolved(item, theme: theme, layout: layout).measure( + width: width) + } +} diff --git a/native-views/react-native-native-list/ios/NativeListResolvedText.swift b/native-views/react-native-native-list/ios/NativeListResolvedText.swift new file mode 100644 index 000000000..861f5fe18 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListResolvedText.swift @@ -0,0 +1,87 @@ +import UIKit + +struct NativeListResolvedText { + let text: String + let font: UIFont + let color: UIColor + let lineHeight: CGFloat + let lines: Int + let alignment: NSTextAlignment + let breakMode: NSLineBreakMode + let verticalAlignment: String? + let offsetY: CGFloat + let explicitLineHeight: Bool + let explicitTruncation: Bool + + init( + _ value: String, style: [String: Any]?, size: CGFloat, weight: NativeListFontWeight = .regular, + color: UIColor, lineHeight: CGFloat, lines: Int, breakMode: NSLineBreakMode = .byTruncatingTail + ) { + let style = style ?? [:] + self.lines = min(3, max(1, style.int("lines", default: lines))) + explicitTruncation = style["lines"] != nil || style["truncate"] != nil + text = + self.lines == 1 && explicitTruncation + ? value.replacingOccurrences(of: "\r\n|[\r\n]", with: " ", options: .regularExpression) + : value + let resolvedWeight: NativeListFontWeight + switch style.string("fontWeight") { + case "regular": resolvedWeight = .regular + case "medium": resolvedWeight = .medium + case "semibold": resolvedWeight = .semibold + case "bold": resolvedWeight = .bold + default: resolvedWeight = weight + } + font = nativeListFont( + ofSize: CGFloat(style.double("fontSize", default: Double(size))), weight: resolvedWeight) + self.color = + (style["color"] as? String).map { UIColor(nativeListHex: $0, fallback: color) } ?? color + self.lineHeight = CGFloat(style.double("lineHeight", default: Double(lineHeight))) + explicitLineHeight = style["lineHeight"] != nil + alignment = + style.string("alignment") == "center" + ? .center : style.string("alignment") == "end" ? .right : .natural + self.breakMode = + explicitTruncation + ? (style.string("truncate", default: "tail") == "clip" + ? (self.lines == 1 ? .byClipping : .byWordWrapping) : .byTruncatingTail) : breakMode + verticalAlignment = style["verticalAlignment"] as? String + offsetY = CGFloat(style.double("offsetY")) + } + + var attributed: NSAttributedString { + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = lineHeight + paragraph.maximumLineHeight = lineHeight + paragraph.alignment = alignment + if explicitTruncation { paragraph.lineBreakMode = breakMode } + var attributes: [NSAttributedString.Key: Any] = [ + .font: font, .foregroundColor: color, .paragraphStyle: paragraph, + ] + if explicitLineHeight { + attributes[.baselineOffset] = max(0, (lineHeight - font.lineHeight) / 2) + } + return NSAttributedString(string: text, attributes: attributes) + } + + func bind(_ label: NativeListTextLabel) { + label.font = font + label.textColor = color + label.numberOfLines = lines + label.textAlignment = alignment + label.lineBreakMode = breakMode + label.rowVerticalAlignment = verticalAlignment + label.rowOffsetY = offsetY + label.attributedText = attributed + label.isHidden = text.isEmpty + } + + func measure(width: CGFloat) -> CGFloat { + guard !text.isEmpty else { return 0 } + // UILabel uses the same attributed metrics and line cap as the bound view. + let label = NativeListTextLabel() + bind(label) + return ceil( + label.sizeThatFits(CGSize(width: max(1, width), height: .greatestFiniteMagnitude)).height) + } +} diff --git a/native-views/react-native-native-list/ios/NativeListRowHost.swift b/native-views/react-native-native-list/ios/NativeListRowHost.swift new file mode 100644 index 000000000..1a469a788 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListRowHost.swift @@ -0,0 +1,32 @@ +import UIKit + +// Common list-facing lifecycle. Legacy templates and migrated renderers share +// this contract, not their view allocation or reset implementation. +class NativeListRowHost: UICollectionViewCell { + var bindingEpoch = 0 + var listStyle: [String: Any]? + var onAction: ((NativeListItem, String, NativeSelectionTarget?, NativeListActionOrigin?) -> Void)? + var onBindingInvalidated: ((NativeListRowHost, Int) -> Void)? + + func bind( + item: NativeListItem, theme: [String: Any]?, layout: String, itemIndex: Int? = nil, + selected: Bool, checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + preconditionFailure("A row host must implement binding") + } + func updateSelection( + item: NativeListItem, selected: Bool, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) {} + func updateMarketQuote(_ item: NativeListItem, theme: [String: Any]?) {} + func setPressed(_ pressed: Bool) { isHighlighted = pressed && isUserInteractionEnabled } + func canStartWalletGroupReorder(at point: CGPoint) -> Bool { true } + func setWalletGroupReorderCompact(_ compact: Bool) {} + func prepareWalletGroupReorderExpansion() {} + func animateWalletGroupReorderExpansion() {} + func finishWalletGroupReorderExpansion() {} + func rowActionOrigin() -> NativeListActionOrigin { + NativeListActionOrigin( + sourceView: contentView, ownerCell: self, bindingEpoch: bindingEpoch, source: "row") + } +} diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index b47282888..2196db2dc 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -7,7 +7,7 @@ final class NativeListView: UIView { private final class ActionAnchorRecord { let token: String weak var sourceView: UIView? - weak var ownerCell: NativeListCell? + weak var ownerCell: NativeListRowHost? let bindingEpoch: Int var open = false var invalidatedReason: String? @@ -102,13 +102,13 @@ final class NativeListView: UIView { private var keyboardDismissMode: NativeListKeyboardDismissMode = .none private var keyboardShouldPersistTaps: NativeListKeyboardShouldPersistTaps = .never private var interactiveReorderSource: (key: String, index: Int)? - private weak var interactiveReorderCell: NativeListCell? + private weak var interactiveReorderCell: NativeListRowHost? private var interactiveReorderCompactKey: String? private var interactiveReorderUsesAtomicTargeting = false private var interactiveReorderTargetIndex: Int? private var interactiveReorderTargetKey: String? private var interactiveReorderLockedCrossAxisPosition: CGFloat? - private var interactiveReorderTransformedCells: [NativeListCell] = [] + private var interactiveReorderTransformedCells: [NativeListRowHost] = [] private var deferredReorderReconfigureKeys = Set() private let interactiveReorderPlaceholder = UIView() private var interactiveReorderAnimator: UIViewPropertyAnimator? @@ -129,9 +129,7 @@ final class NativeListView: UIView { override init(frame: CGRect) { super.init(frame: frame) - for renderer in NativeListRendererKey.allCases { - collectionView.register(NativeListCell.self, forCellWithReuseIdentifier: NativeListCell.reuseIdentifier(for: renderer)) - } + NativeListRendererRegistry.register(in: collectionView) collectionView.backgroundColor = .clear collectionView.delegate = self collectionView.dragDelegate = self @@ -230,9 +228,9 @@ final class NativeListView: UIView { guard let self, let item = self.itemsByKey[key], let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: NativeListCell.reuseIdentifier(for: item.rendererKey), + withReuseIdentifier: NativeListRendererRegistry.reuseIdentifier(for: item.rendererKey), for: indexPath - ) as? NativeListCell else { return nil } + ) as? NativeListRowHost else { return nil } cell.onAction = { [weak self] item, action, target, origin in self?.handleAction(item: item, actionKey: action, target: target, origin: origin) } @@ -455,7 +453,7 @@ final class NativeListView: UIView { for indexPath in collectionView.indexPathsForVisibleItems { guard let item = item(at: indexPath), marketQuoteKeys.contains(item.key), - let cell = collectionView.cellForItem(at: indexPath) as? NativeListCell else { continue } + let cell = collectionView.cellForItem(at: indexPath) as? NativeListRowHost else { continue } cell.updateMarketQuote(item, theme: current.theme) } if changedKeys.isEmpty { @@ -696,7 +694,7 @@ final class NativeListView: UIView { let indexPath = collectionView.indexPathForItem(at: point), let item = item(at: indexPath), item.isReorderable, - let cell = collectionView.cellForItem(at: indexPath) as? NativeListCell, + let cell = collectionView.cellForItem(at: indexPath) as? NativeListRowHost, cell.canStartWalletGroupReorder(at: gesture.location(in: cell)) else { interactiveReorderSource = nil interactiveReorderCell = nil @@ -992,9 +990,9 @@ final class NativeListView: UIView { animator.startAnimation() } - private func walletGroupCell(for key: String) -> NativeListCell? { + private func walletGroupCell(for key: String) -> NativeListRowHost? { guard let index = dataSource.snapshot().indexOfItem(key) else { return nil } - return collectionView.cellForItem(at: IndexPath(item: index, section: 0)) as? NativeListCell + return collectionView.cellForItem(at: IndexPath(item: index, section: 0)) as? NativeListRowHost } // OneKey patch: flow layout sizes an interactive move in UIKit's in-flight order, @@ -1028,7 +1026,7 @@ final class NativeListView: UIView { let distance = CGFloat(68) + flowLayout.minimumLineSpacing let updates = { [weak self] in guard let self else { return } - for case let cell as NativeListCell in self.collectionView.visibleCells { + for case let cell as NativeListRowHost in self.collectionView.visibleCells { guard let indexPath = self.collectionView.indexPath(for: cell), indexPath.item != source.index else { continue } let offset: CGFloat @@ -1327,7 +1325,7 @@ final class NativeListView: UIView { bind(cell: footerCell, item: footer, itemIndex: nil) } - private func bind(cell: NativeListCell, item: NativeListItem, itemIndex: Int? = nil) { + private func bind(cell: NativeListRowHost, item: NativeListItem, itemIndex: Int? = nil) { guard let config else { return } cell.listStyle = config.listStyle cell.bind( @@ -1372,7 +1370,7 @@ final class NativeListView: UIView { item.isRowPressEnabled else { return } let actionKey = item.data.string("longPressActionKey") guard !actionKey.isEmpty else { return } - let origin = (collectionView.cellForItem(at: indexPath) as? NativeListCell)?.rowActionOrigin() + let origin = (collectionView.cellForItem(at: indexPath) as? NativeListRowHost)?.rowActionOrigin() origin?.windowPoint = gesture.location(in: window) handleAction(item: item, actionKey: actionKey, target: nil, origin: origin) } @@ -1454,7 +1452,7 @@ final class NativeListView: UIView { } for indexPath in collectionView.indexPathsForVisibleItems { guard let item = item(at: indexPath), - let cell = collectionView.cellForItem(at: indexPath) as? NativeListCell else { continue } + let cell = collectionView.cellForItem(at: indexPath) as? NativeListRowHost else { continue } if changedSummaryKeys.contains(item.key) { bind(cell: cell, item: item, itemIndex: indexPath.item) continue @@ -1684,7 +1682,8 @@ final class NativeListView: UIView { private func rowHeight( _ item: NativeListItem, - usingCompactReorderHeight: Bool = true + usingCompactReorderHeight: Bool = true, + allocatedWidth: CGFloat? = nil ) -> CGFloat { // OneKey patch: honor selector baseline geometry; keep compact drag sizing. if item.type == "walletGroup", usingCompactReorderHeight, item.key == interactiveReorderCompactKey { return 68 } @@ -1727,11 +1726,18 @@ final class NativeListView: UIView { if item.type == "identity", item.data.string("presentation") == "networkSelector" { return 47 } + let availableWidth = max(0, collectionView.bounds.width - flowLayout.sectionInset.left - flowLayout.sectionInset.right) + let columnWidth = config?.layout == "grid" + ? floor((availableWidth - CGFloat((config?.gridColumns ?? 2) - 1) * (config?.itemSpacing ?? 0)) / CGFloat(config?.gridColumns ?? 2)) + : availableWidth + let rowWidth = allocatedWidth ?? (config?.orientation == "horizontal" ? 280 : columnWidth) + if let height = NativeListRendererRegistry.measure(item, width: rowWidth, theme: config?.theme, layout: config?.layout ?? "linear") { + return max(0, height + (item.data.string("size") == "small" ? -8 : item.data.string("size") == "large" ? 12 : 0)) + } let base: CGFloat switch item.type { case "rail": base = 40 case "activity": base = item.data.dictionaries("footerActions").isEmpty ? 60 : 100 - case "message": base = messageHeight(item) case "mediaTile": base = 244 case "metricCard": base = item.data.string("variant") == "activity" @@ -1821,13 +1827,6 @@ final class NativeListView: UIView { } } - private func messageHeight(_ item: NativeListItem) -> CGFloat { - NativeListMessageRenderer.measure( - item, availableWidth: collectionView.bounds.width, - contentPaddingHorizontal: config?.contentPaddingHorizontal ?? 0 - ) - } - private func emit(_ block: ((String) -> Void)?, _ value: [String: Any]) { guard JSONSerialization.isValidJSONObject(value), let data = try? JSONSerialization.data(withJSONObject: value), @@ -1909,7 +1908,7 @@ final class NativeListView: UIView { reorderLongPress.delegate = nil marketLongPress.delegate = nil - for case let cell as NativeListCell in collectionView.visibleCells { + for case let cell as NativeListRowHost in collectionView.visibleCells { cell.onAction = nil cell.onBindingInvalidated = nil cell.prepareForReuse() @@ -1976,7 +1975,7 @@ final class NativeListView: UIView { && (sourceView === ownerCell.contentView || sourceView.isDescendant(of: ownerCell.contentView)) } - private func handleBindingInvalidated(cell: NativeListCell, epoch: Int) { + private func handleBindingInvalidated(cell: NativeListRowHost, epoch: Int) { guard let anchor = actionAnchor, anchor.ownerCell === cell, anchor.bindingEpoch == epoch else { return } @@ -2182,10 +2181,10 @@ extension NativeListView: UICollectionViewDelegateFlowLayout { if config.layout == "grid", !structural { let spacing = CGFloat(config.gridColumns - 1) * config.itemSpacing let width = floor((available - spacing) / CGFloat(config.gridColumns)) - let height = item.styledHeight ?? (item.type == "mediaTile" && item.data["height"] == nil ? width + 48 : rowHeight(item)) + let height = item.styledHeight ?? (item.type == "mediaTile" && item.data["height"] == nil ? width + 48 : rowHeight(item, allocatedWidth: width)) return CGSize(width: width, height: height) } - return CGSize(width: available, height: rowHeight(item)) + return CGSize(width: available, height: rowHeight(item, allocatedWidth: available)) } private func railWidth(_ item: NativeListItem) -> CGFloat { @@ -2209,7 +2208,7 @@ extension NativeListView: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = config?.items[safe: indexPath.item] else { return } if item.type == "walletGroup" { return } - let origin = (collectionView.cellForItem(at: indexPath) as? NativeListCell)?.rowActionOrigin() + let origin = (collectionView.cellForItem(at: indexPath) as? NativeListRowHost)?.rowActionOrigin() handleRowPress(item, origin: origin) } @@ -2222,7 +2221,7 @@ extension NativeListView: UICollectionViewDelegateFlowLayout { guard let item = config?.items[safe: indexPath.item], item.type == "market" else { return } let actionKey = item.data.string("pressInActionKey") guard !actionKey.isEmpty else { return } - let origin = (collectionView.cellForItem(at: indexPath) as? NativeListCell)?.rowActionOrigin() + let origin = (collectionView.cellForItem(at: indexPath) as? NativeListRowHost)?.rowActionOrigin() handleAction(item: item, actionKey: actionKey, target: nil, origin: origin) } From ac91a3385629447ac9c392f4fdec9ca2adfb4fb0 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 22:06:50 +0800 Subject: [PATCH 30/71] fix(native-list): isolate Message image styles from corner badges --- .../src/__tests__/NativeList.web.test.ts | 13 +++++++++++-- .../src/web/templates/MessageRowRenderer.ts | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index 2626b7e4a..ed2fe58f4 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -1018,7 +1018,11 @@ describe('web row style', () => { body: 'Body', time: 'Now', height: 136, - leading: { kind: 'image', image: { ...image, retryTimes: 2 } }, + leading: { + kind: 'token', + image: { ...image, retryTimes: 2 }, + networkImage: image, + }, thumbnail: image, } as const; const engine = new NativeListWebEngine( @@ -1035,7 +1039,11 @@ describe('web row style', () => { const body = row().firstElementChild!; const title = body.querySelector('[data-nl-slot="title"]')!; const images = Array.from(body.querySelectorAll('img')); - expect(images).toHaveLength(2); + expect(images).toHaveLength(3); + const corner = body.querySelector( + '.ok-native-list-visual-corner' + )!; + const cornerStyle = corner.style.cssText; engine.applySnapshot( snapshot({ kind: 'sectioned' }, [ { @@ -1051,6 +1059,7 @@ describe('web row style', () => { expect(row().firstElementChild).toBe(body); expect(body.querySelector('[data-nl-slot="title"]')).toBe(title); expect(title.textContent).toBe('Updated'); + expect(corner.style.cssText).toBe(cornerStyle); expect(Array.from(body.querySelectorAll('img'))).toEqual(images); engine.applySnapshot(snapshot({ kind: 'sectioned' }, [message])); expect(Array.from(body.querySelectorAll('img'))).toEqual(images); diff --git a/native-views/react-native-native-list/src/web/templates/MessageRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/MessageRowRenderer.ts index 7525704d5..a4d349eea 100644 --- a/native-views/react-native-native-list/src/web/templates/MessageRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/MessageRowRenderer.ts @@ -215,7 +215,7 @@ export function bindMessageRow( const images = leading.matches('img') ? [leading] : leading.querySelectorAll( - ':scope > img, :scope > .ok-native-list-visual-fallback' + ':scope > img:not(.ok-native-list-visual-corner), :scope > .ok-native-list-visual-fallback:not(.ok-native-list-visual-corner)' ); images.forEach((bitmap) => { if (bitmap !== leading) { From 5c9c71d84bbd3f15a795add1e3f48aaf0283dc1d Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 22:09:21 +0800 Subject: [PATCH 31/71] docs(native-list): close baseline and Message migration stages --- .../react-native-native-list/docs/DESIGN.md | 108 ++++++++---------- .../react-native-native-list/docs/SPEC.md | 39 ++++--- .../docs/STYLE_SPEC.md | 34 +++--- 3 files changed, 87 insertions(+), 94 deletions(-) diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index f44b67c0e..f02de49dc 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -80,28 +80,33 @@ ownership is container → template registry → renderer → shared primitives: the same resolved inputs. Height precedence remains `style.container.height` → `row.height` → template measurement/default. There is no automatic fit pass. - Full binding must be correct without a preceding recycle callback. Image - descriptors that did not change should retain their bindings. Async work and - action anchors must still be invalidated by the host's binding epoch. + descriptors that did not change should retain their bindings. Image requests have their own + slot epoch; source changes/recycle invalidate them. Row rebind/recycle still + invalidates action anchors through the host's binding epoch. -Migration begins with `message`, one platform per commit. Its current boundary: +Message completes the first renderer pilot, with one platform per commit: -| Platform | Extracted | Still owned by the existing host | +| Platform | Renderer and host ownership | Container ownership | | --- | --- | --- | -| Web | DOM structure, semantic text roles, estimated measurement in `src/web/templates/MessageRowRenderer.ts` | Shared style/asset primitives; wrapper pooling and body replacement | -| Android | Text subtree allocation, default typography/reset, content binding and direct title/body/time style targets in `NativeListMessageRenderer.kt` | Legacy host allocation, image slots, box styles, measurement and style restoration | -| iOS | Text subtree allocation, default typography/reset, content binding, semantic text targets and existing measured height in `NativeListMessageRenderer.swift` | Legacy host allocation, image slots, box styles, style restoration and image lifecycle | - -The migration still keeps existing platform defaults and the native monolithic -hosts. It does **not** yet establish a renderer registry, lightweight native -hosts or complete resolved-style pipeline. Native Message text views are now -allocated lazily by the renderer and no longer borrow the legacy title/body/time -slots. Their text primitives are shared with the legacy templates; line-gap -styling targets the renderer's own column. The legacy host still allocates its -unused text views, so this intermediate extraction makes no allocation savings -claim. [SPEC.md](SPEC.md#conformance-and-six-stage-migration) tracks all six stages. -The callback parameters are temporary adapters to the existing image/text -primitives, not a public plugin API. Shared legacy slot maps still serve -unmigrated templates and will be retired as ownership moves. +| Web | `MessageRowRenderer`: persistent body/text/image nodes, resolved styles, update classification and intrinsic estimate/DOM measurement | Registered family pooling, placement, shared row appearance/events and layout correction | +| Android | `NativeListMessageRowView`: lightweight host; `NativeListMessageRenderer`: owned column, resolved text/spacing/images and update classification; native `onMeasure` consumes the bound views | Registered holder factories, DiffUtil, selection/reorder routing and viewport allocation | +| iOS | `NativeListMessageCell`: lightweight host; `NativeListMessageRenderer`: owned column, resolved text/spacing/images and width-aware measurement | Registered cell factories, data-source reload/reconfigure, selection/reorder routing and viewport allocation | + +`NativeListResolvedText`, `NativeListLeadingVisual` and `NativeListImageSlot` +are internal primitives. The image slot owns request identity, bounded retries, +completion-once guards and stale-write invalidation. A text/style-only update +retains the existing image slot and, when its effective image request is +unchanged, the request. Geometry/content-fit changes may require the image +module to decode/request again. The existing bounded source-fallback cache is +shared with legacy visuals. Image slots are lazy and bounded to the template's +visible assets; Message never allocates the unused legacy Market/Wallet/table +subtrees. This is source-level ownership evidence, not a performance benchmark. + +The renderer resolves defaults from current data/theme/style rather than +capturing a previous row's view state. Legacy global slot maps no longer contain +Message. No public plugin API or Nitro schema is added. Remaining template maps +and partial updates are retired only with their own migration. See +[SPEC.md](SPEC.md#conformance-and-six-stage-migration) for all six stages. Structural reuse is now partitioned into two internal families on all platforms: `message` and `legacy`. Unmigrated templates retain their existing shared tree. @@ -115,47 +120,11 @@ to replace an incompatible holder. Market quote and selection payloads retain their existing full/partial update decisions. Footer and nested-row hosts remain outside these scrolling pools. -Both native families still allocate the legacy cell/row class. The next Message -step extracts the remaining shared image primitives and replaces that class with -a lightweight host for the renderer-owned tree, connecting its factory to the -existing reuse family. Resolved styles and update classification still need to -move into the renderer. Preserve Market quote and selection update paths -throughout migration. -Move the remaining simple templates only after this lifecycle works on all -three platforms; migrate Market/Identity/Header and composed wallet groups last. - -Acceptance for every step includes style set/change/clear, binding A → B without -recycle, same-key template changes, scrolling away/back, image add/remove, -explicit height restoration and shared footer placement. Keep geometry and -typography changes in separate commits from structural extraction. - -The first extraction was checked on 2026-09-22: - -- Web: 149 tests, typecheck and lint (zero errors); actual Chrome desktop and - 390px RTL rendering, rapid style/template switching, image decoding and - scrolling through 41 messages. Styled height 192 returns to model height 136. -- Android: Kotlin build and seven unit tests; an isolated RN app using the real - NativeList/Image/Logger/Skeleton/Nitro packages ran on API 36. Style clearing, - same-key Message/Identity switching, content updates, image add/remove, - scroll-to-end/back and the fixed footer were checked. At the test device's - density, legacy model height 136 rendered as 122px through the existing scale; - explicit style height rendered as 192px and clearing restored 122px. The - styled three-line body measured 66px; clearing restored its one-line box. -- iOS: the isolated RN app compiled and linked with the real - NativeList/Image/Logger/Skeleton/Nitro packages, then ran on a dedicated - iPhone 17 Pro simulator with iOS 26.5. Its data volume is an APFS sparsebundle - stored on the external drive and mounted at the simulator's standard data - directory. Style set/clear restored row height 136 → 192 → 136pt and body - height 20 → 66 → 20pt. Same-key Message/Identity switching, content updates, - leading image/thumbnail add/remove and scrolling to row 39/back passed; - fixed-footer placement stayed unchanged. Screenshots, accessibility trees - and a screen recording were retained. The build used the example app's iOS - 16.4 deployment target for all pods because Xcode 27 rejects older pod targets. - Sampled gallery scrolling and style off/on checks also covered Message - appearance reset and top/center/bottom container alignment with explicit height. - -These results cover the Message extraction, not native acceptance of every -template or completion of the target architecture. +The two native reuse families now allocate different classes through the closed +registry. The list only uses `NativeListRowHost` lifecycle operations; Message +binds and measures its own resolved model. Market quote, selection echo, +WalletGroup compact/expanded reorder and fixed-footer behavior remain on their +existing paths. The next step is stage 3: migrate simple templates one at a time. The structural reuse follow-up on 2026-09-22 passed 150 package tests, typecheck, lint (zero errors), the Android build/seven unit tests and a full iOS build. @@ -181,6 +150,25 @@ screenshots, accessibility trees and an iOS recording were retained outside the repository. These checks cover this text-subtree increment, not completion of stage 2 or full native acceptance of every template. +The Message host completion on 2026-09-22 adds the implicit-key inventory and +finishes stage 2. Package regression tests cover persistent text/image identity, +style clearing, removed-image cleanup and incompatible-family reuse. Both native +apps were rebuilt and installed on the dedicated external-drive iOS 26.5/API 36 +simulators. Runtime cases cover style/height set-clear, intrinsic height with +missing fields, narrow grid columns, local/HTTP images, text-only image updates, +failed-image fallback/recovery, delayed-source removal, mixed-family swaps, +empty/repopulation and scrolling with the common fixed footer. Chrome also runs +these flows at desktop width and 390px RTL. The baseline explicit row heights +remain iOS 136 → 192 → 136pt and Android 122 → 192 → 122px on the narrow device; +Web remains 136 → 192 → 136px. Automatic measurement now follows actual resolved +metrics (the title/body/time intrinsic example is iOS 114pt and Web 74px), +including the allocated iOS grid column width. A grid text-only patch shrinks +the Message from 114 to 94pt; the same full-width text would occupy one line, +so patch invalidation is checked against the actual column. The Web primary +image style leaves network/corner decorations at their original dimensions. Runtime evidence is retained in +the task's external validation directory. The dated earlier paragraphs describe +intermediate commits, not the current stage-2 boundary. + `linear`, `sectioned`, `grid`, and `table` are observable native container semantics. Linear is a full-width stream. Sectioned adds a visual break before section-header rows and can pin those rows. Grid allocates native spans and diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index 341347da5..9727f79a0 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -67,9 +67,9 @@ migration adds no new input limits, retry policy or automatic layout correction. ## Performance and resources Snapshots and patches cross the bridge in batches. Visible rows use platform -recycling/windowing; shared primitives own image cancellation. The Message -pilot still allocates the legacy native host, so renderer extraction alone is -not evidence of lower allocations or faster scrolling. Performance claims need +recycling/windowing; shared primitives own image cancellation. Message uses a dedicated lightweight native host and persistent Web body. +The legacy native tree is not allocated for Message. This structural change is +not a measured scrolling-performance result; performance claims still need a separate workload and measurements. ## Conformance and six-stage migration @@ -77,28 +77,31 @@ separate workload and measurements. | Stage | Current status | Exit condition | | --- | --- | --- | | 1. Baseline | Complete: [implicit keys, defaults, special updates and acceptance baseline](MIGRATION_BASELINE.md) inventoried | Preserve each existing rule or explicitly migrate its caller | -| 2. Message pilot | In progress: three-platform binding/reuse families and renderer-owned text subtrees implemented; Message runtime cases verified | Lightweight native hosts, resolved styling/measurement, update classification and lifecycle acceptance | +| 2. Message pilot | Complete: registered lightweight native hosts, persistent Web body, resolved styling/measurement and lifecycle acceptance | Lightweight native hosts, resolved styling/measurement, update classification and lifecycle acceptance | | 3. Simple templates | Not started | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | | 4. Complex templates | Not started | Migrate Market, Identity and SectionHeader while preserving quote, selection and header behavior | | 5. WalletGroup | Not started | Independent member renderers; verify member events/styles, compact dragging and height restoration | | 6. Cleanup | Not started | Remove migrated key inference and global style maps; container no longer manipulates template internals | -Native Message now owns its text subtree and typography; Web owns its DOM -structure and still uses shared CSS defaults. Shared leading/thumbnail image -slots and generic native host lifecycle stay in place -until their primitives are extracted. The legacy host remains allocated. - -Stage 2 completion requires registered Message factories to create lightweight -hosts on both native platforms and a persistent Web Message body. The renderer -resolves text/spacing/image inputs from data/theme/styles, classifies updates, -and binds/measures using that resolved model. Unchanged assets keep their slot -and request identity; changed/removed assets invalidate stale callbacks and -retries. The legacy Message binding and slot-map branches must be removed. - -Message intrinsic measurement will use its actual resolved view metrics rather +The closed internal registry selects Message or the legacy family. Message owns +its text column, optional leading visual and thumbnail; its native host does not +allocate Market, WalletGroup or table views. Web preserves the Message body, +text nodes and unchanged image elements when rebinding. Remaining templates +continue through the legacy family. + +Message resolves text/spacing/image inputs from data/theme/styles, classifies +updates as unchanged, content/style, assets or replacement, and binds/measures +using those resolved values. Image slots retain an unchanged effective request; +source/row/slot or image-request-option changes invalidate old callbacks and +retries. Image request epochs are independent of row action-anchor epochs, so +text-only updates preserve in-flight images. Legacy Message binding, slot-map +and measurement branches have been removed. The named Android separator-key +compatibility policy is retained outside the renderer's style resolver. + +Message intrinsic measurement now uses its actual resolved view metrics rather than the historical estimator-only defaults listed in MIGRATION_BASELINE.md. This can change **automatic** Message heights; explicit row/container heights -and unstyled drawing defaults remain unchanged. Native measurement must account +and unstyled drawing defaults remain unchanged. Native measurement accounts for the allocated column width, line limits, gaps and optional images. Web uses the same resolved geometry with a conservative text estimate, then corrects the visible intrinsic height from its rendered DOM; it does not auto-fit content. diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index 541caa692..8fce50b05 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -200,9 +200,9 @@ Radius steps in use: `4` (chip), `8` (rail, small visual), `10` (visual `rounded `style.X` modifies the rendering of the **model field named `X`**, regardless of which physical view carries it. This matters because legacy view slots are shared: on -every platform `metricCard` renders its *value* through the title label and its +native legacy `metricCard` renders its *value* through the title label and its *title* through the subtitle label, and the status label carries `rail.status`, -`activity.status`, `message.time`, and `metricCard.trend`. Naming style keys after +`activity.status` and `metricCard.trend`. Naming style keys after views would therefore mis-target. `market` already follows this rule with `style.price` / `style.change`. @@ -649,8 +649,11 @@ Row height is computed from data by three independent implementations — | `sectionHeader` value + checkbox | 56 | 40 | 56 | | `message`, `mediaTile`, composite `metricCard` | computed / 244 / 160+ | 0 (wrap content) | computed / 244 / 161 | -Within iOS, `NativeListCell.bindMessage()` sizes the leading slot at 28 while -`rowHeight()` estimates text width against 40; `bindSystem()` uses 52/120 for a +Message now uses its resolved text/spacing/image metrics for intrinsic +measurement; iOS measures the actual allocated column width and Web corrects +its conservative estimate from the rendered DOM. This explicitly fixes the old +Message estimator-only fallback differences. Explicit/model heights keep their +precedence. Other legacy mismatches remain: iOS `bindSystem()` uses 52/120 for a market retry row where `rowHeight()` uses 44, and 88 for `noMatch` where `rowHeight()` uses 44. @@ -822,23 +825,22 @@ All three are implemented as `applyRowStyle`. | Android | `NativeListRowView.bind()`, **after** `applySize(item)` | `applySize` re-dispatches font size and typeface by row type and would otherwise overwrite the style | | Web | `renderElement()`, after template and presentation defaults | Wallet-group members apply their own local pass before the group pass | -Both native platforms use `resetRowStyle` before the binder, per §7 rule 2. +Both native legacy hosts use `resetRowStyle` before the binder, per §7 rule 2. They capture the bound text view's defaults before modifying it and restore that state before the normal template reset. This includes text metrics, color and alignment; iOS also restores attributed text. This avoids guessing one baseline for views shared by different templates. Device reuse checks remain required. -Message is the first incremental renderer extraction: its renderer allocates and -resets the native title/body/time subtree, and text styles and line gaps target -that subtree directly. Box styles and style restoration still run through the -existing host. Web Message owns its DOM -structure and measurement in a separate module. All three scrolling hosts now -partition Message from legacy reuse; style changes keep the same family, while -cross-family template changes replace the host. The legacy native host and image -slots are still allocated; lightweight hosts and resolved style inputs shared -with measurement remain pending. See -[DESIGN.md](DESIGN.md#incremental-renderer-migration) for the current ownership, -remaining migration steps and acceptance scope. +Message completes the first renderer pilot. Its native registry creates a +lightweight host, and its renderer owns the title/body/time column and optional +image slots. Text, box and image styles resolve from current defaults/data/theme; +clearing a style rebinds those defaults without using the legacy restoration map. +Native measurement and binding share resolved inputs; Web keeps a persistent +body and corrects intrinsic estimates from DOM measurements. Unchanged effective +image requests survive text-only binding. All three platforms separate Message +from legacy reuse; cross-family changes replace the host. See +[DESIGN.md](DESIGN.md#incremental-renderer-migration) for source boundaries and +runtime acceptance. Stages 3–6 remain pending. The existing market helpers generalize rather than being rewritten: `applyMarketTextStyle` / `applyMarketButtonStyle` / `marketAttributedText` (iOS), From fc6f42cb2198cbe12be73b1fb9bb880a44fe956c Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 22:37:58 +0800 Subject: [PATCH 32/71] docs(native-list): define simple-template migration acceptance --- native-views/react-native-native-list/docs/SPEC.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index 9727f79a0..8b66ae561 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -78,7 +78,7 @@ separate workload and measurements. | --- | --- | --- | | 1. Baseline | Complete: [implicit keys, defaults, special updates and acceptance baseline](MIGRATION_BASELINE.md) inventoried | Preserve each existing rule or explicitly migrate its caller | | 2. Message pilot | Complete: registered lightweight native hosts, persistent Web body, resolved styling/measurement and lifecycle acceptance | Lightweight native hosts, resolved styling/measurement, update classification and lifecycle acceptance | -| 3. Simple templates | Not started | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | +| 3. Simple templates | In progress: migrate one template across all three platforms per increment | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | | 4. Complex templates | Not started | Migrate Market, Identity and SectionHeader while preserving quote, selection and header behavior | | 5. WalletGroup | Not started | Independent member renderers; verify member events/styles, compact dragging and height restoration | | 6. Cleanup | Not started | Remove migrated key inference and global style maps; container no longer manipulates template internals | @@ -112,3 +112,14 @@ binding without recycle, same-key template changes, image add/remove, scrolling away/back, explicit height restoration and fixed-footer placement. Unit/type checks are not native runtime verification, and Message acceptance does not complete the remaining stages. + +Stage 3 extends the closed registry one template at a time. Migrated templates +own their view trees and semantic styles; native templates share the Message +host's appearance/event lifecycle and bounded image primitives, not a legacy +view tree. Preserve existing default geometry, variant behavior, actions, +selection echo and platform differences. Horizontal sizing belongs to each +renderer and must use its resolved text/image/spacing inputs. A style change +must invalidate horizontal placement when its measured width changes. Fixed +template height defaults remain defaults, not automatic content fitting. +DataRow keeps the inventoried `asset`-column Web badge behavior until a separate +caller migration; this extraction does not change which column owns badges. From 471903cfeddf83eab22063e0630464f88000f7e0 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 23:17:45 +0800 Subject: [PATCH 33/71] fix(native-list): default Android text offset to zero --- .../main/java/com/onekey/nativelist/NativeListResolvedText.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt index 8df98666c..bc633ccec 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt @@ -67,7 +67,7 @@ internal data class NativeListResolvedText( } } else { view.text = text - androidx.core.widget.TextViewCompat.setLineHeight(view, lineHeight) + if (lineHeight > 0) androidx.core.widget.TextViewCompat.setLineHeight(view, lineHeight) } view.visibility = if (text.isEmpty()) android.view.View.GONE else android.view.View.VISIBLE } @@ -129,7 +129,7 @@ internal data class NativeListResolvedText( truncation, style?.optString("truncate") == "clip", horizontal or vertical, - ((style?.optDouble("offsetY") ?: 0.0) * density).toFloat(), + ((style?.optDouble("offsetY", 0.0) ?: 0.0) * density).toFloat(), ) } } From 62620200c2d4b429c1a27c4ab56c41e2a25696dd Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 23:26:12 +0800 Subject: [PATCH 34/71] refactor(native-list): share lightweight renderer host lifecycle --- .../nativelist/NativeListMessageRenderer.kt | 259 +-------------- .../nativelist/NativeListRendererRowView.kt | 306 ++++++++++++++++++ .../ios/NativeListMessageRenderer.swift | 168 +--------- .../ios/NativeListRendererCell.swift | 175 ++++++++++ .../src/web/templates/MessageRowRenderer.ts | 103 +----- .../src/web/templates/RowVisual.ts | 86 +++++ 6 files changed, 611 insertions(+), 486 deletions(-) create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt create mode 100644 native-views/react-native-native-list/ios/NativeListRendererCell.swift create mode 100644 native-views/react-native-native-list/src/web/templates/RowVisual.ts diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt index ff7567b76..b698dd6c9 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt @@ -1,12 +1,9 @@ package com.margelo.nitro.nativelist -import android.graphics.Canvas import android.graphics.Color import android.graphics.Outline -import android.graphics.Paint import android.graphics.drawable.GradientDrawable import android.view.Gravity -import android.view.MotionEvent import android.view.View import android.view.ViewOutlineProvider import android.widget.LinearLayout @@ -15,13 +12,6 @@ import kotlin.math.roundToInt import org.json.JSONObject internal object NativeListMessageRenderer { - enum class Update { - UNCHANGED, - CONTENT, - ASSETS, - REPLACE, - } - data class Resolved( val title: NativeListResolvedText, val body: NativeListResolvedText, @@ -98,19 +88,6 @@ internal object NativeListMessageRenderer { ) } - fun update(old: NativeListItem?, next: NativeListItem): Update { - if (old == null || old.key != next.key || old.rendererKey != next.rendererKey) - return Update.REPLACE - if (old.content == next.content) return Update.UNCHANGED - if ( - listOf("leading", "thumbnail").any { - nativeListCanonical(old.json.opt(it)) != nativeListCanonical(next.json.opt(it)) - } - ) - return Update.ASSETS - return Update.CONTENT - } - class Views(private val context: ThemedReactContext) { val column = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } val title = NativeListTextView(context) @@ -229,237 +206,27 @@ internal object NativeListMessageRenderer { } } -internal class NativeListMessageRowView(private val reactContext: ThemedReactContext) : - NativeListRowHost(reactContext) { - private val views = NativeListMessageRenderer.Views(reactContext) - private var current: NativeListItem? = null - private var theme: JSONObject? = null - private var listLayout = "linear" - private var selected = false - private var sourceScale = false - private var inputSignature = "" - private var explicitHeight: Int? = null - private var touchPressed = false - private val separator = Paint(Paint.ANTI_ALIAS_FLAG) - - private fun dp(value: Int) = - if (sourceScale) (value * resources.displayMetrics.density).roundToInt() - else NativeListScale.dp(resources, value) - - private fun stylePx(value: Double) = (value * resources.displayMetrics.density).roundToInt() - - private fun color(value: String, fallback: Int = Color.TRANSPARENT) = - runCatching { parseNativeListColor(value) }.getOrDefault(fallback) - - init { - orientation = HORIZONTAL - setOnClickListener { - current - ?.takeIf { it.isRowPressEnabled } - ?.let { onRowPress?.invoke(it, NativeListActionOrigin(this, this, bindingEpoch, "row")) } - } - setOnTouchListener { _, event -> - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> touchPressed = current?.isRowPressEnabled == true - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL -> touchPressed = false - } - appearance() - false - } - } - - override fun bind( - item: NativeListItem, - theme: JSONObject?, - layout: String, - listOrientation: String, - itemIndex: Int?, - selected: Boolean, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - useSourceScale: Boolean, - ) { - val update = NativeListMessageRenderer.update(current, item) - val signature = - nativeListCanonical( - JSONObject() - .put("theme", theme) - .put("layout", layout) - .put("orientation", listOrientation) - .put("sourceScale", useSourceScale) - .put("listStyle", listStyle) - ) - sourceScale = useSourceScale - if (update != NativeListMessageRenderer.Update.UNCHANGED || signature != inputSignature) { - if (current != null) onBindingInvalidated?.invoke(this, bindingEpoch) - bindingEpoch++ - if (update == NativeListMessageRenderer.Update.REPLACE) { - views.recycle() - touchPressed = false - } - views.bind( - this, - NativeListMessageRenderer.resolve(reactContext, item, theme, sourceScale), - item, - theme, - sourceScale, - ) - inputSignature = signature - } - current = item - tag = item - this.theme = theme - listLayout = layout - this.selected = selected - explicitHeight = - item.styledHeight?.let(::stylePx) - ?: if (item.json.has("height")) - when (item.json.optString("heightRounding")) { - "floor" -> (item.json.optDouble("height") * resources.displayMetrics.density).toInt() - "nearest" -> stylePx(item.json.optDouble("height")) - else -> dp(item.json.optInt("height")) - } - else null - minimumHeight = - if (explicitHeight == null && item.json.optString("size") == "large") dp(12) else 0 - layoutParams = - (layoutParams - ?: android.view.ViewGroup.LayoutParams( - LayoutParams.MATCH_PARENT, - LayoutParams.WRAP_CONTENT, - )) - .apply { - width = if (listOrientation == "horizontal") dp(280) else LayoutParams.MATCH_PARENT - height = LayoutParams.WRAP_CONTENT - } - contentDescription = item.json.optString("accessibilityLabel", item.json.optString("title")) - setTag( - com.facebook.react.R.id.react_test_id, - item.json.optString("testID").takeIf { it.isNotEmpty() }, - ) - isEnabled = !item.json.optBoolean("disabled") - isClickable = item.isWholeRowInteractive - isFocusable = isClickable - if (!isEnabled) touchPressed = false - appearance() - requestLayout() - } +internal class NativeListMessageRowView(context: ThemedReactContext) : + NativeListRendererRowView(context) { + override val assetFields = listOf("leading", "thumbnail") + private val views = NativeListMessageRenderer.Views(context) - override fun bindSelection( + override fun bindContent( item: NativeListItem, theme: JSONObject?, layout: String, - itemIndex: Int?, - selected: Boolean, checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, ) { - if (current?.key != item.key) return - current = item - tag = item - this.theme = theme - this.selected = selected - listLayout = layout - appearance() - } - - private fun appearance() { - val item = current ?: return - val container = item.json.optJSONObject("style")?.optJSONObject("container") ?: JSONObject() - val showSelected = selected && (listLayout != "sectioned" || item.json.optBoolean("selected")) - val backgroundName = - if (touchPressed) "rowPressedBackground" - else if (showSelected) "rowSelectedBackground" else "rowBackground" - val backgroundFallback = - if (touchPressed) "#00000017" else if (showSelected) "#0000000F" else "#FFFFFF" - var fill = color(theme?.optString(backgroundName, backgroundFallback) ?: backgroundFallback) - if (!touchPressed) { - if (item.json.has("backgroundColor")) - fill = color(item.json.optString("backgroundColor"), fill) - if (container.has("backgroundColor")) - fill = color(container.optString("backgroundColor"), fill) - } - val position = item.json.optString("groupPosition") - val radius = - if (container.has("cornerRadius")) stylePx(container.optDouble("cornerRadius")).toFloat() - else if (listStyle?.has("groupCornerRadius") == true) - stylePx(listStyle!!.optDouble("groupCornerRadius")).toFloat() - else dp(12).toFloat() - val top = container.has("cornerRadius") || position in setOf("first", "single") - val bottom = container.has("cornerRadius") || position in setOf("last", "single") - val radii = FloatArray(8) { if (it < 4 && top || it >= 4 && bottom) radius else 0f } - outlineProvider = ViewOutlineProvider.BACKGROUND - clipToOutline = container.has("cornerRadius") - background = - GradientDrawable().apply { - setColor(fill) - cornerRadii = radii - } - foreground = - if (container.has("borderWidth")) - GradientDrawable().apply { - setColor(Color.TRANSPARENT) - cornerRadii = radii - setStroke( - stylePx(container.optDouble("borderWidth")), - color(container.optString("borderColor", "#00000000")), - ) - } - else null - alpha = - container.optDouble("opacity", item.json.optDouble("opacity", 1.0)).toFloat() * - if (isEnabled) 1f else 0.5f - separator.color = - color( - listStyle - ?.optJSONObject("separator") - ?.optString("color", theme?.optString("separator", "#0000001F") ?: "#0000001F") - ?: theme?.optString("separator", "#0000001F") - ?: "#0000001F" - ) - separator.strokeWidth = 1f - invalidate() - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - // Android's native measurement consumes the exact resolved text/image views. - super.onMeasure( - widthMeasureSpec, - explicitHeight?.let { MeasureSpec.makeMeasureSpec(it, MeasureSpec.EXACTLY) } - ?: heightMeasureSpec, + views.bind( + this, + NativeListMessageRenderer.resolve(reactContext, item, theme, sourceScale), + item, + theme, + sourceScale, ) } - override fun dispatchDraw(canvas: Canvas) { - super.dispatchDraw(canvas) - current?.takeIf(::nativeListLegacyShowsSeparator)?.let { - val start = - listStyle - ?.optJSONObject("separator") - ?.takeIf { it.has("inset") } - ?.let { stylePx(it.optDouble("inset")) } ?: dp(12) - canvas.drawLine(start.toFloat(), height - 1f, width.toFloat(), height - 1f, separator) - } - } + override fun recycleContent() = views.recycle() - override fun onInitializeAccessibilityNodeInfo( - info: android.view.accessibility.AccessibilityNodeInfo - ) { - super.onInitializeAccessibilityNodeInfo(info) - info.viewIdResourceName = getTag(com.facebook.react.R.id.react_test_id) as? String - } - - override fun recycle() { - if (current != null) onBindingInvalidated?.invoke(this, bindingEpoch) - bindingEpoch++ - current = null - tag = null - inputSignature = "" - touchPressed = false - views.recycle() - } - - override fun dispose() { - recycle() - views.dispose() - } + override fun disposeContent() = views.dispose() } diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt new file mode 100644 index 000000000..11b322983 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt @@ -0,0 +1,306 @@ +package com.margelo.nitro.nativelist + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.drawable.GradientDrawable +import android.view.MotionEvent +import android.view.ViewOutlineProvider +import com.facebook.react.uimanager.ThemedReactContext +import kotlin.math.roundToInt +import org.json.JSONObject + +internal enum class NativeListRendererUpdate { + UNCHANGED, + CONTENT, + ASSETS, + REPLACE, +} + +// Shared chrome and binding lifetime; each renderer owns its content views. +internal abstract class NativeListRendererRowView(protected val reactContext: ThemedReactContext) : + NativeListRowHost(reactContext) { + protected abstract fun bindContent( + item: NativeListItem, + theme: JSONObject?, + layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) + + protected abstract fun recycleContent() + + protected abstract fun disposeContent() + + protected open val assetFields: List = emptyList() + + private fun update(old: NativeListItem?, next: NativeListItem): NativeListRendererUpdate { + if (old == null || old.key != next.key || old.rendererKey != next.rendererKey) + return NativeListRendererUpdate.REPLACE + if (old.content == next.content) return NativeListRendererUpdate.UNCHANGED + if ( + assetFields.any { + nativeListCanonical(old.json.opt(it)) != nativeListCanonical(next.json.opt(it)) + } + ) + return NativeListRendererUpdate.ASSETS + return NativeListRendererUpdate.CONTENT + } + + protected open fun defaultHeight(item: NativeListItem, layout: String) = 0 + + protected open fun horizontalWidth(item: NativeListItem) = dp(280) + + protected open val defaultCornerRadius = 0 + protected open val showsSelection = true + protected open val pressedColorName = "rowPressedBackground" + protected open val pressedColorFallback = "#00000017" + private var current: NativeListItem? = null + private var theme: JSONObject? = null + private var listLayout = "linear" + private var selected = false + protected var sourceScale = false + private var inputSignature = "" + private var explicitHeight: Int? = null + private var touchPressed = false + private var reorderActive = false + private val separator = Paint(Paint.ANTI_ALIAS_FLAG) + + protected fun dp(value: Int) = + if (sourceScale) (value * resources.displayMetrics.density).roundToInt() + else NativeListScale.dp(resources, value) + + protected fun stylePx(value: Double) = (value * resources.displayMetrics.density).roundToInt() + + private fun color(value: String, fallback: Int = Color.TRANSPARENT) = + runCatching { parseNativeListColor(value) }.getOrDefault(fallback) + + init { + orientation = HORIZONTAL + setOnClickListener { + current + ?.takeIf { it.isRowPressEnabled } + ?.let { onRowPress?.invoke(it, NativeListActionOrigin(this, this, bindingEpoch, "row")) } + } + setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> touchPressed = current?.isRowPressEnabled == true + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> touchPressed = false + } + appearance() + false + } + } + + override fun bind( + item: NativeListItem, + theme: JSONObject?, + layout: String, + listOrientation: String, + itemIndex: Int?, + selected: Boolean, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + useSourceScale: Boolean, + ) { + val update = update(current, item) + val signature = + nativeListCanonical( + JSONObject() + .put("theme", theme) + .put("layout", layout) + .put("orientation", listOrientation) + .put("sourceScale", useSourceScale) + .put("listStyle", listStyle) + ) + sourceScale = useSourceScale + if (update != NativeListRendererUpdate.UNCHANGED || signature != inputSignature) { + if (current != null) onBindingInvalidated?.invoke(this, bindingEpoch) + bindingEpoch++ + if (update == NativeListRendererUpdate.REPLACE) { + recycleContent() + touchPressed = false + } + bindContent(item, theme, layout, checkboxState) + inputSignature = signature + } + current = item + tag = item + this.theme = theme + listLayout = layout + this.selected = selected + explicitHeight = + item.styledHeight?.let(::stylePx) + ?: if (item.json.has("height")) + when (item.json.optString("heightRounding")) { + "floor" -> (item.json.optDouble("height") * resources.displayMetrics.density).toInt() + "nearest" -> stylePx(item.json.optDouble("height")) + else -> dp(item.json.optInt("height")) + } + else null + val sizeDelta = + when (item.json.optString("size")) { + "small" -> -8 + "large" -> 12 + else -> 0 + } + minimumHeight = + if (explicitHeight == null) dp((defaultHeight(item, layout) + sizeDelta).coerceAtLeast(0)) + else 0 + layoutParams = + (layoutParams + ?: android.view.ViewGroup.LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.WRAP_CONTENT, + )) + .apply { + width = + if (listOrientation == "horizontal") horizontalWidth(item) + else LayoutParams.MATCH_PARENT + height = LayoutParams.WRAP_CONTENT + } + contentDescription = item.json.optString("accessibilityLabel", item.json.optString("title")) + setTag( + com.facebook.react.R.id.react_test_id, + item.json.optString("testID").takeIf { it.isNotEmpty() }, + ) + isEnabled = !item.json.optBoolean("disabled") + isClickable = item.isWholeRowInteractive + isFocusable = isClickable + if (!isEnabled) touchPressed = false + appearance() + requestLayout() + } + + override fun bindSelection( + item: NativeListItem, + theme: JSONObject?, + layout: String, + itemIndex: Int?, + selected: Boolean, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + if (current?.key != item.key) return + current = item + tag = item + this.theme = theme + this.selected = selected + listLayout = layout + appearance() + } + + private fun appearance() { + val item = current ?: return + val container = item.json.optJSONObject("style")?.optJSONObject("container") ?: JSONObject() + val showSelected = + showsSelection && selected && (listLayout != "sectioned" || item.json.optBoolean("selected")) + val backgroundName = + if (touchPressed || reorderActive) pressedColorName + else if (showSelected) "rowSelectedBackground" else "rowBackground" + val backgroundFallback = + if (touchPressed || reorderActive) pressedColorFallback + else if (showSelected) "#0000000F" else "#FFFFFF" + var fill = color(theme?.optString(backgroundName, backgroundFallback) ?: backgroundFallback) + if (!touchPressed && !reorderActive) { + if (item.json.has("backgroundColor")) + fill = color(item.json.optString("backgroundColor"), fill) + if (container.has("backgroundColor")) + fill = color(container.optString("backgroundColor"), fill) + } + val position = item.json.optString("groupPosition") + val radius = + if (container.has("cornerRadius")) stylePx(container.optDouble("cornerRadius")).toFloat() + else if (defaultCornerRadius > 0) dp(defaultCornerRadius).toFloat() + else if (listStyle?.has("groupCornerRadius") == true) + stylePx(listStyle!!.optDouble("groupCornerRadius")).toFloat() + else dp(12).toFloat() + val top = + container.has("cornerRadius") || + defaultCornerRadius > 0 || + position in setOf("first", "single") + val bottom = + container.has("cornerRadius") || + defaultCornerRadius > 0 || + position in setOf("last", "single") + val radii = FloatArray(8) { if (it < 4 && top || it >= 4 && bottom) radius else 0f } + outlineProvider = ViewOutlineProvider.BACKGROUND + clipToOutline = container.has("cornerRadius") + background = + GradientDrawable().apply { + setColor(fill) + cornerRadii = radii + } + foreground = + if (container.has("borderWidth")) + GradientDrawable().apply { + setColor(Color.TRANSPARENT) + cornerRadii = radii + setStroke( + stylePx(container.optDouble("borderWidth")), + color(container.optString("borderColor", "#00000000")), + ) + } + else null + alpha = + container.optDouble("opacity", item.json.optDouble("opacity", 1.0)).toFloat() * + if (isEnabled) 1f else 0.5f + separator.color = + color( + listStyle + ?.optJSONObject("separator") + ?.optString("color", theme?.optString("separator", "#0000001F") ?: "#0000001F") + ?: theme?.optString("separator", "#0000001F") + ?: "#0000001F" + ) + separator.strokeWidth = 1f + invalidate() + } + + override fun setReorderActive(active: Boolean) { + reorderActive = active + appearance() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + // Android's native measurement consumes the exact resolved text/image views. + super.onMeasure( + widthMeasureSpec, + explicitHeight?.let { MeasureSpec.makeMeasureSpec(it, MeasureSpec.EXACTLY) } + ?: heightMeasureSpec, + ) + } + + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + current?.takeIf(::nativeListLegacyShowsSeparator)?.let { + val start = + listStyle + ?.optJSONObject("separator") + ?.takeIf { it.has("inset") } + ?.let { stylePx(it.optDouble("inset")) } ?: dp(12) + canvas.drawLine(start.toFloat(), height - 1f, width.toFloat(), height - 1f, separator) + } + } + + override fun onInitializeAccessibilityNodeInfo( + info: android.view.accessibility.AccessibilityNodeInfo + ) { + super.onInitializeAccessibilityNodeInfo(info) + info.viewIdResourceName = getTag(com.facebook.react.R.id.react_test_id) as? String + } + + override fun recycle() { + if (current != null) onBindingInvalidated?.invoke(this, bindingEpoch) + bindingEpoch++ + current = null + tag = null + inputSignature = "" + touchPressed = false + reorderActive = false + recycleContent() + } + + override fun dispose() { + recycle() + disposeContent() + } +} diff --git a/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift b/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift index bae67e809..f13a43017 100644 --- a/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift @@ -1,7 +1,6 @@ import UIKit enum NativeListMessageRenderer { - enum Update { case unchanged, content, assets, replace } struct Resolved { let title: NativeListResolvedText let body: NativeListResolvedText @@ -66,7 +65,7 @@ enum NativeListMessageRenderer { } final class Views { - let root = UIStackView() + let root: UIStackView let column = UIStackView() let title = NativeListTextLabel() let body = NativeListTextLabel() @@ -75,7 +74,8 @@ enum NativeListMessageRenderer { private var thumbnail: NativeListImageSlot? private var sizeConstraints: [NSLayoutConstraint] = [] - init() { + init(root: UIStackView = UIStackView()) { + self.root = root root.axis = .horizontal root.spacing = 12 column.axis = .vertical @@ -140,160 +140,22 @@ enum NativeListMessageRenderer { } } - static func update(from old: NativeListItem?, to new: NativeListItem) -> Update { - guard let old, old.key == new.key, old.rendererKey == new.rendererKey else { return .replace } - if old.content == new.content { return .unchanged } - for field in ["leading", "thumbnail"] { - if NativeListImageSlot.signature(old.data.dictionary(field) ?? [:]) - != NativeListImageSlot.signature(new.data.dictionary(field) ?? [:]) - { - return .assets - } - } - return .content - } } -final class NativeListMessageCell: NativeListRowHost { - private let views = NativeListMessageRenderer.Views() - private let separator = UIView() - private let fullWidthBackground = CALayer() - private var rootConstraints: [NSLayoutConstraint] = [] - private var item: NativeListItem? - private var theme: [String: Any]? - private var layout = "linear" - private var selectedState = false - private var inputSignature = "" - - override init(frame: CGRect) { - super.init(frame: frame) - views.root.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(views.root) - contentView.addSubview(separator) - rootConstraints = [ - views.root.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - views.root.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - views.root.topAnchor.constraint(equalTo: contentView.topAnchor), - views.root.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - ] - NSLayoutConstraint.activate(rootConstraints) - } +final class NativeListMessageCell: NativeListRendererCell { + override var assetFields: [String] { ["leading", "thumbnail"] } + private lazy var views = NativeListMessageRenderer.Views(root: root) + override init(frame: CGRect) { super.init(frame: frame) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - override func bind( - item: NativeListItem, theme: [String: Any]?, layout: String, itemIndex: Int? = nil, - selected: Bool, checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String - ) { - let update = NativeListMessageRenderer.update(from: self.item, to: item) - let signature = NativeListImageSlot.signature([ - "theme": theme ?? [:], "layout": layout, "listStyle": listStyle ?? [:], - ]) - if update != .unchanged || signature != inputSignature { - if self.item != nil { onBindingInvalidated?(self, bindingEpoch) } - bindingEpoch &+= 1 - if update == .replace { - views.recycle() - isHighlighted = false - } - let resolved = NativeListMessageRenderer.Resolved(item, theme: theme, layout: layout) - views.bind(resolved, item: item, theme: theme) - rootConstraints[0].constant = resolved.horizontalPadding - rootConstraints[1].constant = -resolved.horizontalPadding - rootConstraints[2].constant = resolved.verticalPadding - rootConstraints[3].constant = -resolved.verticalPadding - inputSignature = signature - } - self.item = item - self.theme = theme - self.layout = layout - self.selectedState = selected - accessibilityLabel = item.data.string("accessibilityLabel", default: item.data.string("title")) - accessibilityIdentifier = item.data["testID"] as? String - isUserInteractionEnabled = !item.data.bool("disabled") - if !isUserInteractionEnabled { isHighlighted = false } - applyAppearance() - setNeedsLayout() - } - - override func updateSelection( - item: NativeListItem, selected: Bool, + override func bindContent( + _ item: NativeListItem, theme: [String: Any]?, layout: String, checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String ) { - guard self.item?.key == item.key else { return } - self.item = item - self.selectedState = selected - applyAppearance() - } - override var isHighlighted: Bool { didSet { applyAppearance() } } - - private func applyAppearance() { - guard let item else { return } - let container = item.data.dictionary("style")?.dictionary("container") ?? [:] - let showSelection = selectedState && (layout != "sectioned" || item.data.bool("selected")) - var background = nativeListColor( - theme, showSelection ? "rowSelectedBackground" : "rowBackground", - showSelection ? "#F0F0F0" : "#FFFFFF") - if let color = (container["backgroundColor"] ?? item.data["backgroundColor"]) as? String { - background = UIColor(nativeListHex: color, fallback: background) - } - contentView.backgroundColor = - isHighlighted ? nativeListColor(theme, "rowPressedBackground", "#E8E8E8") : background - contentView.alpha = - CGFloat(container.double("opacity", default: item.data.double("opacity", default: 1))) - * (item.data.bool("disabled") ? 0.5 : 1) - let position = item.data.string("groupPosition") - let grouped = ["first", "last", "single"].contains(position) - let radius = CGFloat( - container.double( - "cornerRadius", - default: grouped ? listStyle?.double("groupCornerRadius", default: 12) ?? 12 : 0)) - let top: CACornerMask = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - let bottom: CACornerMask = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] - layer.maskedCorners = - container["cornerRadius"] != nil || position == "single" - ? top.union(bottom) : position == "first" ? top : position == "last" ? bottom : [] - layer.cornerRadius = radius - layer.masksToBounds = radius > 0 - contentView.layer.cornerRadius = radius - contentView.layer.maskedCorners = layer.maskedCorners - contentView.layer.borderWidth = CGFloat(container.double("borderWidth")) - contentView.layer.borderColor = - UIColor( - nativeListHex: container.string("borderColor", default: "#00000000"), fallback: .clear - ).cgColor - if item.data.bool("backgroundFullWidth"), - let fill = (container["backgroundColor"] ?? item.data["backgroundColor"]) as? String - { - fullWidthBackground.backgroundColor = UIColor(nativeListHex: fill, fallback: .clear).cgColor - contentView.layer.insertSublayer(fullWidthBackground, at: 0) - clipsToBounds = false - } else { - fullWidthBackground.removeFromSuperlayer() - } - separator.isHidden = !item.data.bool("separator") - separator.backgroundColor = UIColor( - nativeListHex: listStyle?.dictionary("separator")?.string( - "color", default: theme?.string("separator", default: "#E0E0E0") ?? "#E0E0E0") ?? theme? - .string("separator", default: "#E0E0E0") ?? "#E0E0E0", fallback: .lightGray) - } - override func layoutSubviews() { - super.layoutSubviews() - fullWidthBackground.frame = CGRect( - x: -frame.minX, y: 0, width: superview?.bounds.width ?? bounds.width, height: bounds.height) - let inset = CGFloat(listStyle?.dictionary("separator")?.double("inset", default: 12) ?? 12) - separator.frame = CGRect( - x: effectiveUserInterfaceLayoutDirection == .rightToLeft ? 0 : inset, - y: contentView.bounds.height - 1 / UIScreen.main.scale, - width: max(0, contentView.bounds.width - inset), height: 1 / UIScreen.main.scale) - } - override func prepareForReuse() { - super.prepareForReuse() - if item != nil { onBindingInvalidated?(self, bindingEpoch) } - bindingEpoch &+= 1 - item = nil - inputSignature = "" - views.recycle() - fullWidthBackground.removeFromSuperlayer() - isHighlighted = false + let resolved = NativeListMessageRenderer.Resolved(item, theme: theme, layout: layout) + views.bind(resolved, item: item, theme: theme) + contentInsets = UIEdgeInsets( + top: resolved.verticalPadding, left: resolved.horizontalPadding, + bottom: resolved.verticalPadding, right: resolved.horizontalPadding) } + override func recycleContent() { views.recycle() } } diff --git a/native-views/react-native-native-list/ios/NativeListRendererCell.swift b/native-views/react-native-native-list/ios/NativeListRendererCell.swift new file mode 100644 index 000000000..ef4ac6ffa --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListRendererCell.swift @@ -0,0 +1,175 @@ +import UIKit + +// Shared chrome and binding lifetime; the subclass owns its template views. +enum NativeListRendererUpdate { case unchanged, content, assets, replace } + +class NativeListRendererCell: NativeListRowHost { + let root = UIStackView() + var contentInsets = UIEdgeInsets.zero + var defaultCornerRadius: CGFloat { 0 } + var showsSelection: Bool { true } + var defaultSeparatorInset: CGFloat { 12 } + func bindContent( + _ item: NativeListItem, theme: [String: Any]?, layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) {} + func recycleContent() {} + var assetFields: [String] { [] } + private func update(from old: NativeListItem?, to new: NativeListItem) -> NativeListRendererUpdate + { + guard let old, old.key == new.key, old.rendererKey == new.rendererKey else { return .replace } + if old.content == new.content { return .unchanged } + for field in assetFields { + if NativeListImageSlot.signature(old.data.dictionary(field) ?? [:]) + != NativeListImageSlot.signature(new.data.dictionary(field) ?? [:]) + { + return .assets + } + } + return .content + } + private let separator = UIView() + private let fullWidthBackground = CALayer() + private var rootConstraints: [NSLayoutConstraint] = [] + private var item: NativeListItem? + private var theme: [String: Any]? + private var layout = "linear" + private var selectedState = false + private var inputSignature = "" + + override init(frame: CGRect) { + super.init(frame: frame) + root.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(root) + contentView.addSubview(separator) + rootConstraints = [ + root.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + root.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + root.topAnchor.constraint(equalTo: contentView.topAnchor), + root.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ] + NSLayoutConstraint.activate(rootConstraints) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func bind( + item: NativeListItem, theme: [String: Any]?, layout: String, itemIndex: Int? = nil, + selected: Bool, checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + let update = update(from: self.item, to: item) + let signature = NativeListImageSlot.signature([ + "theme": theme ?? [:], "layout": layout, "listStyle": listStyle ?? [:], + ]) + if update != .unchanged || signature != inputSignature { + if self.item != nil { onBindingInvalidated?(self, bindingEpoch) } + bindingEpoch &+= 1 + if update == .replace { + recycleContent() + isHighlighted = false + } + bindContent(item, theme: theme, layout: layout, checkboxState: checkboxState) + rootConstraints[0].constant = contentInsets.left + rootConstraints[1].constant = -contentInsets.right + rootConstraints[2].constant = contentInsets.top + rootConstraints[3].constant = -contentInsets.bottom + inputSignature = signature + } + self.item = item + self.theme = theme + self.layout = layout + self.selectedState = selected + accessibilityLabel = item.data.string("accessibilityLabel", default: item.data.string("title")) + accessibilityIdentifier = item.data["testID"] as? String + isUserInteractionEnabled = !item.data.bool("disabled") + if !isUserInteractionEnabled { isHighlighted = false } + applyAppearance() + setNeedsLayout() + } + + override func updateSelection( + item: NativeListItem, selected: Bool, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + guard self.item?.key == item.key else { return } + self.item = item + self.selectedState = selected + applyAppearance() + } + override var isHighlighted: Bool { didSet { applyAppearance() } } + + private func applyAppearance() { + guard let item else { return } + let container = item.data.dictionary("style")?.dictionary("container") ?? [:] + let showSelection = + showsSelection && selectedState && (layout != "sectioned" || item.data.bool("selected")) + var background = nativeListColor( + theme, showSelection ? "rowSelectedBackground" : "rowBackground", + showSelection ? "#F0F0F0" : "#FFFFFF") + if let color = (container["backgroundColor"] ?? item.data["backgroundColor"]) as? String { + background = UIColor(nativeListHex: color, fallback: background) + } + contentView.backgroundColor = + isHighlighted ? nativeListColor(theme, "rowPressedBackground", "#E8E8E8") : background + contentView.alpha = + CGFloat(container.double("opacity", default: item.data.double("opacity", default: 1))) + * (item.data.bool("disabled") ? 0.5 : 1) + let position = item.data.string("groupPosition") + let grouped = ["first", "last", "single"].contains(position) + let radius = CGFloat( + container.double( + "cornerRadius", + default: defaultCornerRadius > 0 + ? Double(defaultCornerRadius) + : grouped ? listStyle?.double("groupCornerRadius", default: 12) ?? 12 : 0)) + let top: CACornerMask = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + let bottom: CACornerMask = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + layer.maskedCorners = + container["cornerRadius"] != nil || position == "single" || defaultCornerRadius > 0 + ? top.union(bottom) : position == "first" ? top : position == "last" ? bottom : [] + layer.cornerRadius = radius + layer.masksToBounds = radius > 0 + contentView.layer.cornerRadius = radius + contentView.layer.maskedCorners = layer.maskedCorners + contentView.layer.borderWidth = CGFloat(container.double("borderWidth")) + contentView.layer.borderColor = + UIColor( + nativeListHex: container.string("borderColor", default: "#00000000"), fallback: .clear + ).cgColor + if item.data.bool("backgroundFullWidth"), + let fill = (container["backgroundColor"] ?? item.data["backgroundColor"]) as? String + { + fullWidthBackground.backgroundColor = UIColor(nativeListHex: fill, fallback: .clear).cgColor + contentView.layer.insertSublayer(fullWidthBackground, at: 0) + clipsToBounds = false + } else { + fullWidthBackground.removeFromSuperlayer() + } + separator.isHidden = !item.data.bool("separator") + separator.backgroundColor = UIColor( + nativeListHex: listStyle?.dictionary("separator")?.string( + "color", default: theme?.string("separator", default: "#E0E0E0") ?? "#E0E0E0") ?? theme? + .string("separator", default: "#E0E0E0") ?? "#E0E0E0", fallback: .lightGray) + } + override func layoutSubviews() { + super.layoutSubviews() + fullWidthBackground.frame = CGRect( + x: -frame.minX, y: 0, width: superview?.bounds.width ?? bounds.width, height: bounds.height) + let inset = CGFloat( + listStyle?.dictionary("separator")?.double("inset", default: Double(defaultSeparatorInset)) + ?? Double(defaultSeparatorInset)) + separator.frame = CGRect( + x: effectiveUserInterfaceLayoutDirection == .rightToLeft ? 0 : inset, + y: contentView.bounds.height - 1 / UIScreen.main.scale, + width: max(0, contentView.bounds.width - inset), height: 1 / UIScreen.main.scale) + } + override func prepareForReuse() { + super.prepareForReuse() + if item != nil { onBindingInvalidated?(self, bindingEpoch) } + bindingEpoch &+= 1 + item = nil + inputSignature = "" + recycleContent() + fullWidthBackground.removeFromSuperlayer() + isHighlighted = false + } +} diff --git a/native-views/react-native-native-list/src/web/templates/MessageRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/MessageRowRenderer.ts index a4d349eea..95058b6e3 100644 --- a/native-views/react-native-native-list/src/web/templates/MessageRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/MessageRowRenderer.ts @@ -1,16 +1,6 @@ -import type { - ImageSource, - LeadingVisual, - MessageRow, - NativeListTextStyle, -} from '../../models'; - -export type MessagePrimitives = Readonly<{ - visual: (source: LeadingVisual | undefined) => HTMLElement | undefined; - thumbnail: (source: ImageSource) => HTMLElement | undefined; - textStyle: (element: HTMLElement, style: NativeListTextStyle) => void; - dispose: (element: HTMLElement) => void; -}>; +import { RowVisualStyle } from './RowVisual'; +import type { RowPrimitives } from './RowVisual'; +import type { MessageRow, NativeListTextStyle } from '../../models'; type Text = Readonly<{ value: string; @@ -91,20 +81,10 @@ type Views = { thumbnailIdentity: string; // Factory defaults are captured before any caller style is applied. They are // never inferred from the previous bound row's styled state. - visualDefaults: Map>; + visualStyle?: RowVisualStyle; row?: MessageRow; - primitives: MessagePrimitives; + primitives: RowPrimitives; }; -const visualStyleKeys = [ - 'width', - 'height', - 'flex-basis', - 'border-radius', - 'overflow', - 'object-fit', - 'margin-inline-end', - 'align-self', -]; const viewsByBody = new WeakMap(); const identity = (key: string, source: unknown) => JSON.stringify([key, source]); @@ -126,7 +106,7 @@ export function classifyMessageUpdate( export function bindMessageRow( body: HTMLElement, row: MessageRow, - primitives: MessagePrimitives + primitives: RowPrimitives ): void { const views = viewsByBody.get(body); if (!views) throw new Error('Message renderer received an incompatible body'); @@ -166,68 +146,18 @@ export function bindMessageRow( } views.leading = primitives.visual(row.leading); views.leadingIdentity = leadingIdentity; - views.visualDefaults.clear(); + views.visualStyle = undefined; if (views.leading) { body.insertBefore(views.leading, body.firstChild); - [ - views.leading, - ...views.leading.querySelectorAll('*'), - ].forEach((view) => - views.visualDefaults.set( - view, - Object.fromEntries( - visualStyleKeys.map((key) => [ - key, - view.style.getPropertyValue(key), - ]) - ) - ) - ); + views.visualStyle = new RowVisualStyle(views.leading); } } - if (views.leading) { - views.visualDefaults.forEach((style, view) => { - visualStyleKeys.forEach((key) => { - view.style.removeProperty(key); - if (style[key]) view.style.setProperty(key, style[key]!); - }); - }); - const leading = views.leading; - leading.style.width = `${resolved.imageWidth}px`; - leading.style.height = `${resolved.imageHeight}px`; - leading.style.flexBasis = `${resolved.imageWidth}px`; - leading.style.marginInlineEnd = `${resolved.leadingGap - 12}px`; - const image = resolved.image; - const radius = - image?.cornerRadius !== undefined - ? `${image.cornerRadius}px` - : image?.shape === 'circle' - ? '50%' - : image?.shape === 'square' - ? '0px' - : image?.shape === 'rounded' - ? '10px' - : undefined; - if (radius !== undefined) { - leading.style.setProperty('border-radius', radius, 'important'); - leading.style.overflow = leading.matches('img') ? 'hidden' : 'visible'; - } - const images = leading.matches('img') - ? [leading] - : leading.querySelectorAll( - ':scope > img:not(.ok-native-list-visual-corner), :scope > .ok-native-list-visual-fallback:not(.ok-native-list-visual-corner)' - ); - images.forEach((bitmap) => { - if (bitmap !== leading) { - if (image?.width !== undefined) bitmap.style.width = '100%'; - if (image?.height !== undefined) bitmap.style.height = '100%'; - } - if (radius !== undefined) bitmap.style.borderRadius = radius; - if (image?.contentFit) - bitmap.style.objectFit = - image.contentFit === 'center' ? 'none' : image.contentFit; - }); - } + views.visualStyle?.apply( + resolved.image, + resolved.imageWidth, + resolved.imageHeight, + resolved.leadingGap - 12 + ); const thumbnailIdentity = identity(row.key, row.thumbnail); if (thumbnailIdentity !== views.thumbnailIdentity) { if (views.thumbnail) { @@ -247,7 +177,7 @@ export function bindMessageRow( export function createMessageRow( document: Document, row: MessageRow, - primitives: MessagePrimitives + primitives: RowPrimitives ): HTMLElement { const span = (className: string, role?: string) => { const view = document.createElement('span'); @@ -272,7 +202,6 @@ export function createMessageRow( unread: span('ok-native-list-unread'), leadingIdentity: '', thumbnailIdentity: '', - visualDefaults: new Map(), primitives, }); bindMessageRow(body, row, primitives); @@ -295,7 +224,7 @@ export function recycleMessageRow(body: HTMLElement): void { views.row = undefined; views.leadingIdentity = ''; views.thumbnailIdentity = ''; - views.visualDefaults.clear(); + views.visualStyle = undefined; } export function measureMessageRow( diff --git a/native-views/react-native-native-list/src/web/templates/RowVisual.ts b/native-views/react-native-native-list/src/web/templates/RowVisual.ts new file mode 100644 index 000000000..04e5cb8f9 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/RowVisual.ts @@ -0,0 +1,86 @@ +import type { + ImageSource, + LeadingVisual, + NativeListTextStyle, + NativeListImageStyle, +} from '../../models'; + +export type RowPrimitives = Readonly<{ + visual: (source: LeadingVisual | undefined) => HTMLElement | undefined; + thumbnail: (source: ImageSource) => HTMLElement | undefined; + textStyle: (element: HTMLElement, style: NativeListTextStyle) => void; + dispose: (element: HTMLElement) => void; +}>; + +const properties = [ + 'width', + 'height', + 'flex-basis', + 'border-radius', + 'overflow', + 'object-fit', + 'margin-inline-end', + 'align-self', +]; + +/** Restores factory geometry without touching async loading visibility. */ +export class RowVisualStyle { + private readonly defaults = new Map>(); + constructor(private readonly view: HTMLElement) { + [view, ...view.querySelectorAll('*')].forEach((element) => { + this.defaults.set( + element, + new Map( + properties.map((key) => [key, element.style.getPropertyValue(key)]) + ) + ); + }); + } + apply( + image: NativeListImageStyle | undefined, + width: number, + height: number, + margin: number + ) { + this.defaults.forEach((style, element) => + style.forEach((value, key) => { + element.style.removeProperty(key); + if (value) element.style.setProperty(key, value); + }) + ); + const leading = this.view; + leading.style.width = `${width}px`; + leading.style.height = `${height}px`; + leading.style.flexBasis = `${width}px`; + leading.style.marginInlineEnd = `${margin}px`; + const radius = + image?.cornerRadius !== undefined + ? `${image.cornerRadius}px` + : image?.shape === 'circle' + ? '50%' + : image?.shape === 'square' + ? '0px' + : image?.shape === 'rounded' + ? '10px' + : undefined; + if (radius !== undefined) { + leading.style.setProperty('border-radius', radius, 'important'); + leading.style.overflow = leading.matches('img') ? 'hidden' : 'visible'; + } + const images = leading.matches('img') + ? [leading] + : leading.querySelectorAll( + ':scope > img:not(.ok-native-list-visual-corner), :scope > .ok-native-list-visual-fallback:not(.ok-native-list-visual-corner)' + ); + images.forEach((bitmap) => { + if (bitmap !== leading) { + if (image?.width !== undefined) bitmap.style.width = '100%'; + if (image?.height !== undefined) bitmap.style.height = '100%'; + } + if (radius !== undefined) bitmap.style.borderRadius = radius; + if (image?.contentFit) + bitmap.style.objectFit = + image.contentFit === 'center' ? 'none' : image.contentFit; + }); + } +} From 21e79774d027917ca972e5292425582ac7989b56 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 23:30:06 +0800 Subject: [PATCH 35/71] refactor(native-list): migrate Android Rail renderer --- .../com/onekey/nativelist/NativeListModels.kt | 10 +- .../nativelist/NativeListRailRenderer.kt | 147 ++++++++++++++++++ .../nativelist/NativeListRendererRegistry.kt | 7 +- .../onekey/nativelist/NativeListRowView.kt | 64 +------- .../nativelist/NativeListStyleSlotTest.kt | 3 +- 5 files changed, 161 insertions(+), 70 deletions(-) create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRailRenderer.kt diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index ac26986ed..24e7a25f4 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -29,7 +29,7 @@ internal fun isNativeListWholeRowInteractive( * Maps a style key - which names a model field - to the view slot that renders it. * The view pool is shared across templates and the mapping is not one to one: * metricCard renders `value` through the title view and its own `title` through - * the subtitle view, and one status view carries rail.status, activity.status, + * the subtitle view, and one status view carries activity.status, * metricCard.trend. See docs/STYLE_SPEC.md section 4. * * Returns null when the template does not render that field, so an unmapped key @@ -41,10 +41,6 @@ internal fun nativeListStyleSlot(type: String, variant: String, field: String): "title", "subtitle", "tertiary", "badge", "value", "valueSecondary" -> field else -> null } - "rail" -> when (field) { - "title", "badge", "status" -> field - else -> null - } "activity" -> when (field) { "title", "status" -> field "description" -> "subtitle" @@ -113,9 +109,9 @@ private fun updateLengthPrefixed(digest: MessageDigest, value: String) { digest.update(bytes) } -// Message is the first migrated renderer. All remaining templates still share +// Message and Rail own dedicated renderers. Remaining templates still share // a compatible legacy tree; keys and style values never partition the pool. -internal enum class NativeListRendererKey { LEGACY, MESSAGE } +internal enum class NativeListRendererKey { LEGACY, MESSAGE, RAIL } internal data class NativeListItem( val key: String, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRailRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRailRenderer.kt new file mode 100644 index 000000000..159d60076 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRailRenderer.kt @@ -0,0 +1,147 @@ +package com.margelo.nitro.nativelist + +import android.view.Gravity +import android.widget.LinearLayout +import com.facebook.react.uimanager.ThemedReactContext +import kotlin.math.roundToInt +import org.json.JSONObject + +internal class NativeListRailRowView(context: ThemedReactContext) : + NativeListRendererRowView(context) { + private val textLine = + LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + private val title = NativeListTextView(context) + private val badge = NativeListTextView(context) + private val status = NativeListTextView(context) + private var visual: NativeListLeadingVisual? = null + private var leadingWidth = 0 + private var leadingGap = 0 + private var badgeGap = 0 + private var trailingGap = 0 + override val defaultCornerRadius = 8 + override val pressedColorName = "strongBackground" + override val pressedColorFallback = "#0000000F" + override val assetFields = listOf("visual") + override val showsSelection = false + + override fun defaultHeight(item: NativeListItem, layout: String) = 28 + + init { + listOf(title, badge, status).forEach { + textLine.addView(it, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + } + addView(textLine, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + } + + override fun bindContent( + item: NativeListItem, + theme: JSONObject?, + layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + val style = item.json.optJSONObject("style") ?: JSONObject() + fun dimension(name: String, fallback: Int) = + if (style.has(name)) stylePx(style.optDouble(name)) else dp(fallback) + fun color(name: String, fallback: String) = + parseNativeListColor(theme?.optString(name, fallback) ?: fallback) + fun text( + view: NativeListTextView, + field: String, + value: String, + weight: String, + tint: Int, + lineHeight: Int, + ) { + NativeListResolvedText.resolve( + reactContext, + value, + style.optJSONObject(field), + 12f, + weight, + tint, + lineHeight, + 1, + sourceScale, + ) + .bind(view) + } + text( + title, + "title", + item.json.optString("title"), + "medium", + color("primaryText", "#000000DF"), + 16, + ) + val badgeData = item.json.optJSONObject("badge") + val tone = badgeData?.optString("tone") + val badgeColor = + when (tone) { + "success" -> color("positive", "#00713FDE") + "danger" -> color("negative", "#C40006D3") + else -> color("secondaryText", "#0000009B") + } + text(badge, "badge", badgeData?.optString("text") ?: "", "medium", badgeColor, 16) + val statusValue = item.json.optString("status").takeUnless { it == "none" } ?: "" + text(status, "status", statusValue, "regular", color("secondaryText", "#0000009B"), 0) + val horizontal = dimension("horizontalPadding", 4) + val vertical = dimension("verticalPadding", 4) + setPadding(horizontal, vertical, horizontal, vertical) + gravity = + when (style.optJSONObject("container")?.optString("contentVerticalAlignment")) { + "top" -> Gravity.TOP + "bottom" -> Gravity.BOTTOM + else -> Gravity.CENTER_VERTICAL + } + leadingGap = dimension("leadingGap", 6) + badgeGap = dimension("titleBadgeGap", 6) + trailingGap = dimension("trailingGap", 0) + badge.layoutParams = + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + marginStart = badgeGap + } + status.layoutParams = + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + marginStart = trailingGap + } + val image = style.optJSONObject("image") ?: JSONObject() + leadingWidth = if (image.has("width")) stylePx(image.optDouble("width")) else dp(20) + val imageHeight = if (image.has("height")) stylePx(image.optDouble("height")) else dp(20) + val source = item.json.optJSONObject("visual") + if (source != null) { + val view = visual ?: NativeListLeadingVisual(reactContext).also { visual = it } + view.bind(source, image, item.key, theme, false, sourceScale) + val params = LayoutParams(leadingWidth, imageHeight).apply { marginEnd = leadingGap } + if (view.parent == null) addView(view, 0, params) else view.layoutParams = params + } else + visual?.let { + it.recycle() + removeView(it) + } + } + + // Retain the legacy 6-unit trailing allowance when a status is present. + override fun horizontalWidth(item: NativeListItem): Int = + (paddingLeft + + paddingRight + + leadingWidth + + leadingGap + + title.paint.measureText(title.text.toString()) + + (if (badge.visibility == VISIBLE) badgeGap + badge.paint.measureText(badge.text.toString()) + else 0f) + + (if (status.visibility == VISIBLE) + trailingGap + dp(6) + status.paint.measureText(status.text.toString()) + else 0f)) + .roundToInt() + + override fun recycleContent() { + visual?.recycle() + } + + override fun disposeContent() { + visual?.dispose() + } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt index 01881d1bb..bc15c1800 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt @@ -7,10 +7,13 @@ internal object NativeListRendererRegistry { mapOf( NativeListRendererKey.LEGACY to ::NativeListRowView, NativeListRendererKey.MESSAGE to ::NativeListMessageRowView, + NativeListRendererKey.RAIL to ::NativeListRailRowView, ) - fun key(type: String) = - if (type == "message") NativeListRendererKey.MESSAGE else NativeListRendererKey.LEGACY + private val keys = + mapOf("message" to NativeListRendererKey.MESSAGE, "rail" to NativeListRendererKey.RAIL) + + fun key(type: String) = keys[type] ?: NativeListRendererKey.LEGACY fun create(context: ThemedReactContext, viewType: Int): NativeListRowHost = factories.getValue(NativeListRendererKey.entries[viewType])(context) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index b64fea8ac..70216944d 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -960,14 +960,13 @@ internal class NativeListRowView( applySelectionState(item, theme, layout, itemIndex, selected) pressedRowBackground = groupedBackground( when (item.type) { - "rail" -> "rail" "mediaTile" -> "mediaTile" else -> "single" }, color( theme, - if (item.type == "rail") "strongBackground" else "rowPressedBackground", - if (item.type == "rail") "#0000000F" else "#00000017", + "rowPressedBackground", + "#00000017", ), ) background = if (touchPressed || reorderActive) pressedRowBackground else restingRowBackground @@ -1005,7 +1004,6 @@ internal class NativeListRowView( when (item.type) { "walletGroup" -> bindWalletGroup(item, theme, layout, listOrientation, checkboxState) "identity" -> bindIdentity(item, theme, selected, checkboxState) - "rail" -> bindRail(item, theme) "activity" -> bindActivity(item, theme) "dataRow" -> bindDataRow(item, theme, checkboxState) "market" -> bindMarket(item, theme) @@ -1116,8 +1114,7 @@ internal class NativeListRowView( } if (style.has("trailingGap")) { val gap = styleDp(style.optDouble("trailingGap")) - if (item.type == "rail") styleMargins(status, start = gap) - else styleGap(trailingColumn, gap) + styleGap(trailingColumn, gap) } style.optJSONObject("image")?.let { image -> if (leadingFrame.parent != null) { @@ -2166,39 +2163,6 @@ internal class NativeListRowView( bindAccessories(item, accessories, theme, checkboxState) } - private fun bindRail(item: NativeListItem, theme: JSONObject?) { - setPadding(dp(4), dp(4), dp(4), dp(4)) - addLeading(item.json.optJSONObject("visual"), 20, spacingDp = 6) - showText(title, item.json.optString("title"), 1) - TextViewCompat.setLineHeight(title, dp(16)) - title.fontFeatureSettings = "tnum" - title.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) - titleLine.layoutParams = wrap() - mainColumn.orientation = HORIZONTAL - mainColumn.gravity = Gravity.CENTER_VERTICAL - mainColumn.layoutParams = wrap() - title.setHorizontallyScrolling(true) - item.json.optJSONObject("badge")?.let { badge -> - showText(badgeLine, badge.optString("text"), 1) - badgeLine.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { - marginStart = dp(6) - } - badgeLine.setTextColor( - when (badge.optString("tone")) { - "success" -> color(theme, "positive", "#00713FDE") - "danger" -> color(theme, "negative", "#C40006D3") - else -> color(theme, "secondaryText", "#0000009B") - }, - ) - badgeLine.typeface = NativeListFonts.medium(context) - badgeLine.fontFeatureSettings = "tnum" - TextViewCompat.setLineHeight(badgeLine, dp(16)) - } - item.json.optString("status").takeUnless { it.isEmpty() || it == "none" } - ?.let { showText(status, it, 1) } - addView(mainColumn, wrap()) - } - private fun bindActivity(item: NativeListItem, theme: JSONObject?) { addLeading( item.json.optJSONObject("leading"), @@ -3931,9 +3895,8 @@ internal class NativeListRowView( if (selected) "rowSelectedBackground" else "rowBackground", if (selected) "#0000000F" else "#FFFFFF", ) - if (item.type == "rail" || item.type == "mediaTile") { - // The source FavoriteTokenItem has no active/resting fill, and the NFT - // tile changes only the image opacity while pressed. + if (item.type == "mediaTile") { + // The NFT tile changes only the image opacity while pressed. rowBackground = color(theme, "rowBackground", "#FFFFFF") } else if (item.type == "metricCard" && !selected) { rowBackground = color(theme, "subduedBackground", "#F9F9F9") @@ -3944,7 +3907,6 @@ internal class NativeListRowView( item.type == "identity" && item.json.optString("presentation") in setOf("accountSelector", "networkSelector") && item.json.has("height") -> "single" else -> when (item.type) { "metricCard" -> "single" - "rail" -> "rail" else -> item.json.optString("groupPosition") } } @@ -4190,7 +4152,6 @@ internal class NativeListRowView( item.json.optString("variant") == "history" || item.sectionKey?.startsWith("history-") == true -> 12f else -> 14f } - "rail" -> 12f "system" -> 14f "metricCard" -> if (item.json.optString("size") == "large") 24f else 18f else -> 16f @@ -4293,7 +4254,6 @@ internal class NativeListRowView( } isNetworkSelectorIdentity -> 47 else -> when (item.type) { - "rail" -> 28 "activity" -> if ((item.json.optJSONArray("footerActions")?.length() ?: 0) > 0) 104 else 60 "mediaTile" -> 0 "metricCard" -> when (item.json.optString("variant")) { @@ -4380,7 +4340,6 @@ internal class NativeListRowView( val params = layoutParams ?: return if (listOrientation == "horizontal") { params.width = when (item.type) { - "rail" -> railWidth() "mediaTile" -> dp(200) else -> dp(280) } @@ -4392,18 +4351,6 @@ internal class NativeListRowView( layoutParams = params } - private fun railWidth(): Int { - var width = dp(4 + 20 + 6).toFloat() + title.paint.measureText(title.text.toString()) - if (badgeLine.visibility == VISIBLE) { - width += dp(6) + badgeLine.paint.measureText(badgeLine.text.toString()) - } - if (status.visibility == VISIBLE) { - width += dp(6) + status.paint.measureText(status.text.toString()) - } - width += dp(4) - return width.roundToInt() - } - private fun leadingCornerRadius(shape: String, sizeDp: Int): Float = when (shape) { "square" -> 0f "rounded" -> minOf(10f, sizeDp / 4f) @@ -4574,7 +4521,6 @@ internal class NativeListRowView( "first" -> floatArrayOf(radius, radius, radius, radius, 0f, 0f, 0f, 0f) "last" -> floatArrayOf(0f, 0f, 0f, 0f, radius, radius, radius, radius) "single" -> FloatArray(8) { radius } - "rail" -> FloatArray(8) { scaledDp(8f) } "mediaTile" -> FloatArray(8) { scaledDp(16f) } else -> FloatArray(8) } diff --git a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt index 4b119acdd..e9237e595 100644 --- a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt +++ b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt @@ -24,7 +24,7 @@ class NativeListStyleSlotTest { Case(type = "identity", field = "title", expected = "title"), Case(type = "identity", field = "valueSecondary", expected = "valueSecondary"), // Legacy status views only serve unmigrated templates. - Case(type = "rail", field = "status", expected = "status"), + Case(type = "rail", field = "status", expected = null), Case(type = "activity", field = "status", expected = "status"), Case(type = "message", field = "time", expected = null), // Amounts, indices and values share the two trailing views. @@ -57,7 +57,6 @@ class NativeListStyleSlotTest { // A collision would make one of the two keys silently win. val templates = listOf( Triple("identity", "", listOf("title", "subtitle", "tertiary", "badge", "value", "valueSecondary")), - Triple("rail", "", listOf("title", "badge", "status")), Triple("activity", "", listOf("title", "description", "status", "primaryAmount", "secondaryAmount")), Triple("dataRow", "", listOf("columns", "columnSecondary", "index")), Triple("mediaTile", "", listOf("title", "subtitle", "badge")), From 8d62bff6810d84d11f10e91fee6e9de58b6f4498 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 23:30:06 +0800 Subject: [PATCH 36/71] refactor(native-list): migrate iOS Rail renderer --- .../ios/NativeListCell.swift | 53 +------ .../ios/NativeListModels.swift | 2 +- .../ios/NativeListRailRenderer.swift | 142 ++++++++++++++++++ .../ios/NativeListRendererRegistry.swift | 4 +- .../ios/NativeListResolvedText.swift | 10 +- .../ios/RNCNativeListView.swift | 17 +-- 6 files changed, 158 insertions(+), 70 deletions(-) create mode 100644 native-views/react-native-native-list/ios/NativeListRailRenderer.swift diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index 5d3b833cd..1e78b4bad 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -993,7 +993,6 @@ final class NativeListCell: NativeListRowHost { switch item.type { case "walletGroup": bindWalletGroup(item, theme: theme, layout: layout, checkboxState) case "identity": bindIdentity(item, theme: theme, selected: selected, checkboxState) - case "rail": bindRail(item, theme: theme) case "activity": bindActivity(item, theme: theme) case "dataRow": bindDataRow(item, theme: theme, checkboxState) case "market": bindMarket(item, theme: theme) @@ -1170,9 +1169,9 @@ final class NativeListCell: NativeListRowHost { ) if item.type == "metricCard", !selected { color = nativeListColor(theme, "subduedBackground", "#F9F9F9") - } else if item.type == "rail" || item.type == "mediaTile" { - // Selection is communicated by the destination state for these source - // components; neither has a persistent selected tile background. + } else if item.type == "mediaTile" { + // Selection is communicated by the destination state, without a + // persistent selected tile background. color = nativeListColor(theme, "rowBackground", "#FFFFFF") } else if layout == "sectioned", !item.data.bool("selected") { // Checkbox-backed section lists in app-monorepo keep rows on $bg; @@ -2053,47 +2052,6 @@ final class NativeListCell: NativeListRowHost { } } - private func bindRail(_ item: NativeListItem, theme: [String: Any]?) { - rootStack.spacing = 6 - rootLeadingConstraint.constant = 4 - rootTrailingConstraint.constant = -4 - rootTopConstraint.constant = 4 - rootBottomConstraint.constant = -4 - leadingWidth.constant = 20 - leadingHeight.constant = 20 - addLeading(item.data.dictionary("visual"), key: item.key) - show(titleLabel, item.data.string("title"), lines: 1) - titleLabel.font = nativeListFont(ofSize: 12, weight: .medium) - setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 16) - mainStack.axis = .horizontal - mainStack.alignment = .center - mainStack.spacing = 6 - mainStack.setContentHuggingPriority(.required, for: .horizontal) - mainStack.setContentCompressionResistancePriority(.required, for: .horizontal) - titleRowStack.setContentHuggingPriority(.required, for: .horizontal) - titleRowStack.setContentCompressionResistancePriority(.required, for: .horizontal) - titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) - if let badge = item.data.dictionary("badge") { - show(badgeLabel, badge.string("text"), lines: 1) - badgeLabel.font = nativeListTabularFont(ofSize: 12, weight: .medium) - badgeLabel.textColor = badge.string("tone") == "success" - ? nativeListColor(theme, "positive", "#218358") - : badge.string("tone") == "danger" - ? nativeListColor(theme, "negative", "#CE2C31") - : nativeListColor(theme, "secondaryText", "#646464") - setLineHeight(badgeLabel, text: badge.string("text"), lineHeight: 16) - } - let status = item.data.string("status") - if !status.isEmpty, status != "none" { - show(statusLabel, status, lines: 1) - statusLabel.font = nativeListTabularFont(ofSize: 12) - setLineHeight(statusLabel, text: status, lineHeight: 16) - } - rootStack.addArrangedSubview(mainStack) - contentView.layer.cornerRadius = 8 - contentView.clipsToBounds = true - } - private func bindActivity(_ item: NativeListItem, theme: [String: Any]?) { addLeading( item.data.dictionary("leading"), @@ -2251,8 +2209,6 @@ final class NativeListCell: NativeListRowHost { case "identity": return ["title", "subtitle", "tertiary", "badge", "value", "valueSecondary"] .contains(field) ? field : nil - case "rail": - return ["title", "badge", "status"].contains(field) ? field : nil case "activity": switch field { case "title", "status": return field @@ -2362,8 +2318,7 @@ final class NativeListCell: NativeListRowHost { } if style["trailingGap"] != nil { let gap = CGFloat(style.double("trailingGap")) - if item.type == "rail" { styleSpacing(mainStack, after: titleRowStack, gap) } - else { styleGap(trailingStack, gap) } + styleGap(trailingStack, gap) } if let image = style.dictionary("image"), leadingContainer.superview != nil { if image["width"] != nil { diff --git a/native-views/react-native-native-list/ios/NativeListModels.swift b/native-views/react-native-native-list/ios/NativeListModels.swift index ff76660e5..81f866009 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -4,7 +4,7 @@ import UIKit // Message is the first migrated renderer. Other templates still use the // compatible legacy tree; style, content and row keys do not affect reuse. enum NativeListRendererKey: String, CaseIterable { - case legacy, message + case legacy, message, rail } struct NativeListItem { diff --git a/native-views/react-native-native-list/ios/NativeListRailRenderer.swift b/native-views/react-native-native-list/ios/NativeListRailRenderer.swift new file mode 100644 index 000000000..98b0f3874 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListRailRenderer.swift @@ -0,0 +1,142 @@ +import UIKit + +enum NativeListRailRenderer { + struct Resolved { + let title: NativeListResolvedText + let badge: NativeListResolvedText + let status: NativeListResolvedText + let horizontalPadding: CGFloat + let verticalPadding: CGFloat + let leadingGap: CGFloat + let badgeGap: CGFloat + let trailingGap: CGFloat + let imageWidth: CGFloat + let imageHeight: CGFloat + let imageStyle: [String: Any] + let visual: [String: Any]? + let alignment: UIStackView.Alignment + + init(_ item: NativeListItem, theme: [String: Any]?) { + let style = item.data.dictionary("style") ?? [:] + let badge = item.data.dictionary("badge") ?? [:] + title = NativeListResolvedText( + item.data.string("title"), style: style.dictionary("title"), + size: 12, weight: .medium, color: nativeListColor(theme, "primaryText", "#202020"), + lineHeight: 16, lines: 1) + self.badge = NativeListResolvedText( + badge.string("text"), style: style.dictionary("badge"), + size: 12, weight: .medium, + color: nativeListColor( + theme, + badge.string("tone") == "success" + ? "positive" : badge.string("tone") == "danger" ? "negative" : "secondaryText", + badge.string("tone") == "success" + ? "#218358" : badge.string("tone") == "danger" ? "#CE2C31" : "#646464"), lineHeight: 16, + lines: 1, tabular: true) + let status = item.data.string("status") + self.status = NativeListResolvedText( + status == "none" ? "" : status, style: style.dictionary("status"), + size: 12, color: nativeListColor(theme, "secondaryText", "#646464"), lineHeight: 16, + lines: 1, tabular: true) + horizontalPadding = CGFloat(style.double("horizontalPadding", default: 4)) + verticalPadding = CGFloat(style.double("verticalPadding", default: 4)) + leadingGap = CGFloat(style.double("leadingGap", default: 6)) + // The legacy iOS title/badge stack uses 8, independently of the rail's 6 gap. + badgeGap = CGFloat(style.double("titleBadgeGap", default: 8)) + trailingGap = CGFloat(style.double("trailingGap", default: 6)) + imageStyle = style.dictionary("image") ?? [:] + imageWidth = CGFloat(imageStyle.double("width", default: 20)) + imageHeight = CGFloat(imageStyle.double("height", default: 20)) + visual = item.data.dictionary("visual") + switch style.dictionary("container")?.string("contentVerticalAlignment") { + case "top": alignment = .top + case "bottom": alignment = .bottom + default: alignment = .center + } + } + var horizontalWidth: CGFloat { + func width(_ text: NativeListResolvedText) -> CGFloat { + (text.text as NSString).size(withAttributes: [.font: text.font]).width + } + // Preserve the legacy viewport bounds and trailing allowance. The old + // estimator budgeted 6 points for the actual 8-point title/badge gap. + return min( + 288, + max( + 72, + ceil( + horizontalPadding * 2 + imageWidth + leadingGap + + width(title) + (badge.text.isEmpty ? 0 : badgeGap + width(badge)) + + (status.text.isEmpty ? 0 : trailingGap + width(status)) + + (badge.text.isEmpty ? 8 : 6)))) + } + } +} + +final class NativeListRailCell: NativeListRendererCell { + private let title = NativeListTextLabel() + private let badge = NativeListTextLabel() + private let status = NativeListTextLabel() + private let titleLine = UIStackView() + private let textLine = UIStackView() + private var visual: NativeListLeadingVisual? + private var dimensions: [NSLayoutConstraint] = [] + override var defaultCornerRadius: CGFloat { 8 } + override var assetFields: [String] { ["visual"] } + override var showsSelection: Bool { false } + override var defaultSeparatorInset: CGFloat { 0 } + + override init(frame: CGRect) { + super.init(frame: frame) + root.axis = .horizontal + titleLine.axis = .horizontal + textLine.axis = .horizontal + titleLine.alignment = .center + textLine.alignment = .center + [title, badge].forEach(titleLine.addArrangedSubview) + [titleLine, status].forEach(textLine.addArrangedSubview) + root.addArrangedSubview(textLine) + [title, titleLine, textLine].forEach { + $0.setContentHuggingPriority(.required, for: .horizontal) + $0.setContentCompressionResistancePriority(.required, for: .horizontal) + } + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func bindContent( + _ item: NativeListItem, theme: [String: Any]?, layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + let r = NativeListRailRenderer.Resolved(item, theme: theme) + r.title.bind(title) + r.badge.bind(badge) + r.status.bind(status) + badge.setContentHuggingPriority(.required, for: .horizontal) + title.setContentHuggingPriority(r.title.lines > 1 ? .defaultLow : .required, for: .horizontal) + status.setContentHuggingPriority(r.title.lines > 1 ? .required : .defaultLow, for: .horizontal) + root.alignment = r.alignment + root.spacing = r.leadingGap + titleLine.spacing = r.badgeGap + textLine.spacing = r.trailingGap + contentInsets = UIEdgeInsets( + top: r.verticalPadding, left: r.horizontalPadding, + bottom: r.verticalPadding, right: r.horizontalPadding) + NSLayoutConstraint.deactivate(dimensions) + dimensions.removeAll(keepingCapacity: true) + if let source = r.visual { + let view = visual ?? NativeListLeadingVisual(frame: .zero) + visual = view + if view.superview == nil { root.insertArrangedSubview(view, at: 0) } + view.bind(source, style: r.imageStyle, key: item.key, theme: theme, isUnread: false) + dimensions = [ + view.widthAnchor.constraint(equalToConstant: r.imageWidth), + view.heightAnchor.constraint(equalToConstant: r.imageHeight), + ] + NSLayoutConstraint.activate(dimensions) + } else if let visual { + visual.recycle() + root.removeArrangedSubview(visual) + visual.removeFromSuperview() + } + } + override func recycleContent() { visual?.recycle() } +} diff --git a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift index ac1ebece7..b28f59108 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift @@ -5,9 +5,10 @@ import UIKit enum NativeListRendererRegistry { private static let hosts: [NativeListRendererKey: NativeListRowHost.Type] = [ .legacy: NativeListCell.self, .message: NativeListMessageCell.self, + .rail: NativeListRailCell.self, ] static func key(for type: String) -> NativeListRendererKey { - type == "message" ? .message : .legacy + NativeListRendererKey(rawValue: type) ?? .legacy } static func reuseIdentifier(for key: NativeListRendererKey) -> String { "NativeList.\(key.rawValue)" @@ -20,6 +21,7 @@ enum NativeListRendererRegistry { static func measure(_ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String) -> CGFloat? { + if item.rendererKey == .rail { return 40 } guard item.rendererKey == .message else { return nil } return NativeListMessageRenderer.Resolved(item, theme: theme, layout: layout).measure( width: width) diff --git a/native-views/react-native-native-list/ios/NativeListResolvedText.swift b/native-views/react-native-native-list/ios/NativeListResolvedText.swift index 861f5fe18..e163ea248 100644 --- a/native-views/react-native-native-list/ios/NativeListResolvedText.swift +++ b/native-views/react-native-native-list/ios/NativeListResolvedText.swift @@ -15,7 +15,8 @@ struct NativeListResolvedText { init( _ value: String, style: [String: Any]?, size: CGFloat, weight: NativeListFontWeight = .regular, - color: UIColor, lineHeight: CGFloat, lines: Int, breakMode: NSLineBreakMode = .byTruncatingTail + color: UIColor, lineHeight: CGFloat, lines: Int, breakMode: NSLineBreakMode = .byTruncatingTail, + tabular: Bool = false ) { let style = style ?? [:] self.lines = min(3, max(1, style.int("lines", default: lines))) @@ -32,8 +33,11 @@ struct NativeListResolvedText { case "bold": resolvedWeight = .bold default: resolvedWeight = weight } - font = nativeListFont( - ofSize: CGFloat(style.double("fontSize", default: Double(size))), weight: resolvedWeight) + let resolvedSize = CGFloat(style.double("fontSize", default: Double(size))) + font = + tabular + ? nativeListTabularFont(ofSize: resolvedSize, weight: resolvedWeight) + : nativeListFont(ofSize: resolvedSize, weight: resolvedWeight) self.color = (style["color"] as? String).map { UIColor(nativeListHex: $0, fallback: color) } ?? color self.lineHeight = CGFloat(style.double("lineHeight", default: Double(lineHeight))) diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index 2196db2dc..ae0bac752 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -1736,7 +1736,6 @@ final class NativeListView: UIView { } let base: CGFloat switch item.type { - case "rail": base = 40 case "activity": base = item.data.dictionaries("footerActions").isEmpty ? 60 : 100 case "mediaTile": base = 244 case "metricCard": @@ -2188,21 +2187,7 @@ extension NativeListView: UICollectionViewDelegateFlowLayout { } private func railWidth(_ item: NativeListItem) -> CGFloat { - let titleWidth = (item.data.string("title") as NSString).size( - withAttributes: [.font: nativeListFont(ofSize: 12, weight: .medium)] - ).width - let badge = item.data.dictionary("badge")?.string("text") ?? "" - let badgeWidth = (badge as NSString).size( - withAttributes: [.font: nativeListTabularFont(ofSize: 12, weight: .medium)] - ).width - let status = item.data.string("status") - let statusWidth = status.isEmpty || status == "none" ? 0 : (status as NSString).size( - withAttributes: [.font: nativeListTabularFont(ofSize: 12)] - ).width - let visibleTextCount = 1 + (badgeWidth > 0 ? 1 : 0) + (statusWidth > 0 ? 1 : 0) - let width = 4 + 20 + 6 + titleWidth + badgeWidth + statusWidth - + CGFloat(max(0, visibleTextCount - 1)) * 6 + 4 - return min(288, max(72, ceil(width + 8))) + NativeListRailRenderer.Resolved(item, theme: config?.theme).horizontalWidth } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { From 6b53b35c83711d0ccf37cd4938c860ff8d4ffa88 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 23:30:06 +0800 Subject: [PATCH 37/71] refactor(native-list): persist Web Rail renderer views --- .../src/__tests__/NativeList.web.test.ts | 133 +++++++++++++++++ .../src/web/NativeListWebEngine.ts | 92 ++---------- .../src/web/templates/RailRowRenderer.ts | 136 ++++++++++++++++++ .../src/web/templates/RowRendererRegistry.ts | 53 +++++-- 4 files changed, 320 insertions(+), 94 deletions(-) create mode 100644 native-views/react-native-native-list/src/web/templates/RailRowRenderer.ts diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index ed2fe58f4..4ccbd6d09 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -1007,6 +1007,139 @@ describe('web row style', () => { } }); + it('retains Rail views and image requests while resetting semantic styles and compatible pools', () => { + const { document } = new JSDOM('').window; + const host = document.createElement('div'); + document.body.appendChild(host); + const rail = { + type: 'rail', + key: 'rail-pilot', + title: 'Bitcoin', + visual: { kind: 'token', image, networkImage: image }, + badge: { key: 'change', text: '+12%', tone: 'success' }, + status: 'online', + } as const; + const engine = new NativeListWebEngine( + host, + snapshot({ kind: 'sectioned' }, [rail]), + {}, + false + ); + const row = () => + host.querySelector( + '[data-native-list-row-key="rail-pilot"]' + )!; + try { + const wrapper = row(); + const body = wrapper.firstElementChild as HTMLElement; + const title = body.querySelector('[data-nl-slot="title"]')!; + const visual = body.querySelector('.ok-native-list-visual')!; + const badge = body.querySelector('[data-nl-slot="badge"]')!; + const images = Array.from(body.querySelectorAll('img')); + const corner = body.querySelector( + '.ok-native-list-visual-corner' + )!; + const cornerStyle = corner.style.cssText; + engine.applyPatches([ + { + type: 'rail', + key: rail.key, + changes: { + title: 'Updated', + style: { + container: { height: 96 }, + horizontalPadding: 12, + verticalPadding: 10, + leadingGap: 14, + titleBadgeGap: 12, + trailingGap: 10, + title: { fontSize: 18, lineHeight: 24, lines: 2 }, + badge: { color: '#BC3030' }, + image: { width: 32, height: 24, shape: 'square' }, + }, + }, + }, + ]); + expect(row()).toBe(wrapper); + expect(row().firstElementChild).toBe(body); + expect(body.querySelector('[data-nl-slot="title"]')).toBe(title); + expect(title.textContent).toBe('Updated'); + expect(title.style.fontSize).toBe('18px'); + expect(row().style.height).toBe('96px'); + expect(visual.style.width).toBe('32px'); + expect(corner.style.cssText).toBe(cornerStyle); + expect(Array.from(body.querySelectorAll('img'))).toEqual(images); + engine.applySnapshot(snapshot({ kind: 'sectioned' }, [rail])); + expect(row()).toBe(wrapper); + expect(title.style.fontSize).toBe(''); + expect(visual.style.width).toBe('20px'); + expect(row().style.height).toBe('40px'); + expect(Array.from(body.querySelectorAll('img'))).toEqual(images); + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [ + { ...rail, badge: undefined, status: 'none' }, + ]) + ); + expect(body.querySelector('[data-nl-slot="badge"]')).toBeNull(); + expect(body.querySelector('[data-nl-slot="status"]')).toBeNull(); + engine.applySnapshot(snapshot({ kind: 'sectioned' }, [rail])); + expect(body.querySelector('[data-nl-slot="badge"]')).toBe(badge); + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [ + { + type: 'message', + key: rail.key, + title: 'Other', + body: 'Body', + time: '', + }, + ]) + ); + expect(row()).not.toBe(wrapper); + engine.applySnapshot(snapshot({ kind: 'sectioned' }, [rail])); + expect(row()).toBe(wrapper); + expect(row().firstElementChild).toBe(body); + expect(title.textContent).toBe(rail.title); + } finally { + engine.destroy(); + } + }); + + it('recomputes horizontal Rail placement from styled metrics and restores default widths', () => { + const rail = { + type: 'rail', + key: 'one', + title: 'BTC', + visual: { kind: 'icon', name: 'StarOutline' }, + badge: { key: 'change', text: '+1%' }, + } as const; + const layout = (row: RowModel) => + computeWebListLayout( + snapshot( + { kind: 'linear', orientation: 'horizontal', itemSpacing: 8 }, + [row, { ...rail, key: 'two' }] + ), + 390, + 200 + ); + const plain = layout(rail); + const styled = layout({ + ...rail, + style: { + title: { fontSize: 24 }, + horizontalPadding: 12, + leadingGap: 16, + titleBadgeGap: 12, + image: { width: 32 }, + }, + }); + expect(styled.items[0]!.width).toBeGreaterThan(plain.items[0]!.width); + expect(styled.items[1]!.x - plain.items[1]!.x).toBe( + styled.items[0]!.width - plain.items[0]!.width + ); + expect(layout({ ...rail, style: {} })).toEqual(plain); + }); + it('retains Message text and image slots through content/style updates and cancels removed retries', () => { const { document } = new JSDOM('').window; const host = document.createElement('div'); diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index fbd3c9915..0a8359c03 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -1,3 +1,4 @@ +import { measureRailWidth } from './templates/RailRowRenderer'; import { rowRenderer, rowRendererKey, @@ -541,16 +542,9 @@ export function estimateWebRowHeight( const registered = rowRenderer(row); if (registered) - return Math.max( - 0, - registered.renderer.measure(registered.row, availableWidth) + - sizeModifier(row) - ); + return Math.max(0, registered.measure(availableWidth) + sizeModifier(row)); let base: number; switch (row.type) { - case 'rail': - base = 40; - break; case 'activity': base = row.footerActions?.length ? 100 : 60; break; @@ -636,13 +630,7 @@ export function estimateWebRowHeight( function estimateHorizontalWidth(row: RowModel): number { if (row.type === 'mediaTile') return 200; if (row.type !== 'rail') return 280; - const badgeLength = row.badge?.text.length ?? 0; - const statusLength = - row.status && row.status !== 'none' ? row.status.length : 0; - return Math.min( - 288, - Math.max(72, 50 + (row.title.length + badgeLength + statusLength) * 7) - ); + return measureRailWidth(row); } function sectionIndexEnabled(snapshot: NativeListSnapshot): boolean { @@ -2615,44 +2603,6 @@ function createSystemRow( return body; } -function createRailRow( - context: RenderContext, - row: Extract -): HTMLElement { - const body = createElement( - context.document, - 'div', - 'ok-native-list-row ok-native-list-rail' - ); - const visual = createVisual(context, row.visual); - if (visual) body.appendChild(visual); - body.appendChild( - tagSlot( - createElement( - context.document, - 'span', - 'ok-native-list-rail-title', - row.title - ), - 'title' - ) - ); - if (row.badge) body.appendChild(createBadge(context, row.badge)); - if (row.status && row.status !== 'none') - body.appendChild( - tagSlot( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - row.status - ), - 'status' - ) - ); - return body; -} - function createMediaRow( context: RenderContext, row: Extract @@ -3908,8 +3858,6 @@ export function applyRowStyle(body: HTMLElement, row: RowModel): void { body.style.gap || (row.type === 'mediaTile' ? '7px' - : row.type === 'rail' - ? '6px' : row.type === 'metricCard' ? '5px' : row.type === 'identity' && row.presentation === 'walletSidebar' @@ -3973,12 +3921,6 @@ export function applyRowStyle(body: HTMLElement, row: RowModel): void { : 'calc(' + gap + ' - ' + String(box.lineGap) + 'px)'; else badges.style.marginInlineStart = gap; }); - if (row.type === 'rail') { - const badge = body.querySelector('[data-nl-slot="badge"]'); - if (badge) - badge.style.marginInlineStart = - 'calc(' + gap + ' - ' + defaultGap + ')'; - } } if (box.trailingGap !== undefined) { const gap = String(box.trailingGap) + 'px'; @@ -3999,12 +3941,6 @@ export function applyRowStyle(body: HTMLElement, row: RowModel): void { 'calc(' + gap + ' - ' + defaultGap + ')'; }); } - if (row.type === 'rail') { - const status = body.querySelector('[data-nl-slot="status"]'); - if (status) - status.style.marginInlineStart = - 'calc(' + gap + ' - ' + defaultGap + ')'; - } } if (leading && box.image && !compositeMetric) { const image = box.image; @@ -4082,11 +4018,7 @@ export function createRowBody( ): HTMLElement { const registered = rowRenderer(row); if (registered) - return registered.renderer.create( - context.document, - registered.row, - rendererPrimitives(context) - ); + return registered.create(context.document, rendererPrimitives(context)); switch (row.type) { case 'walletGroup': return createWalletGroupRow(context, row); @@ -4096,8 +4028,6 @@ export function createRowBody( return createActionRow(context, row); case 'system': return createSystemRow(context, row); - case 'rail': - return createRailRow(context, row); case 'mediaTile': return createMediaRow(context, row); case 'metricCard': @@ -4140,6 +4070,7 @@ export class NativeListWebEngine { private readonly pool: Record = { legacy: [], message: [], + rail: [], }; private readonly rendererKeys = new WeakMap(); private frameHandle: number | undefined; @@ -4870,9 +4801,8 @@ export class NativeListWebEngine { const row = this.rows[index]; const registered = row && rowRenderer(row); if (registered && element.firstElementChild) { - const height = registered.renderer.measureRendered( - element.firstElementChild as HTMLElement, - registered.row + const height = registered.measureRendered( + element.firstElementChild as HTMLElement ); if ( height !== undefined && @@ -4928,7 +4858,7 @@ export class NativeListWebEngine { const registered = rowRenderer(row); const existingBody = element.firstElementChild as HTMLElement | null; const reusableBody = - registered && existingBody?.dataset.nlRenderer === registered.renderer.key + registered && existingBody?.dataset.nlRenderer === registered.key ? existingBody : undefined; if (!reusableBody) disposeWebImageRetries(element); @@ -4981,11 +4911,7 @@ export class NativeListWebEngine { }; const body = reusableBody ?? createRowBody(context, row); if (reusableBody && registered) - registered.renderer.bind( - body, - registered.row, - rendererPrimitives(context) - ); + registered.bind(body, rendererPrimitives(context)); applySelectorTabularNumbers(body, row); // OneKey patch: explicit selector fields preserve original page geometry. element.style.contain = row.backgroundFullWidth ? 'layout style' : ''; diff --git a/native-views/react-native-native-list/src/web/templates/RailRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/RailRowRenderer.ts new file mode 100644 index 000000000..86984cbb8 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/RailRowRenderer.ts @@ -0,0 +1,136 @@ +import type { NativeListTextStyle, RailRow } from '../../models'; +import type { RowPrimitives } from './RowVisual'; +import { RowVisualStyle } from './RowVisual'; + +export function resolveRailRow(row: RailRow) { + const style = row.style; + return { + horizontalPadding: style?.horizontalPadding ?? 4, + verticalPadding: style?.verticalPadding ?? 4, + leadingGap: style?.leadingGap ?? 6, + badgeGap: style?.titleBadgeGap ?? 6, + trailingGap: style?.trailingGap ?? 6, + width: style?.image?.width ?? 20, + height: style?.image?.height ?? 20, + title: row.title, + badge: row.badge?.text ?? '', + status: row.status === 'none' ? '' : row.status ?? '', + }; +} + +type Views = { + title: HTMLElement; + badge: HTMLElement; + status: HTMLElement; + visual?: HTMLElement; + visualStyle?: RowVisualStyle; + identity: string; + primitives: RowPrimitives; +}; +const views = new WeakMap(); + +function bind(body: HTMLElement, row: RailRow, primitives: RowPrimitives) { + const v = views.get(body)!; + v.primitives = primitives; + const r = resolveRailRow(row); + body.style.cssText = ''; + body.removeAttribute('data-nl-container-background'); + body.removeAttribute('data-nl-container-border'); + body.style.padding = `${r.verticalPadding}px ${r.horizontalPadding}px`; + const text = ( + view: HTMLElement, + value: string, + style?: NativeListTextStyle + ) => { + view.style.cssText = ''; + view.textContent = value; + if (style) primitives.textStyle(view, style); + if (value) body.appendChild(view); + else view.remove(); + }; + text(v.title, r.title, row.style?.title); + text(v.badge, r.badge, row.style?.badge); + if (row.badge?.tone) v.badge.dataset.tone = row.badge.tone; + else delete v.badge.dataset.tone; + v.badge.style.marginInlineStart = `${r.badgeGap - 6}px`; + text(v.status, r.status, row.style?.status); + v.status.style.marginInlineStart = `${r.trailingGap - 6}px`; + const identity = JSON.stringify([row.key, row.visual]); + if (identity !== v.identity) { + if (v.visual) { + primitives.dispose(v.visual); + v.visual.remove(); + } + v.visual = primitives.visual(row.visual); + v.visualStyle = v.visual ? new RowVisualStyle(v.visual) : undefined; + v.identity = identity; + } + if (v.visual) body.insertBefore(v.visual, body.firstChild); + v.visualStyle?.apply(row.style?.image, r.width, r.height, r.leadingGap - 6); +} +function create(document: Document, row: RailRow, primitives: RowPrimitives) { + const span = (className: string, slot: string) => { + const view = document.createElement('span'); + view.className = className; + view.dataset.nlSlot = slot; + return view; + }; + const body = document.createElement('div'); + body.className = 'ok-native-list-row ok-native-list-rail'; + body.dataset.nlRenderer = 'rail'; + views.set(body, { + title: span('ok-native-list-rail-title', 'title'), + badge: span('ok-native-list-badge', 'badge'), + status: span('ok-native-list-secondary', 'status'), + identity: '', + primitives, + }); + bind(body, row, primitives); + return body; +} +function recycle(body: HTMLElement) { + const v = views.get(body); + if (!v) return; + if (v.visual) { + v.primitives.dispose(v.visual); + v.visual.remove(); + } + v.visual = undefined; + v.visualStyle = undefined; + v.identity = ''; +} +export function measureRailWidth(row: RailRow) { + const r = resolveRailRow(row); + const width = ( + value: string, + style: NativeListTextStyle | undefined, + size: number + ) => value.length * 7 * ((style?.fontSize ?? size) / size); + // Retain the historical 50px allowance and viewport limits at default values. + return Math.min( + 288, + Math.max( + 72, + 50 + + width(r.title, row.style?.title, 12) + + width(r.badge, row.style?.badge, 11) + + width(r.status, row.style?.status, 13) + + (r.horizontalPadding - 4) * 2 + + r.width - + 20 + + r.leadingGap - + 6 + + (r.badge ? r.badgeGap - 6 : 0) + + (r.status ? r.trailingGap - 6 : 0) + ) + ); +} +export const railRowRenderer = { + key: 'rail' as const, + create, + bind, + recycle, + measure: (_row: RailRow, _width: number) => 40, + measureRendered: (_body: HTMLElement, _row: RailRow): number | undefined => + undefined, +}; diff --git a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts index 58b1dc59e..31954a3fc 100644 --- a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts +++ b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts @@ -1,20 +1,51 @@ -import type { RowModel } from '../../models'; +import type { MessageRow, RailRow, RowModel } from '../../models'; import { messageRowRenderer } from './MessageRowRenderer'; +import type { RowPrimitives } from './RowVisual'; +import { railRowRenderer } from './RailRowRenderer'; -// Closed registration: only Message is migrated. Legacy templates keep their -// existing binders, updates and default geometry until their own migration. +type Renderer = { + key: string; + create: ( + document: Document, + row: R, + primitives: RowPrimitives + ) => HTMLElement; + bind: (body: HTMLElement, row: R, primitives: RowPrimitives) => void; + measure: (row: R, width: number) => number; + measureRendered: (body: HTMLElement, row: R) => number | undefined; + recycle: (body: HTMLElement) => void; +}; +function registration(renderer: Renderer, row: R) { + return { + key: renderer.key, + create: (document: Document, primitives: RowPrimitives) => + renderer.create(document, row, primitives), + bind: (body: HTMLElement, primitives: RowPrimitives) => + renderer.bind(body, row, primitives), + measure: (width: number) => renderer.measure(row, width), + measureRendered: (body: HTMLElement) => renderer.measureRendered(body, row), + }; +} +const factories = { + message: (row: RowModel) => + registration(messageRowRenderer, row as MessageRow), + rail: (row: RowModel) => registration(railRowRenderer, row as RailRow), +}; +const renderers = { message: messageRowRenderer, rail: railRowRenderer }; +export type RowRendererKey = 'legacy' | keyof typeof factories; +const isRegistered = (key: string): key is keyof typeof factories => + Object.prototype.hasOwnProperty.call(factories, key); + +// Closed registration; a row's data key, styles and placement never affect reuse. export function rowRenderer(row: RowModel) { - return row.type === 'message' - ? { renderer: messageRowRenderer, row } - : undefined; + return isRegistered(row.type) ? factories[row.type](row) : undefined; } -export type RowRendererKey = 'legacy' | typeof messageRowRenderer.key; export function rowRendererKey(row: RowModel): RowRendererKey { - return rowRenderer(row)?.renderer.key ?? 'legacy'; + return isRegistered(row.type) ? row.type : 'legacy'; } - export function recycleRowBody(body: HTMLElement): boolean { - if (body.dataset.nlRenderer !== 'message') return false; - messageRowRenderer.recycle(body); + const key = body.dataset.nlRenderer ?? ''; + if (!isRegistered(key)) return false; + renderers[key].recycle(body); return true; } From 0d8d06fe9be8002982c5553bbc1925af6daceb7f Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 22 Sep 2026 23:30:41 +0800 Subject: [PATCH 38/71] docs(native-list): record Rail migration and runtime acceptance --- .../react-native-native-list/docs/DESIGN.md | 43 ++++++++++++++++--- .../docs/MIGRATION_BASELINE.md | 14 ++++++ .../react-native-native-list/docs/SPEC.md | 19 +++++--- 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index f02de49dc..8aa17706d 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -104,12 +104,12 @@ subtrees. This is source-level ownership evidence, not a performance benchmark. The renderer resolves defaults from current data/theme/style rather than capturing a previous row's view state. Legacy global slot maps no longer contain -Message. No public plugin API or Nitro schema is added. Remaining template maps +Message or Rail. No public plugin API or Nitro schema is added. Remaining template maps and partial updates are retired only with their own migration. See [SPEC.md](SPEC.md#conformance-and-six-stage-migration) for all six stages. -Structural reuse is now partitioned into two internal families on all platforms: -`message` and `legacy`. Unmigrated templates retain their existing shared tree. +Structural reuse is partitioned into three internal families on all platforms: +`message`, `rail` and `legacy`. Unmigrated templates retain their existing shared tree. Neither `row.key`, content, style, height nor placement changes the family. Web selects a compatible wrapper pool even when the row at a mounted index changes family. iOS registers separate reuse identifiers and reloads retained @@ -120,11 +120,11 @@ to replace an incompatible holder. Market quote and selection payloads retain their existing full/partial update decisions. Footer and nested-row hosts remain outside these scrolling pools. -The two native reuse families now allocate different classes through the closed +The native reuse families allocate different classes through the closed registry. The list only uses `NativeListRowHost` lifecycle operations; Message binds and measures its own resolved model. Market quote, selection echo, WalletGroup compact/expanded reorder and fixed-footer behavior remain on their -existing paths. The next step is stage 3: migrate simple templates one at a time. +existing paths. Stage 3 migrates simple templates one at a time. The structural reuse follow-up on 2026-09-22 passed 150 package tests, typecheck, lint (zero errors), the Android build/seven unit tests and a full iOS build. @@ -177,6 +177,39 @@ weighted/aligned columns. Orientation, refresh, load-more, visibility events, empty state, fixed footer, and native reordering are capabilities, not row types. + +### Stage 3: Rail increment (2026-09-22) + +Rail joins the closed registry on Web, Android and iOS. Its title, badge, status +and leading visual belong to the Rail renderer; it no longer allocates the +legacy composite tree. Message and Rail share `NativeListRendererCell` / +`NativeListRendererRowView` for appearance, binding classification, action-anchor +invalidation and recycling. Each declares its own asset fields. Web uses a +shared primitive contract and visual-style restoration without touching async +image visibility. No public API or generated bridge change is involved. + +Unstyled typography, gaps, platform height defaults and horizontal width +allowances are preserved. Explicit row styles participate in horizontal sizing; +this repairs the previous fixed-metric estimator. The Android resolved-text +primitive also fixes an omitted `offsetY` being read as NaN when another text +style was provided; it now resolves to zero. Explicit line heights without an +offset visibly render in both Rail and Message. + +The increment passes 153 package tests, TypeScript, lint (zero errors), Android +build/seven unit tests, and a full iOS build. Chrome, Android API 36 and iOS 26.5 +ran style set/clear, local images and corner badges, text/image replacement, +badge/status removal, horizontal sizing, same-key Rail/Message/Identity swaps, +end/top scrolling, empty/repopulate and the shared fixed footer. iOS default and +styled text bounds match the captured legacy Rail baseline. Chrome additionally +ran a 390px RTL viewport. All three platforms verified row clicks, disabled clicks and drag reorder. +iOS reorder used XCTest long-press-and-drag with a 600ms hold; a synthesized +swipe alone did not exercise the long-press recognizer. + +This is stage 3's first template, not completion of the seven-template stage. +mediaTile, action, system, activity, dataRow and metricCard remain in the legacy +family. Their variants, embedded actions and table semantics require their own +increments and runtime acceptance. + ## Section index The mobile section index is an opt-in capability for vertical `sectioned` diff --git a/native-views/react-native-native-list/docs/MIGRATION_BASELINE.md b/native-views/react-native-native-list/docs/MIGRATION_BASELINE.md index 54f6f6f8b..65fdcc3b8 100644 --- a/native-views/react-native-native-list/docs/MIGRATION_BASELINE.md +++ b/native-views/react-native-native-list/docs/MIGRATION_BASELINE.md @@ -70,3 +70,17 @@ body/time, same-key Message/Identity changes, images, end/top scrolling, 41-row mixed swaps, empty/repopulate and the fixed footer on all three platforms. Full native acceptance of the later templates remains a gate for stages 3–5, not an implied result of finishing this inventory. + +## Rail extraction baseline (stage 3) + +Rail keeps iOS title/badge gap 8 versus Android/Web 6. Android's default status +margin is 0 even though its historical width reserves 6; iOS/Web use 6. Default +height remains iOS/Web 40 and Android's scaled minimum 28. Native text is 12, +with 16-unit title/badge line metrics; Android status keeps its natural line +height. Web retains 12/11/13 for title/badge/status. Leading size is 20 and +padding is 4. These are existing differences, not different style property APIs. +Horizontal sizing retains the legacy allowances (including iOS's old 6-unit +badge-gap estimate inside its trailing budget and Android's status allowance) +and now applies explicit resolved style metrics. Style removal restores the +baseline dimensions. Rail does not show persistent row selection; press and +reorder feedback remain container-owned. diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index 8b66ae561..b61f03735 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -44,7 +44,7 @@ UI thread; [DESIGN.md](DESIGN.md) describes update and event ownership. `row.key` identifies data; renderer reuse identity describes compatible view structure. Content, styles, height and placement must not enter reuse identity. -The implemented families are `message` and `legacy`; the latter serves all +The implemented families are `message`, `rail` and `legacy`; the latter serves all unmigrated templates. Same-key family changes must replace the incompatible host. OneKeyImage owns image caches; NativeList owns row/slot binding identity. @@ -67,8 +67,8 @@ migration adds no new input limits, retry policy or automatic layout correction. ## Performance and resources Snapshots and patches cross the bridge in batches. Visible rows use platform -recycling/windowing; shared primitives own image cancellation. Message uses a dedicated lightweight native host and persistent Web body. -The legacy native tree is not allocated for Message. This structural change is +recycling/windowing; shared primitives own image cancellation. Message and Rail use dedicated lightweight native hosts and persistent Web bodies. +Neither allocates the legacy native tree. This structural change is not a measured scrolling-performance result; performance claims still need a separate workload and measurements. @@ -78,12 +78,12 @@ separate workload and measurements. | --- | --- | --- | | 1. Baseline | Complete: [implicit keys, defaults, special updates and acceptance baseline](MIGRATION_BASELINE.md) inventoried | Preserve each existing rule or explicitly migrate its caller | | 2. Message pilot | Complete: registered lightweight native hosts, persistent Web body, resolved styling/measurement and lifecycle acceptance | Lightweight native hosts, resolved styling/measurement, update classification and lifecycle acceptance | -| 3. Simple templates | In progress: migrate one template across all three platforms per increment | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | +| 3. Simple templates | In progress (1/7): Rail migrated on all three platforms; six templates remain | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | | 4. Complex templates | Not started | Migrate Market, Identity and SectionHeader while preserving quote, selection and header behavior | | 5. WalletGroup | Not started | Independent member renderers; verify member events/styles, compact dragging and height restoration | | 6. Cleanup | Not started | Remove migrated key inference and global style maps; container no longer manipulates template internals | -The closed internal registry selects Message or the legacy family. Message owns +The closed internal registry selects Message, Rail or the legacy family. Message owns its text column, optional leading visual and thumbnail; its native host does not allocate Market, WalletGroup or table views. Web preserves the Message body, text nodes and unchanged image elements when rebinding. Remaining templates @@ -123,3 +123,12 @@ must invalidate horizontal placement when its measured width changes. Fixed template height defaults remain defaults, not automatic content fitting. DataRow keeps the inventoried `asset`-column Web badge behavior until a separate caller migration; this extraction does not change which column owns badges. + +Rail owns its title/badge/status views and leading visual. Shared native hosts +own appearance, action-anchor epochs, selection routing and reuse invalidation; +Web uses the same primitive interface for Message and Rail. Legacy Rail bind, +style-slot, reset and width branches have been removed. Horizontal measurement +now responds to explicit fonts, image dimensions, padding and gaps while +retaining the existing default width allowances and platform limits. Text-only +rebinding preserves unchanged image requests; clearing styles restores defaults. +Stage 3 still requires mediaTile, action, system, activity, dataRow and metricCard. From 83cbeccb3fb3e741b0e93026267c34c7e3df6610 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 19:07:32 +0800 Subject: [PATCH 39/71] refactor(native-list): migrate MediaTile renderer on three platforms --- .../nativelist/NativeListMediaTileRenderer.kt | 224 +++++++ .../com/onekey/nativelist/NativeListModels.kt | 7 +- .../nativelist/NativeListRendererRegistry.kt | 35 +- .../nativelist/NativeListRendererRowView.kt | 558 +++++++++--------- .../onekey/nativelist/NativeListRowView.kt | 152 +---- .../nativelist/NativeListStyleSlotTest.kt | 1 - .../react-native-native-list/docs/DESIGN.md | 21 + .../react-native-native-list/docs/SPEC.md | 18 +- .../ios/NativeListCell.swift | 174 +----- .../ios/NativeListMediaTileRenderer.swift | 164 +++++ .../ios/NativeListModels.swift | 2 +- .../ios/NativeListRendererCell.swift | 37 +- .../ios/NativeListRendererRegistry.swift | 3 +- .../ios/RNCNativeListView.swift | 1 - .../src/__tests__/NativeList.web.test.ts | 92 +++ .../src/web/NativeListWebEngine.ts | 104 +--- .../src/web/templates/MediaTileRowRenderer.ts | 182 ++++++ .../src/web/templates/RowRendererRegistry.ts | 11 +- 18 files changed, 1079 insertions(+), 707 deletions(-) create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt create mode 100644 native-views/react-native-native-list/ios/NativeListMediaTileRenderer.swift create mode 100644 native-views/react-native-native-list/src/web/templates/MediaTileRowRenderer.ts diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt new file mode 100644 index 000000000..58c7b9456 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt @@ -0,0 +1,224 @@ +package com.margelo.nitro.nativelist + +import android.graphics.Color +import android.graphics.Outline +import android.graphics.drawable.GradientDrawable +import android.view.Gravity +import android.view.View +import android.view.ViewOutlineProvider +import android.widget.FrameLayout +import android.widget.LinearLayout +import com.facebook.react.uimanager.ThemedReactContext +import org.json.JSONObject + +internal class NativeListMediaTileRowView(context: ThemedReactContext) : + NativeListRendererRowView(context) { + private val picture = FrameLayout(context) + private val image = NativeListImageSlot(context) + private val network = NativeListImageSlot(context) + private val errorIcon = OneKeyIconView(context) + private val title = NativeListTextView(context) + private val subtitle = NativeListTextView(context) + private val badge = NativeListTextView(context) + private val metadata = LinearLayout(context).apply { orientation = VERTICAL } + private val subtitleLine = + LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + private val close = NativeListTextView(context) + private var imageStyle = JSONObject() + private var closeKey = "" + override val defaultCornerRadius = 16 + override val showsSelection = false + override val pressChangesBackground = false + override val assetFields = listOf("image", "networkImage") + + override fun horizontalWidth(item: NativeListItem) = dp(200) + + override fun pressContent(pressed: Boolean) { + picture.alpha = if (pressed) 0.8f else 1f + } + + init { + orientation = VERTICAL + picture.addView(image.view, FrameLayout.LayoutParams(-1, -1)) + picture.addView(errorIcon) + picture.addView(badge) + subtitleLine.addView(subtitle, LayoutParams(0, -2, 1f)) + subtitleLine.addView(network.view) + metadata.addView(subtitleLine, LayoutParams(-1, -2)) + metadata.addView(title, LayoutParams(-1, -2)) + addView(picture) + addView(metadata, LayoutParams(-1, -2)) + addView(close, LayoutParams(-1, -2)) + close.setOnClickListener { emitAction(closeKey, close, "mediaClose") } + close.gravity = Gravity.END + } + + override fun bindContent( + item: NativeListItem, + theme: JSONObject?, + layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + val style = item.json.optJSONObject("style") ?: JSONObject() + imageStyle = style.optJSONObject("image") ?: JSONObject() + fun size(name: String, fallback: Int) = + if (style.has(name)) stylePx(style.optDouble(name)) else dp(fallback) + fun tint(name: String, fallback: String) = + parseNativeListColor(theme?.optString(name, fallback) ?: fallback) + setPadding( + size("horizontalPadding", 10), + size("verticalPadding", 10), + size("horizontalPadding", 10), + size("verticalPadding", 10), + ) + gravity = + Gravity.START or + when (style.optJSONObject("container")?.optString("contentVerticalAlignment")) { + "bottom" -> Gravity.BOTTOM + "center" -> Gravity.CENTER_VERTICAL + else -> Gravity.TOP + } + NativeListResolvedText.resolve( + reactContext, + item.json.optString("title"), + style.optJSONObject("title"), + 16f, + "medium", + tint("primaryText", "#000000DF"), + 24, + 1, + sourceScale, + ) + .bind(title) + NativeListResolvedText.resolve( + reactContext, + item.json.optString("subtitle"), + style.optJSONObject("subtitle"), + 12f, + "regular", + tint("secondaryText", "#0000009B"), + 16, + 1, + sourceScale, + ) + .bind(subtitle) + NativeListResolvedText.resolve( + reactContext, + item.json.optJSONObject("badge")?.optString("text") ?: "", + style.optJSONObject("badge"), + 14f, + "regular", + parseNativeListColor("#FCFCFC"), + 0, + 1, + sourceScale, + ) + .bind(badge) + if (!style.optJSONObject("badge").let { it?.has("alignment") == true }) + badge.gravity = Gravity.CENTER + badge.setPadding(dp(8), 0, dp(8), 0) + badge.background = + GradientDrawable().apply { + setColor(parseNativeListColor("#000000DF")) + cornerRadius = dp(10).toFloat() + setStroke(dp(1), Color.WHITE) + } + badge.layoutParams = FrameLayout.LayoutParams(-2, dp(24), Gravity.END or Gravity.BOTTOM) + title.layoutParams = LayoutParams(-1, -2).apply { topMargin = size("lineGap", 0) } + metadata.layoutParams = LayoutParams(-1, -2).apply { topMargin = size("leadingGap", 8) } + (subtitle.layoutParams as LayoutParams).marginEnd = dp(8) + val state = item.json.optString("imageState") + errorIcon.visibility = if (state == "error") VISIBLE else GONE + errorIcon.iconName = "ImageSquareWavesOutline" + errorIcon.tintColor = parseNativeListColor("#00000044") + errorIcon.useSourceScale = sourceScale + errorIcon.layoutParams = FrameLayout.LayoutParams(dp(24), dp(24), Gravity.CENTER) + if (state != "empty" && state != "error" && item.json.has("image")) + image.bind( + item.json.getJSONObject("image"), + "${item.key}:media", + fit = imageStyle.optString("contentFit").takeIf { it.isNotEmpty() }, + ) + else image.recycle() + image.view.background = + GradientDrawable().apply { + setColor( + if (state == "error") tint("strongBackground", "#0000000F") else Color.WHITE + ) + } + image.view.outlineProvider = + object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + if ( + imageStyle.optString("shape") == "circle" && !imageStyle.has("cornerRadius") + ) + outline.setOval(0, 0, view.width, view.height) + else + outline.setRoundRect( + 0, + 0, + view.width, + view.height, + if (imageStyle.has("cornerRadius")) + stylePx(imageStyle.optDouble("cornerRadius")).toFloat() + else if (imageStyle.optString("shape") == "square") 0f + else dp(10).toFloat(), + ) + } + } + image.view.clipToOutline = true + network.view.layoutParams = LayoutParams(dp(14), dp(14)) + network.view.outlineProvider = + object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setOval(0, 0, view.width, view.height) + } + } + network.view.clipToOutline = true + item.json.optJSONObject("networkImage")?.let { source -> + network.view.visibility = VISIBLE + network.view.alpha = 0f + network.bind(source, "${item.key}:network", variant = "network", round = true) { loaded + -> + network.view.alpha = if (loaded) 1f else 0f + } + } + ?: run { + network.recycle() + network.view.visibility = GONE + } + closeKey = item.json.optString("closeActionKey") + close.text = "×" + close.textSize = if (sourceScale) 22f else NativeListScale.font(resources, 22f) + close.setTextColor(tint("primaryText", "#000000DF")) + close.visibility = if (closeKey.isEmpty()) GONE else VISIBLE + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val width = + if (imageStyle.has("width")) stylePx(imageStyle.optDouble("width")) + else + (MeasureSpec.getSize(widthMeasureSpec) - paddingLeft - paddingRight).coerceAtLeast( + 0 + ) + val height = + if (imageStyle.has("height")) stylePx(imageStyle.optDouble("height")) else width + picture.layoutParams = LayoutParams(width, height) + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + + override fun recycleContent() { + image.recycle() + network.recycle() + closeKey = "" + picture.alpha = 1f + } + + override fun disposeContent() { + image.dispose() + network.dispose() + } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index 24e7a25f4..4a406a7b2 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -54,11 +54,6 @@ internal fun nativeListStyleSlot(type: String, variant: String, field: String): "index" -> "value" else -> null } - "mediaTile" -> when (field) { - "title", "subtitle" -> field - "badge" -> "mediaBadge" - else -> null - } // The large number and the small label sit in swapped views. "metricCard" -> when (field) { "value" -> "title" @@ -111,7 +106,7 @@ private fun updateLengthPrefixed(digest: MessageDigest, value: String) { // Message and Rail own dedicated renderers. Remaining templates still share // a compatible legacy tree; keys and style values never partition the pool. -internal enum class NativeListRendererKey { LEGACY, MESSAGE, RAIL } +internal enum class NativeListRendererKey { LEGACY, MESSAGE, RAIL, MEDIA_TILE } internal data class NativeListItem( val key: String, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt index bc15c1800..1f7f97c6a 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt @@ -3,26 +3,31 @@ package com.margelo.nitro.nativelist import com.facebook.react.uimanager.ThemedReactContext internal object NativeListRendererRegistry { - private val factories: Map NativeListRowHost> = - mapOf( - NativeListRendererKey.LEGACY to ::NativeListRowView, - NativeListRendererKey.MESSAGE to ::NativeListMessageRowView, - NativeListRendererKey.RAIL to ::NativeListRailRowView, - ) + private val factories: Map NativeListRowHost> = + mapOf( + NativeListRendererKey.LEGACY to ::NativeListRowView, + NativeListRendererKey.MESSAGE to ::NativeListMessageRowView, + NativeListRendererKey.RAIL to ::NativeListRailRowView, + NativeListRendererKey.MEDIA_TILE to ::NativeListMediaTileRowView, + ) - private val keys = - mapOf("message" to NativeListRendererKey.MESSAGE, "rail" to NativeListRendererKey.RAIL) + private val keys = + mapOf( + "message" to NativeListRendererKey.MESSAGE, + "rail" to NativeListRendererKey.RAIL, + "mediaTile" to NativeListRendererKey.MEDIA_TILE, + ) - fun key(type: String) = keys[type] ?: NativeListRendererKey.LEGACY + fun key(type: String) = keys[type] ?: NativeListRendererKey.LEGACY - fun create(context: ThemedReactContext, viewType: Int): NativeListRowHost = - factories.getValue(NativeListRendererKey.entries[viewType])(context) + fun create(context: ThemedReactContext, viewType: Int): NativeListRowHost = + factories.getValue(NativeListRendererKey.entries[viewType])(context) } // Compatibility belongs to list chrome, never to Message style resolution. // docs/MIGRATION_BASELINE.md records the callers that must migrate before removal. internal fun nativeListLegacyShowsSeparator(item: NativeListItem): Boolean = - item.json.optBoolean("separator") && - !item.key.startsWith("token-") && - !item.key.startsWith("balance-token-") && - item.key != "linear-custom-token" + item.json.optBoolean("separator") && + !item.key.startsWith("token-") && + !item.key.startsWith("balance-token-") && + item.key != "linear-custom-token" diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt index 11b322983..cea3334ce 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt @@ -5,302 +5,334 @@ import android.graphics.Color import android.graphics.Paint import android.graphics.drawable.GradientDrawable import android.view.MotionEvent +import android.view.View import android.view.ViewOutlineProvider import com.facebook.react.uimanager.ThemedReactContext import kotlin.math.roundToInt import org.json.JSONObject internal enum class NativeListRendererUpdate { - UNCHANGED, - CONTENT, - ASSETS, - REPLACE, + UNCHANGED, + CONTENT, + ASSETS, + REPLACE, } // Shared chrome and binding lifetime; each renderer owns its content views. internal abstract class NativeListRendererRowView(protected val reactContext: ThemedReactContext) : - NativeListRowHost(reactContext) { - protected abstract fun bindContent( - item: NativeListItem, - theme: JSONObject?, - layout: String, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - ) + NativeListRowHost(reactContext) { + protected abstract fun bindContent( + item: NativeListItem, + theme: JSONObject?, + layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) - protected abstract fun recycleContent() + protected abstract fun recycleContent() - protected abstract fun disposeContent() + protected abstract fun disposeContent() - protected open val assetFields: List = emptyList() + protected open val assetFields: List = emptyList() - private fun update(old: NativeListItem?, next: NativeListItem): NativeListRendererUpdate { - if (old == null || old.key != next.key || old.rendererKey != next.rendererKey) - return NativeListRendererUpdate.REPLACE - if (old.content == next.content) return NativeListRendererUpdate.UNCHANGED - if ( - assetFields.any { - nativeListCanonical(old.json.opt(it)) != nativeListCanonical(next.json.opt(it)) - } - ) - return NativeListRendererUpdate.ASSETS - return NativeListRendererUpdate.CONTENT - } + private fun update(old: NativeListItem?, next: NativeListItem): NativeListRendererUpdate { + if (old == null || old.key != next.key || old.rendererKey != next.rendererKey) + return NativeListRendererUpdate.REPLACE + if (old.content == next.content) return NativeListRendererUpdate.UNCHANGED + if ( + assetFields.any { + nativeListCanonical(old.json.opt(it)) != nativeListCanonical(next.json.opt(it)) + } + ) + return NativeListRendererUpdate.ASSETS + return NativeListRendererUpdate.CONTENT + } - protected open fun defaultHeight(item: NativeListItem, layout: String) = 0 + protected open fun defaultHeight(item: NativeListItem, layout: String) = 0 - protected open fun horizontalWidth(item: NativeListItem) = dp(280) + protected open fun horizontalWidth(item: NativeListItem) = dp(280) - protected open val defaultCornerRadius = 0 - protected open val showsSelection = true - protected open val pressedColorName = "rowPressedBackground" - protected open val pressedColorFallback = "#00000017" - private var current: NativeListItem? = null - private var theme: JSONObject? = null - private var listLayout = "linear" - private var selected = false - protected var sourceScale = false - private var inputSignature = "" - private var explicitHeight: Int? = null - private var touchPressed = false - private var reorderActive = false - private val separator = Paint(Paint.ANTI_ALIAS_FLAG) + protected open val defaultCornerRadius = 0 + protected open val pressChangesBackground = true - protected fun dp(value: Int) = - if (sourceScale) (value * resources.displayMetrics.density).roundToInt() - else NativeListScale.dp(resources, value) + protected open fun pressContent(pressed: Boolean) {} - protected fun stylePx(value: Double) = (value * resources.displayMetrics.density).roundToInt() + protected open val showsSelection = true + protected open val pressedColorName = "rowPressedBackground" + protected open val pressedColorFallback = "#00000017" + private var current: NativeListItem? = null + private var theme: JSONObject? = null + private var listLayout = "linear" + private var selected = false + protected var sourceScale = false + private var inputSignature = "" + private var explicitHeight: Int? = null + private var touchPressed = false + private var reorderActive = false + private val separator = Paint(Paint.ANTI_ALIAS_FLAG) - private fun color(value: String, fallback: Int = Color.TRANSPARENT) = - runCatching { parseNativeListColor(value) }.getOrDefault(fallback) + protected fun dp(value: Int) = + if (sourceScale) (value * resources.displayMetrics.density).roundToInt() + else NativeListScale.dp(resources, value) - init { - orientation = HORIZONTAL - setOnClickListener { - current - ?.takeIf { it.isRowPressEnabled } - ?.let { onRowPress?.invoke(it, NativeListActionOrigin(this, this, bindingEpoch, "row")) } - } - setOnTouchListener { _, event -> - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> touchPressed = current?.isRowPressEnabled == true - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL -> touchPressed = false - } - appearance() - false - } - } + protected fun stylePx(value: Double) = (value * resources.displayMetrics.density).roundToInt() - override fun bind( - item: NativeListItem, - theme: JSONObject?, - layout: String, - listOrientation: String, - itemIndex: Int?, - selected: Boolean, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - useSourceScale: Boolean, - ) { - val update = update(current, item) - val signature = - nativeListCanonical( - JSONObject() - .put("theme", theme) - .put("layout", layout) - .put("orientation", listOrientation) - .put("sourceScale", useSourceScale) - .put("listStyle", listStyle) - ) - sourceScale = useSourceScale - if (update != NativeListRendererUpdate.UNCHANGED || signature != inputSignature) { - if (current != null) onBindingInvalidated?.invoke(this, bindingEpoch) - bindingEpoch++ - if (update == NativeListRendererUpdate.REPLACE) { - recycleContent() - touchPressed = false - } - bindContent(item, theme, layout, checkboxState) - inputSignature = signature + private fun color(value: String, fallback: Int = Color.TRANSPARENT) = + runCatching { parseNativeListColor(value) }.getOrDefault(fallback) + + init { + orientation = HORIZONTAL + setOnClickListener { + current + ?.takeIf { it.isRowPressEnabled } + ?.let { + onRowPress?.invoke(it, NativeListActionOrigin(this, this, bindingEpoch, "row")) + } + } + setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> touchPressed = current?.isRowPressEnabled == true + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> touchPressed = false + } + appearance() + false + } } - current = item - tag = item - this.theme = theme - listLayout = layout - this.selected = selected - explicitHeight = - item.styledHeight?.let(::stylePx) - ?: if (item.json.has("height")) - when (item.json.optString("heightRounding")) { - "floor" -> (item.json.optDouble("height") * resources.displayMetrics.density).toInt() - "nearest" -> stylePx(item.json.optDouble("height")) - else -> dp(item.json.optInt("height")) - } - else null - val sizeDelta = - when (item.json.optString("size")) { - "small" -> -8 - "large" -> 12 - else -> 0 - } - minimumHeight = - if (explicitHeight == null) dp((defaultHeight(item, layout) + sizeDelta).coerceAtLeast(0)) - else 0 - layoutParams = - (layoutParams - ?: android.view.ViewGroup.LayoutParams( - LayoutParams.MATCH_PARENT, - LayoutParams.WRAP_CONTENT, - )) - .apply { - width = - if (listOrientation == "horizontal") horizontalWidth(item) - else LayoutParams.MATCH_PARENT - height = LayoutParams.WRAP_CONTENT + + override fun bind( + item: NativeListItem, + theme: JSONObject?, + layout: String, + listOrientation: String, + itemIndex: Int?, + selected: Boolean, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + useSourceScale: Boolean, + ) { + val update = update(current, item) + val signature = + nativeListCanonical( + JSONObject() + .put("theme", theme) + .put("layout", layout) + .put("orientation", listOrientation) + .put("sourceScale", useSourceScale) + .put("listStyle", listStyle) + ) + sourceScale = useSourceScale + if (update != NativeListRendererUpdate.UNCHANGED || signature != inputSignature) { + if (current != null) onBindingInvalidated?.invoke(this, bindingEpoch) + bindingEpoch++ + if (update == NativeListRendererUpdate.REPLACE) { + recycleContent() + touchPressed = false + } + bindContent(item, theme, layout, checkboxState) + inputSignature = signature } - contentDescription = item.json.optString("accessibilityLabel", item.json.optString("title")) - setTag( - com.facebook.react.R.id.react_test_id, - item.json.optString("testID").takeIf { it.isNotEmpty() }, - ) - isEnabled = !item.json.optBoolean("disabled") - isClickable = item.isWholeRowInteractive - isFocusable = isClickable - if (!isEnabled) touchPressed = false - appearance() - requestLayout() - } + current = item + tag = item + this.theme = theme + listLayout = layout + this.selected = selected + explicitHeight = + item.styledHeight?.let(::stylePx) + ?: if (item.json.has("height")) + when (item.json.optString("heightRounding")) { + "floor" -> + (item.json.optDouble("height") * resources.displayMetrics.density) + .toInt() + "nearest" -> stylePx(item.json.optDouble("height")) + else -> dp(item.json.optInt("height")) + } + else null + val sizeDelta = + when (item.json.optString("size")) { + "small" -> -8 + "large" -> 12 + else -> 0 + } + minimumHeight = + if (explicitHeight == null) + dp((defaultHeight(item, layout) + sizeDelta).coerceAtLeast(0)) + else 0 + layoutParams = + (layoutParams + ?: android.view.ViewGroup.LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.WRAP_CONTENT, + )) + .apply { + width = + if (listOrientation == "horizontal") horizontalWidth(item) + else LayoutParams.MATCH_PARENT + height = LayoutParams.WRAP_CONTENT + } + contentDescription = item.json.optString("accessibilityLabel", item.json.optString("title")) + setTag( + com.facebook.react.R.id.react_test_id, + item.json.optString("testID").takeIf { it.isNotEmpty() }, + ) + isEnabled = !item.json.optBoolean("disabled") + isClickable = item.isWholeRowInteractive + isFocusable = isClickable + if (!isEnabled) touchPressed = false + appearance() + requestLayout() + } - override fun bindSelection( - item: NativeListItem, - theme: JSONObject?, - layout: String, - itemIndex: Int?, - selected: Boolean, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - ) { - if (current?.key != item.key) return - current = item - tag = item - this.theme = theme - this.selected = selected - listLayout = layout - appearance() - } + override fun bindSelection( + item: NativeListItem, + theme: JSONObject?, + layout: String, + itemIndex: Int?, + selected: Boolean, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + if (current?.key != item.key) return + current = item + tag = item + this.theme = theme + this.selected = selected + listLayout = layout + appearance() + } - private fun appearance() { - val item = current ?: return - val container = item.json.optJSONObject("style")?.optJSONObject("container") ?: JSONObject() - val showSelected = - showsSelection && selected && (listLayout != "sectioned" || item.json.optBoolean("selected")) - val backgroundName = - if (touchPressed || reorderActive) pressedColorName - else if (showSelected) "rowSelectedBackground" else "rowBackground" - val backgroundFallback = - if (touchPressed || reorderActive) pressedColorFallback - else if (showSelected) "#0000000F" else "#FFFFFF" - var fill = color(theme?.optString(backgroundName, backgroundFallback) ?: backgroundFallback) - if (!touchPressed && !reorderActive) { - if (item.json.has("backgroundColor")) - fill = color(item.json.optString("backgroundColor"), fill) - if (container.has("backgroundColor")) - fill = color(container.optString("backgroundColor"), fill) + protected fun emitAction( + key: String, + view: View, + source: String, + slot: Int? = null, + target: NativeSelectionTarget? = null, + ) { + val item = current ?: return + if (key.isNotEmpty() && !item.json.optBoolean("disabled")) + onAction?.invoke( + item, + key, + target, + NativeListActionOrigin(view, this, bindingEpoch, source, slot), + ) } - val position = item.json.optString("groupPosition") - val radius = - if (container.has("cornerRadius")) stylePx(container.optDouble("cornerRadius")).toFloat() - else if (defaultCornerRadius > 0) dp(defaultCornerRadius).toFloat() - else if (listStyle?.has("groupCornerRadius") == true) - stylePx(listStyle!!.optDouble("groupCornerRadius")).toFloat() - else dp(12).toFloat() - val top = - container.has("cornerRadius") || - defaultCornerRadius > 0 || - position in setOf("first", "single") - val bottom = - container.has("cornerRadius") || - defaultCornerRadius > 0 || - position in setOf("last", "single") - val radii = FloatArray(8) { if (it < 4 && top || it >= 4 && bottom) radius else 0f } - outlineProvider = ViewOutlineProvider.BACKGROUND - clipToOutline = container.has("cornerRadius") - background = - GradientDrawable().apply { - setColor(fill) - cornerRadii = radii - } - foreground = - if (container.has("borderWidth")) - GradientDrawable().apply { - setColor(Color.TRANSPARENT) - cornerRadii = radii - setStroke( - stylePx(container.optDouble("borderWidth")), - color(container.optString("borderColor", "#00000000")), - ) + + private fun appearance() { + val item = current ?: return + val pressed = touchPressed || reorderActive + pressContent(pressed) + val container = item.json.optJSONObject("style")?.optJSONObject("container") ?: JSONObject() + val showSelected = + showsSelection && + selected && + (listLayout != "sectioned" || item.json.optBoolean("selected")) + val backgroundName = + if (pressed && pressChangesBackground) pressedColorName + else if (showSelected) "rowSelectedBackground" else "rowBackground" + val backgroundFallback = + if (pressed && pressChangesBackground) pressedColorFallback + else if (showSelected) "#0000000F" else "#FFFFFF" + var fill = color(theme?.optString(backgroundName, backgroundFallback) ?: backgroundFallback) + if (!pressed || !pressChangesBackground) { + if (item.json.has("backgroundColor")) + fill = color(item.json.optString("backgroundColor"), fill) + if (container.has("backgroundColor")) + fill = color(container.optString("backgroundColor"), fill) } - else null - alpha = - container.optDouble("opacity", item.json.optDouble("opacity", 1.0)).toFloat() * - if (isEnabled) 1f else 0.5f - separator.color = - color( - listStyle - ?.optJSONObject("separator") - ?.optString("color", theme?.optString("separator", "#0000001F") ?: "#0000001F") - ?: theme?.optString("separator", "#0000001F") - ?: "#0000001F" - ) - separator.strokeWidth = 1f - invalidate() - } + val position = item.json.optString("groupPosition") + val radius = + if (container.has("cornerRadius")) + stylePx(container.optDouble("cornerRadius")).toFloat() + else if (defaultCornerRadius > 0) dp(defaultCornerRadius).toFloat() + else if (listStyle?.has("groupCornerRadius") == true) + stylePx(listStyle!!.optDouble("groupCornerRadius")).toFloat() + else dp(12).toFloat() + val top = + container.has("cornerRadius") || + defaultCornerRadius > 0 || + position in setOf("first", "single") + val bottom = + container.has("cornerRadius") || + defaultCornerRadius > 0 || + position in setOf("last", "single") + val radii = FloatArray(8) { if (it < 4 && top || it >= 4 && bottom) radius else 0f } + outlineProvider = ViewOutlineProvider.BACKGROUND + clipToOutline = container.has("cornerRadius") + background = + GradientDrawable().apply { + setColor(fill) + cornerRadii = radii + } + foreground = + if (container.has("borderWidth")) + GradientDrawable().apply { + setColor(Color.TRANSPARENT) + cornerRadii = radii + setStroke( + stylePx(container.optDouble("borderWidth")), + color(container.optString("borderColor", "#00000000")), + ) + } + else null + alpha = + container.optDouble("opacity", item.json.optDouble("opacity", 1.0)).toFloat() * + if (isEnabled) 1f else 0.5f + separator.color = + color( + listStyle + ?.optJSONObject("separator") + ?.optString("color", theme?.optString("separator", "#0000001F") ?: "#0000001F") + ?: theme?.optString("separator", "#0000001F") + ?: "#0000001F" + ) + separator.strokeWidth = 1f + invalidate() + } - override fun setReorderActive(active: Boolean) { - reorderActive = active - appearance() - } + override fun setReorderActive(active: Boolean) { + reorderActive = active + appearance() + } - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - // Android's native measurement consumes the exact resolved text/image views. - super.onMeasure( - widthMeasureSpec, - explicitHeight?.let { MeasureSpec.makeMeasureSpec(it, MeasureSpec.EXACTLY) } - ?: heightMeasureSpec, - ) - } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + // Android's native measurement consumes the exact resolved text/image views. + super.onMeasure( + widthMeasureSpec, + explicitHeight?.let { MeasureSpec.makeMeasureSpec(it, MeasureSpec.EXACTLY) } + ?: heightMeasureSpec, + ) + } - override fun dispatchDraw(canvas: Canvas) { - super.dispatchDraw(canvas) - current?.takeIf(::nativeListLegacyShowsSeparator)?.let { - val start = - listStyle - ?.optJSONObject("separator") - ?.takeIf { it.has("inset") } - ?.let { stylePx(it.optDouble("inset")) } ?: dp(12) - canvas.drawLine(start.toFloat(), height - 1f, width.toFloat(), height - 1f, separator) + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + current?.takeIf(::nativeListLegacyShowsSeparator)?.let { + val start = + listStyle + ?.optJSONObject("separator") + ?.takeIf { it.has("inset") } + ?.let { stylePx(it.optDouble("inset")) } ?: dp(12) + canvas.drawLine(start.toFloat(), height - 1f, width.toFloat(), height - 1f, separator) + } } - } - override fun onInitializeAccessibilityNodeInfo( - info: android.view.accessibility.AccessibilityNodeInfo - ) { - super.onInitializeAccessibilityNodeInfo(info) - info.viewIdResourceName = getTag(com.facebook.react.R.id.react_test_id) as? String - } + override fun onInitializeAccessibilityNodeInfo( + info: android.view.accessibility.AccessibilityNodeInfo + ) { + super.onInitializeAccessibilityNodeInfo(info) + info.viewIdResourceName = getTag(com.facebook.react.R.id.react_test_id) as? String + } - override fun recycle() { - if (current != null) onBindingInvalidated?.invoke(this, bindingEpoch) - bindingEpoch++ - current = null - tag = null - inputSignature = "" - touchPressed = false - reorderActive = false - recycleContent() - } + override fun recycle() { + if (current != null) onBindingInvalidated?.invoke(this, bindingEpoch) + bindingEpoch++ + current = null + tag = null + inputSignature = "" + touchPressed = false + reorderActive = false + recycleContent() + } - override fun dispose() { - recycle() - disposeContent() - } + override fun dispose() { + recycle() + disposeContent() + } } diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index 70216944d..a935c681b 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -448,10 +448,8 @@ internal class NativeListRowView( private val headerValueIcon = OneKeyIconView(context) private val leadingActionIcon = OneKeyIconView(context) private val secondaryImage = OneKeyImageReusableView(reactContext) - private val mediaNetworkImage = OneKeyImageReusableView(reactContext) private val metricVisualImages = List(5) { OneKeyImageReusableView(reactContext) } private val mainColumn = LinearLayout(context) - private val mediaMetadataRow = LinearLayout(context) private val titleLine = PackedTitleLineLayout(context) private val title = DottedUnderlineTextView(context) private val subtitle = NativeListTextView(context) @@ -476,7 +474,6 @@ internal class NativeListRowView( private val tableDataContainer = LinearLayout(context) private val tableDataColumns = List(4) { NativeListTableColumnView(context) } private val unreadDot = View(context) - private val mediaBadge = NativeListTextView(context) private val skeletonPrimary = View(context) private val skeletonSecondary = View(context) private val walletGroupRows = mutableListOf() @@ -491,7 +488,6 @@ internal class NativeListRowView( private var walletGroupDragChildCount = 0 private var walletGroupExpandedHeightPx = 0 private var walletGroupExpandAnimator: ValueAnimator? = null - private var isMediaTile = false private var boundKey: String? = null // OneKey patch: delayed retries cannot survive cell rebinding or recycling. private val selectorImageRetries = mutableMapOf() @@ -550,7 +546,6 @@ internal class NativeListRowView( leadingCornerIconFrame.addView(leadingCornerIcon) leadingFrame.addView(leadingCornerIconFrame) leadingFrame.addView(unreadDot) - leadingFrame.addView(mediaBadge) leadingFrame.clipChildren = false leadingFrame.clipToPadding = false leadingIcon.layoutParams = FrameLayout.LayoutParams(dp(18), dp(18), Gravity.CENTER) @@ -623,8 +618,6 @@ internal class NativeListRowView( tableDataContainer.gravity = Gravity.CENTER_VERTICAL tableDataColumns.forEach { tableDataContainer.addView(it) } - mediaBadge.gravity = Gravity.CENTER - mediaBadge.typeface = NativeListFonts.regular(context) secondaryImage.outlineProvider = object : ViewOutlineProvider() { override fun getOutline(view: View, outline: Outline) { outline.setRoundRect(0, 0, view.width, view.height, dp(6).toFloat()) @@ -664,11 +657,7 @@ internal class NativeListRowView( marketLongPressHandler.postDelayed(runnable, 800L) } } - if ((tag as? NativeListItem)?.type == "mediaTile") { - leadingFrame.alpha = 0.8f - } else { - background = pressedRowBackground - } + background = pressedRowBackground } MotionEvent.ACTION_MOVE -> { marketTouchX = event.x @@ -775,19 +764,7 @@ internal class NativeListRowView( if (sourceVerticalPadding != null && rowHeight != null) rowHeight - (rowHeight - sourceVerticalPadding).roundToInt() else paddingBottom, ) } - if (isMediaTile) { - val availableWidth = (MeasureSpec.getSize(widthMeasureSpec) - paddingLeft - paddingRight) - .coerceAtLeast(0) - val imageStyle = (tag as? NativeListItem)?.json?.optJSONObject("style")?.optJSONObject("image") - val params = leadingFrame.layoutParams - params.width = if (imageStyle?.has("width") == true) styleDp(imageStyle.optDouble("width")) else LayoutParams.MATCH_PARENT - params.height = when { - imageStyle?.has("height") == true -> styleDp(imageStyle.optDouble("height")) - imageStyle?.has("width") == true -> styleDp(imageStyle.optDouble("width")) - else -> availableWidth - } - leadingFrame.layoutParams = params - } + // OneKey patch: RecyclerView must use the adapter's measured selector height. val heightItem = tag as? NativeListItem val styledHeight = heightItem?.let(::styledHeightPixels)?.takeUnless { @@ -924,7 +901,6 @@ internal class NativeListRowView( selectorImages.forEach(OneKeyImageReusableView::prepareForReuse) leadingImages.forEach(OneKeyImageReusableView::prepareForReuse) secondaryImage.prepareForReuse() - mediaNetworkImage.prepareForReuse() metricVisualImages.forEach(OneKeyImageReusableView::prepareForReuse) } resetViews() @@ -960,7 +936,6 @@ internal class NativeListRowView( applySelectionState(item, theme, layout, itemIndex, selected) pressedRowBackground = groupedBackground( when (item.type) { - "mediaTile" -> "mediaTile" else -> "single" }, color( @@ -1007,7 +982,6 @@ internal class NativeListRowView( "activity" -> bindActivity(item, theme) "dataRow" -> bindDataRow(item, theme, checkboxState) "market" -> bindMarket(item, theme) - "mediaTile" -> bindMediaTile(item, theme) "metricCard" -> bindMetricCard(item, theme) "sectionHeader" -> bindSectionHeader(item, theme, checkboxState) "action" -> bindAction(item, theme, checkboxState) @@ -1230,7 +1204,6 @@ internal class NativeListRowView( "status" -> status "metricSubtitle" -> metricSubtitle "badge" -> badgeLine - "mediaBadge" -> mediaBadge "value" -> trailingViews[0] "valueSecondary" -> trailingViews[1] else -> null @@ -1341,7 +1314,6 @@ internal class NativeListRowView( selectorImages.forEach(OneKeyImageReusableView::prepareForReuse) leadingImages.forEach(OneKeyImageReusableView::prepareForReuse) secondaryImage.prepareForReuse() - mediaNetworkImage.prepareForReuse() metricVisualImages.forEach(OneKeyImageReusableView::prepareForReuse) walletGroupRows.forEach { row -> row.visibility = VISIBLE @@ -1437,7 +1409,6 @@ internal class NativeListRowView( selectorImages.clear() leadingImages.forEach(OneKeyImageReusableView::dispose) secondaryImage.dispose() - mediaNetworkImage.dispose() metricVisualImages.forEach(OneKeyImageReusableView::dispose) marketBadgeImages.forEach(OneKeyImageReusableView::dispose) walletGroupRows.forEach(NativeListRowView::dispose) @@ -1597,7 +1568,6 @@ internal class NativeListRowView( gravity = Gravity.CENTER_VERTICAL minimumHeight = 0 setPadding(dp(12), dp(8), dp(12), dp(8)) - isMediaTile = false walletGroupDragChildCount = 0 walletGroupExpandedHeightPx = 0 boundCheckboxData = null @@ -1606,9 +1576,6 @@ internal class NativeListRowView( // OneKey patch: remove the Market group before restoring shared labels. marketSubtitleLine.removeAllViews() mainColumn.removeView(marketSubtitleLine) - mediaMetadataRow.removeView(subtitle) - mediaMetadataRow.removeView(mediaNetworkImage) - mainColumn.removeView(mediaMetadataRow) mainColumn.removeView(titleLine) mainColumn.removeView(subtitle) mainColumn.removeView(tertiary) @@ -1698,7 +1665,6 @@ internal class NativeListRowView( leadingFrame.clipToPadding = false secondaryImage.visibility = GONE secondaryImage.foreground = null - mediaNetworkImage.visibility = GONE metricVisualImages.forEach { (it.parent as? ViewGroup)?.removeView(it) it.visibility = GONE @@ -1707,8 +1673,6 @@ internal class NativeListRowView( it.outlineProvider = ViewOutlineProvider.BACKGROUND } unreadDot.visibility = GONE - mediaBadge.visibility = GONE - mediaBadge.text = "" leadingIcon.visibility = GONE leadingIcon.iconName = "" leadingIcon.layoutParams = FrameLayout.LayoutParams(dp(18), dp(18), Gravity.CENTER) @@ -2685,101 +2649,6 @@ internal class NativeListRowView( else -> color(theme, "primaryText", "#000000DF") } - private fun bindMediaTile(item: NativeListItem, theme: JSONObject?) { - isMediaTile = true - orientation = VERTICAL - gravity = Gravity.START - setPadding(dp(10), dp(10), dp(10), dp(10)) - val imageState = item.json.optString("imageState", "loaded") - when (imageState) { - "empty" -> { - addLeading(null, 160) - leadingFrame.background = roundedFill(Color.WHITE, 10f) - } - "error" -> { - addLeading(null, 160) - leadingFallback.visibility = VISIBLE - leadingFallback.background = roundedFill( - color(theme, "strongBackground", "#0000000F"), - 10f, - ) - leadingIcon.visibility = VISIBLE - leadingIcon.iconName = "ImageSquareWavesOutline" - leadingIcon.tintColor = parseNativeListColor("#00000044") - leadingIcon.layoutParams = FrameLayout.LayoutParams(dp(24), dp(24), Gravity.CENTER) - } - else -> { - addLeading( - JSONObject().put("kind", "image").put("image", item.json.getJSONObject("image")), - 160, - ) - } - } - leadingFrame.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp(160)) - leadingFallback.layoutParams = FrameLayout.LayoutParams( - LayoutParams.MATCH_PARENT, - LayoutParams.MATCH_PARENT, - ) - leadingImages[0].layoutParams = FrameLayout.LayoutParams( - LayoutParams.MATCH_PARENT, - LayoutParams.MATCH_PARENT, - ) - leadingImages[0].outlineProvider = object : ViewOutlineProvider() { - override fun getOutline(view: View, outline: Outline) { - outline.setRoundRect(0, 0, view.width, view.height, dp(10).toFloat()) - } - } - leadingImages[0].clipToOutline = true - mainColumn.removeView(titleLine) - mainColumn.removeView(subtitle) - mediaMetadataRow.orientation = HORIZONTAL - mediaMetadataRow.gravity = Gravity.CENTER_VERTICAL - mediaMetadataRow.addView(subtitle, weighted().apply { marginEnd = dp(8) }) - item.json.optJSONObject("networkImage")?.let { networkImage -> - // OneKey patch: Preserve badge layout without exposing a loading/error tile. - // mediaNetworkImage.visibility = VISIBLE - mediaNetworkImage.outlineProvider = circleOutlineProvider - mediaNetworkImage.clipToOutline = true - mediaMetadataRow.addView(mediaNetworkImage, LayoutParams(dp(14), dp(14))) - bindImage(networkImage, mediaNetworkImage, item.key, 2, "network", hideUntilLoaded = true) - } - mainColumn.addView(mediaMetadataRow, 0) - mainColumn.addView(titleLine, 1) - showText(subtitle, item.json.optString("subtitle"), 1) - showText(title, item.json.optString("title"), 1) - item.json.optJSONObject("badge")?.optString("text")?.let { value -> - mediaBadge.text = value - mediaBadge.setTextColor(parseNativeListColor("#FCFCFC")) - mediaBadge.textSize = sp(14f) - mediaBadge.background = roundedStroke( - Color.WHITE, - parseNativeListColor("#000000DF"), - 10f, - ) - mediaBadge.setPadding(dp(8), 0, dp(8), 0) - mediaBadge.layoutParams = FrameLayout.LayoutParams( - LayoutParams.WRAP_CONTENT, - dp(24), - Gravity.END or Gravity.BOTTOM, - ) - mediaBadge.visibility = VISIBLE - } - addView(mainColumn, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { - topMargin = dp(8) - }) - item.json.optString("closeActionKey").takeIf { it.isNotEmpty() }?.let { actionKey -> - val close = trailingViews[0] - close.text = "×" - close.textSize = sp(22f) - close.gravity = Gravity.END - close.visibility = VISIBLE - close.setOnClickListener { - emitAction(item, actionKey, null, close, "mediaClose") - } - addView(trailingColumn, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) - } - } - private fun bindMetricCard(item: NativeListItem, theme: JSONObject?) { orientation = VERTICAL gravity = Gravity.START @@ -3425,7 +3294,7 @@ internal class NativeListRowView( val kind = visual.optString("kind") val shape = visual.optString( "shape", - if (kind == "image" || isMediaTile) "rounded" else "circle", + if (kind == "image") "rounded" else "circle", ) val sources = visualSources(visual).toMutableList() secondaryVisual?.let { sources.addAll(visualSources(it).take(1)) } @@ -3895,10 +3764,7 @@ internal class NativeListRowView( if (selected) "rowSelectedBackground" else "rowBackground", if (selected) "#0000000F" else "#FFFFFF", ) - if (item.type == "mediaTile") { - // The NFT tile changes only the image opacity while pressed. - rowBackground = color(theme, "rowBackground", "#FFFFFF") - } else if (item.type == "metricCard" && !selected) { + if (item.type == "metricCard" && !selected) { rowBackground = color(theme, "subduedBackground", "#F9F9F9") } val groupPosition = when { @@ -3910,7 +3776,7 @@ internal class NativeListRowView( else -> item.json.optString("groupPosition") } } - var backgroundGroupPosition = if (item.type == "mediaTile") "mediaTile" else groupPosition + var backgroundGroupPosition = groupPosition if (layout == "sectioned" && !item.json.optBoolean("selected", false)) { // Selection in sectioned lists is represented by the OneKey checkbox, // matching iOS and the app-monorepo network selector. @@ -4178,7 +4044,6 @@ internal class NativeListRowView( } } subtitle.textSize = sp(when (item.type) { - "mediaTile" -> 12f "metricCard" -> 11f else -> 14f }) @@ -4209,9 +4074,7 @@ internal class NativeListRowView( TextViewCompat.setLineHeight(title, dp(24)) } else if (item.type == "system") { TextViewCompat.setLineHeight(title, dp(20)) - } else if (item.type == "mediaTile") { - TextViewCompat.setLineHeight(title, dp(24)) - TextViewCompat.setLineHeight(subtitle, dp(16)) + } status.textSize = sp(12f) badgeLine.textSize = sp(12f) @@ -4255,7 +4118,6 @@ internal class NativeListRowView( isNetworkSelectorIdentity -> 47 else -> when (item.type) { "activity" -> if ((item.json.optJSONArray("footerActions")?.length() ?: 0) > 0) 104 else 60 - "mediaTile" -> 0 "metricCard" -> when (item.json.optString("variant")) { "activity" -> 0 "performance" -> 0 @@ -4340,7 +4202,6 @@ internal class NativeListRowView( val params = layoutParams ?: return if (listOrientation == "horizontal") { params.width = when (item.type) { - "mediaTile" -> dp(200) else -> dp(280) } params.height = ViewGroup.LayoutParams.WRAP_CONTENT @@ -4521,7 +4382,6 @@ internal class NativeListRowView( "first" -> floatArrayOf(radius, radius, radius, radius, 0f, 0f, 0f, 0f) "last" -> floatArrayOf(0f, 0f, 0f, 0f, radius, radius, radius, radius) "single" -> FloatArray(8) { radius } - "mediaTile" -> FloatArray(8) { scaledDp(16f) } else -> FloatArray(8) } } diff --git a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt index e9237e595..9f1665dba 100644 --- a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt +++ b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt @@ -59,7 +59,6 @@ class NativeListStyleSlotTest { Triple("identity", "", listOf("title", "subtitle", "tertiary", "badge", "value", "valueSecondary")), Triple("activity", "", listOf("title", "description", "status", "primaryAmount", "secondaryAmount")), Triple("dataRow", "", listOf("columns", "columnSecondary", "index")), - Triple("mediaTile", "", listOf("title", "subtitle", "badge")), Triple("metricCard", "", listOf("title", "value", "subtitle", "trend")), Triple("sectionHeader", "", listOf("title", "subtitle", "value")), Triple("action", "", listOf("title", "value")), diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index 8aa17706d..be7eb3ece 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -295,3 +295,24 @@ The [shared style matrix](STYLE_SPEC.md#41-shared-configurable-properties) defin the same configurable properties and semantics for Web, iOS and Android. Header/footer placement, sections, scrolling and indexed bar belong to the container. The integrating developer owns content fitting and row-height choices. + +### Stage 3: MediaTile acceptance (2026-09-23) + +Dedicated Swift/Kotlin hosts and a persistent Web body now own MediaTile. Old +MediaTile binders, native-only view allocations and semantic style-map branches +were removed. Shared hosts provide vertical container alignment and epoch-owned +action dispatch without allocating other templates. + +Validation used the external-drive runtime and its dedicated iOS 26 simulator +and Android API 36 emulator, plus headed Chrome. Native before/after captures +confirmed the grid geometry; styled height 260 restores to the iOS 233-point +grid default on clear. iOS retains its legacy title compression with a close +control at the default grid height. Style set/clear, empty/error images, close +action counter, end/top scroll, empty/repopulate and fixed footer were exercised +on all three platforms. Web also verifies same-key incompatible-family reuse, +retained image elements and source contentFit restoration in regression tests. +Evidence: external validation runtime `ios-media-*`, `android-media-*` and +`web-media-*`; build logs retained there. iOS and Android Debug builds and +Android unit tests pass; package typecheck and all 154 tests pass. Focused ESLint +has no errors (six pre-existing engine warnings). Remaining stage 3 templates: +Action, System, Activity, DataRow and MetricCard. diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index b61f03735..ba315065b 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -44,7 +44,7 @@ UI thread; [DESIGN.md](DESIGN.md) describes update and event ownership. `row.key` identifies data; renderer reuse identity describes compatible view structure. Content, styles, height and placement must not enter reuse identity. -The implemented families are `message`, `rail` and `legacy`; the latter serves all +The implemented families are `message`, `rail`, `mediaTile` and `legacy`; the latter serves all unmigrated templates. Same-key family changes must replace the incompatible host. OneKeyImage owns image caches; NativeList owns row/slot binding identity. @@ -67,8 +67,8 @@ migration adds no new input limits, retry policy or automatic layout correction. ## Performance and resources Snapshots and patches cross the bridge in batches. Visible rows use platform -recycling/windowing; shared primitives own image cancellation. Message and Rail use dedicated lightweight native hosts and persistent Web bodies. -Neither allocates the legacy native tree. This structural change is +recycling/windowing; shared primitives own image cancellation. Message, Rail and MediaTile use dedicated lightweight native hosts and persistent Web bodies. +None allocates the legacy native tree. This structural change is not a measured scrolling-performance result; performance claims still need a separate workload and measurements. @@ -78,12 +78,12 @@ separate workload and measurements. | --- | --- | --- | | 1. Baseline | Complete: [implicit keys, defaults, special updates and acceptance baseline](MIGRATION_BASELINE.md) inventoried | Preserve each existing rule or explicitly migrate its caller | | 2. Message pilot | Complete: registered lightweight native hosts, persistent Web body, resolved styling/measurement and lifecycle acceptance | Lightweight native hosts, resolved styling/measurement, update classification and lifecycle acceptance | -| 3. Simple templates | In progress (1/7): Rail migrated on all three platforms; six templates remain | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | +| 3. Simple templates | In progress (2/7): Rail and MediaTile migrated on all three platforms; five templates remain | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | | 4. Complex templates | Not started | Migrate Market, Identity and SectionHeader while preserving quote, selection and header behavior | | 5. WalletGroup | Not started | Independent member renderers; verify member events/styles, compact dragging and height restoration | | 6. Cleanup | Not started | Remove migrated key inference and global style maps; container no longer manipulates template internals | -The closed internal registry selects Message, Rail or the legacy family. Message owns +The closed internal registry selects Message, Rail, MediaTile or the legacy family. Message owns its text column, optional leading visual and thumbnail; its native host does not allocate Market, WalletGroup or table views. Web preserves the Message body, text nodes and unchanged image elements when rebinding. Remaining templates @@ -132,3 +132,11 @@ now responds to explicit fonts, image dimensions, padding and gaps while retaining the existing default width allowances and platform limits. Text-only rebinding preserves unchanged image requests; clearing styles restores defaults. Stage 3 still requires mediaTile, action, system, activity, dataRow and metricCard. + +MediaTile owns its picture, network indicator, title/subtitle, badge and close +control. Its legacy bind/style/reset allocation paths are removed. Images retain +unchanged requests across content/style updates; close events use the shared +host epoch with `mediaClose` source. Grid and horizontal allocation remain +container-owned. Default iOS grid height stays width + 48, including existing +compression when a close control is present; explicit container heights let the +caller allocate more room. No overflow fitting is added. diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index 1e78b4bad..f684898a3 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -436,7 +436,6 @@ private final class NativeListTableColumnView: UIStackView { final class NativeListCell: NativeListRowHost { private let rootStack = UIStackView() - private let mediaVisualWrapper = UIView() private var styledCircleViews: [UIView] = [] private let leadingImages = (0..<3).map { _ in OneKeyImageReusableView(frame: .zero) } private let leadingOverlayBackground = UIView() @@ -450,7 +449,6 @@ final class NativeListCell: NativeListRowHost { private let selectorFullWidthBackground = CALayer() private lazy var selectorTitleTap = UITapGestureRecognizer(target: self, action: #selector(selectorTitlePressed)) private let secondaryImage = OneKeyImageReusableView(frame: .zero) - private let mediaNetworkImage = OneKeyImageReusableView(frame: .zero) private let fallbackLabel = UILabel() private let leadingIconImageView = UIImageView() private let favoriteIconImageView = UIImageView() @@ -460,7 +458,6 @@ final class NativeListCell: NativeListRowHost { private let leadingContainer = UIView() private let unreadDot = UIView() private let mainStack = UIStackView() - private let mediaMetadataStack = UIStackView() private let titleRowStack = UIStackView() private let titleLabel = NativeListDottedUnderlineLabel() private let subtitleLabel = NativeListTextLabel() @@ -486,7 +483,6 @@ final class NativeListCell: NativeListRowHost { private let spinner = UIActivityIndicatorView(style: .medium) private let tableDataStack = UIStackView() private let tableDataColumns = (0..<4).map { _ in NativeListTableColumnView() } - private let mediaBadgeLabel = NativeListTextLabel() private let skeletonPrimary = UIView() private let skeletonSecondary = UIView() private var walletGroupCells: [NativeListCell] = [] @@ -505,7 +501,6 @@ final class NativeListCell: NativeListRowHost { private var leadingHeight: NSLayoutConstraint! private var leadingIconWidth: NSLayoutConstraint! private var leadingIconHeight: NSLayoutConstraint! - private var mediaHeight: NSLayoutConstraint! private var secondaryWidth: NSLayoutConstraint! private var secondaryHeight: NSLayoutConstraint! private var rootLeadingConstraint: NSLayoutConstraint! @@ -674,7 +669,6 @@ final class NativeListCell: NativeListRowHost { ]) leadingWidth = leadingContainer.widthAnchor.constraint(equalToConstant: 40) leadingHeight = leadingContainer.heightAnchor.constraint(equalToConstant: 40) - mediaHeight = leadingContainer.heightAnchor.constraint(equalToConstant: 120) leadingWidth.isActive = true leadingHeight.isActive = true fallbackLabel.textAlignment = .center @@ -685,14 +679,6 @@ final class NativeListCell: NativeListRowHost { secondaryImage.translatesAutoresizingMaskIntoConstraints = false secondaryWidth = secondaryImage.widthAnchor.constraint(equalToConstant: 56) secondaryHeight = secondaryImage.heightAnchor.constraint(equalToConstant: 56) - mediaNetworkImage.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - mediaNetworkImage.widthAnchor.constraint(equalToConstant: 14), - mediaNetworkImage.heightAnchor.constraint(equalToConstant: 14), - ]) - mediaNetworkImage.layer.cornerRadius = 7 - mediaNetworkImage.clipsToBounds = true - leadingContainer.addSubview(unreadDot) unreadDot.translatesAutoresizingMaskIntoConstraints = false unreadDot.layer.cornerRadius = 4 @@ -703,19 +689,6 @@ final class NativeListCell: NativeListRowHost { unreadDot.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor), ]) - leadingContainer.addSubview(mediaBadgeLabel) - mediaBadgeLabel.translatesAutoresizingMaskIntoConstraints = false - mediaBadgeLabel.font = nativeListFont(ofSize: 14, weight: .medium) - mediaBadgeLabel.textAlignment = .center - mediaBadgeLabel.layer.cornerRadius = 10 - mediaBadgeLabel.layer.borderWidth = 2 - mediaBadgeLabel.clipsToBounds = true - NSLayoutConstraint.activate([ - mediaBadgeLabel.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor), - mediaBadgeLabel.bottomAnchor.constraint(equalTo: leadingContainer.bottomAnchor), - mediaBadgeLabel.heightAnchor.constraint(equalToConstant: 24), - ]) - mainStack.axis = .vertical mainStack.alignment = .fill mainStack.spacing = 2 @@ -836,12 +809,7 @@ final class NativeListCell: NativeListRowHost { if currentItem?.data.bool("backgroundFullWidth") == true { selectorFullWidthBackground.frame = CGRect(x: -frame.minX, y: 0, width: superview?.bounds.width ?? bounds.width, height: bounds.height) } - if currentItem?.type == "mediaTile" { - let image = currentItem?.data.dictionary("style")?.dictionary("image") - if let image, image["height"] != nil { mediaHeight.constant = CGFloat(image.double("height")) } - else if let image, image["width"] != nil { mediaHeight.constant = CGFloat(image.double("width")) } - else { mediaHeight.constant = max(0, contentView.bounds.width - rootLeadingConstraint.constant + rootTrailingConstraint.constant) } - } + for view in styledCircleViews { (view.layer.mask as? CAShapeLayer)?.path = UIBezierPath(ovalIn: view.bounds).cgPath } @@ -889,7 +857,6 @@ final class NativeListCell: NativeListRowHost { if canTrimMembers { trimWalletGroupCells(keeping: 0) } leadingImages.forEach { $0.prepareForReuse() } secondaryImage.prepareForReuse() - mediaNetworkImage.prepareForReuse() } override func bind( @@ -910,7 +877,6 @@ final class NativeListCell: NativeListRowHost { currentItem = item leadingImages.forEach { $0.prepareForReuse() } secondaryImage.prepareForReuse() - mediaNetworkImage.prepareForReuse() reset() let primary = nativeListColor(theme, "primaryText", "#202020") @@ -938,9 +904,6 @@ final class NativeListCell: NativeListRowHost { badgeLabel.textColor = accent accessoryButtons.forEach { $0.setTitleColor(primary, for: .normal) } unreadDot.backgroundColor = UIColor(nativeListHex: "#E5484D", fallback: .systemRed) - mediaBadgeLabel.backgroundColor = nativeListColor(theme, "inverseBackground", "#202020") - mediaBadgeLabel.textColor = nativeListColor(theme, "inverseText", "#FCFCFC") - mediaBadgeLabel.layer.borderColor = nativeListColor(theme, "rowBackground", "#FFFFFF").cgColor let separatorStyle = listStyle?.dictionary("separator") let separatorColor = nativeListColor(theme, "separator", "#E0E0E0") separatorView.backgroundColor = (separatorStyle?["color"] as? String) @@ -960,8 +923,6 @@ final class NativeListCell: NativeListRowHost { pressedBackgroundColor = nativeListColor(theme, "rowPressedBackground", "#E8E8E8") if item.type == "rail" { pressedBackgroundColor = UIColor(nativeListHex: "#F0F0F0", fallback: .lightGray) - } else if item.type == "mediaTile" { - pressedBackgroundColor = restingBackgroundColor } updateBackgroundColor() if item.data.bool("backgroundFullWidth"), let background = (item.data.dictionary("style")?.dictionary("container")?["backgroundColor"] ?? item.data["backgroundColor"]) as? String { @@ -996,7 +957,6 @@ final class NativeListCell: NativeListRowHost { case "activity": bindActivity(item, theme: theme) case "dataRow": bindDataRow(item, theme: theme, checkboxState) case "market": bindMarket(item, theme: theme) - case "mediaTile": bindMediaTile(item, theme: theme) case "metricCard": bindMetricCard(item, theme: theme) case "sectionHeader": bindSectionHeader(item, theme: theme, layout: layout, checkboxState) case "action": bindAction(item, theme: theme, checkboxState) @@ -1169,10 +1129,7 @@ final class NativeListCell: NativeListRowHost { ) if item.type == "metricCard", !selected { color = nativeListColor(theme, "subduedBackground", "#F9F9F9") - } else if item.type == "mediaTile" { - // Selection is communicated by the destination state, without a - // persistent selected tile background. - color = nativeListColor(theme, "rowBackground", "#FFFFFF") + } else if layout == "sectioned", !item.data.bool("selected") { // Checkbox-backed section lists in app-monorepo keep rows on $bg; // selection is represented by the checkbox itself. @@ -1283,9 +1240,6 @@ final class NativeListCell: NativeListRowHost { marketSubtitleStack.removeFromSuperview() mainStack.removeArrangedSubview(tertiaryLabel) tertiaryLabel.removeFromSuperview() - mediaMetadataStack.removeArrangedSubview(subtitleLabel) - mediaMetadataStack.removeArrangedSubview(mediaNetworkImage) - mediaMetadataStack.removeFromSuperview() mainStack.removeArrangedSubview(titleRowStack) titleRowStack.removeFromSuperview() mainStack.removeArrangedSubview(subtitleLabel) @@ -1299,7 +1253,6 @@ final class NativeListCell: NativeListRowHost { leadingIconHeight.constant = 18 leadingWidth.isActive = true leadingHeight.isActive = true - mediaHeight.isActive = false secondaryWidth.isActive = false secondaryHeight.isActive = false NSLayoutConstraint.deactivate(leadingSlotConstraints) @@ -1330,15 +1283,12 @@ final class NativeListCell: NativeListRowHost { leadingActionButton.accessibilityIdentifier = nil leadingActionButton.accessibilityLabel = nil unreadDot.isHidden = true - mediaBadgeLabel.isHidden = true - mediaBadgeLabel.text = nil fallbackLabel.isHidden = false fallbackLabel.text = nil fallbackLabel.font = nativeListFont(ofSize: 13, weight: .bold) secondaryImage.isHidden = false secondaryImage.layer.borderWidth = 0 secondaryImage.layer.borderColor = nil - mediaNetworkImage.isHidden = true titleLabel.text = nil titleLabel.font = nativeListFont(ofSize: 16, weight: .medium) titleLabel.lineBreakMode = .byTruncatingTail @@ -1745,22 +1695,7 @@ final class NativeListCell: NativeListRowHost { contentView.clipsToBounds = true return } - if currentItem?.type == "mediaTile" { - // NFTListItem's group hover/press style belongs to the image wrapper, - // not to the complete card. - leadingContainer.alpha = pressed ? 0.8 : 1 - layer.maskedCorners = [ - .layerMinXMinYCorner, - .layerMaxXMinYCorner, - .layerMinXMaxYCorner, - .layerMaxXMaxYCorner, - ] - layer.cornerRadius = 16 - layer.masksToBounds = true - contentView.layer.cornerRadius = 16 - contentView.clipsToBounds = true - return - } + if pressed { let isWalletSidebar = currentItem?.type == "identity" && currentItem?.data.string("presentation") == "walletSidebar" @@ -2224,12 +2159,6 @@ final class NativeListCell: NativeListRowHost { case "index": return "value" default: return nil } - case "mediaTile": - switch field { - case "title", "subtitle": return field - case "badge": return "mediaBadge" - default: return nil - } // The large number and the small label sit in swapped views. case "metricCard": switch field { @@ -2280,7 +2209,7 @@ final class NativeListCell: NativeListRowHost { updateMarketQuote(item, theme: currentTheme) return } - let constraints = [rootLeadingConstraint!, rootTrailingConstraint!, rootTopConstraint!, rootBottomConstraint!, leadingWidth!, leadingHeight!, mediaHeight!] + let constraints = [rootLeadingConstraint!, rootTrailingConstraint!, rootTopConstraint!, rootBottomConstraint!, leadingWidth!, leadingHeight!] let constants = constraints.map { $0.constant } let active = constraints.map { $0.isActive } styledTextRestorations.append { @@ -2307,7 +2236,7 @@ final class NativeListCell: NativeListRowHost { if item.data.string("variant") == "performance", let summary = metricCompositeStack.arrangedSubviews.first as? UIStackView { styleGap(summary, gap) } } } - let leadingSlot = item.type == "mediaTile" ? mediaVisualWrapper : leadingContainer + let leadingSlot = leadingContainer if style["leadingGap"] != nil, let stack = leadingSlot.superview as? UIStackView { styleSpacing(stack, after: leadingSlot, CGFloat(style.double("leadingGap"))) } @@ -2326,8 +2255,8 @@ final class NativeListCell: NativeListRowHost { leadingWidth.isActive = true } if image["height"] != nil { - if mediaHeight.isActive { mediaHeight.constant = CGFloat(image.double("height")) } - else { leadingHeight.constant = CGFloat(image.double("height")); leadingHeight.isActive = true } + leadingHeight.constant = CGFloat(image.double("height")) + leadingHeight.isActive = true } let radius: CGFloat? = image["cornerRadius"] != nil ? CGFloat(image.double("cornerRadius")) : image.string("shape") == "circle" ? min(leadingWidth.constant, leadingHeight.constant) / 2 @@ -2376,7 +2305,6 @@ final class NativeListCell: NativeListRowHost { case "status": applyStyledText(item.type == "activity" && item.data.string("status") == "Failed" ? badgeLabel : statusLabel, slotStyle) case "metricSubtitle": applyStyledText(metricSubtitleLabel, slotStyle) case "badge": (semanticBadgeLabels.isEmpty ? [badgeLabel] : semanticBadgeLabels).forEach { applyStyledText($0, slotStyle) } - case "mediaBadge": applyStyledText(mediaBadgeLabel, slotStyle) case "dataPrimary", "dataSecondary": break // Applied to independent column labels above. case "value", "valueSecondary": let index = slot == "value" ? 0 : 1 @@ -2946,88 +2874,6 @@ final class NativeListCell: NativeListRowHost { accessibilityLabel = item.data.string("accessibilityLabel", default: [item.data.string("title"), item.data.string("subtitle"), item.data.string("price"), changeData.string("text")].filter { !$0.isEmpty }.joined(separator: ", ")) } - private func bindMediaTile(_ item: NativeListItem, theme: [String: Any]?) { - rootStack.axis = .vertical - rootStack.alignment = .fill - rootStack.spacing = 8 - rootLeadingConstraint.constant = 10 - rootTrailingConstraint.constant = -10 - rootTopConstraint.constant = 10 - rootBottomConstraint.constant = -10 - leadingWidth.isActive = false - leadingHeight.isActive = false - mediaHeight.isActive = true - let imageState = item.data.string("imageState") - let imageVisual = imageState == "empty" || imageState == "error" - ? nil - : item.data.dictionary("image").map { ["kind": "image", "image": $0] } - addLeading(imageVisual, key: item.key, to: rootStack) - rootStack.removeArrangedSubview(leadingContainer) - leadingContainer.removeFromSuperview() - mediaVisualWrapper.addSubview(leadingContainer) - rootStack.addArrangedSubview(mediaVisualWrapper) - let imageStyle = item.data.dictionary("style")?.dictionary("image") - let mediaConstraints = [ - leadingContainer.leadingAnchor.constraint(equalTo: mediaVisualWrapper.leadingAnchor), - leadingContainer.topAnchor.constraint(equalTo: mediaVisualWrapper.topAnchor), - leadingContainer.bottomAnchor.constraint(equalTo: mediaVisualWrapper.bottomAnchor), - ] + (imageStyle?["width"] == nil ? [leadingContainer.widthAnchor.constraint(equalTo: mediaVisualWrapper.widthAnchor)] : []) - selectorConstraints.append(contentsOf: mediaConstraints) - NSLayoutConstraint.activate(mediaConstraints) - leadingContainer.layer.cornerRadius = 10 - leadingContainer.clipsToBounds = true - if imageState == "empty" { - leadingContainer.backgroundColor = .clear - fallbackLabel.isHidden = true - } else if imageState == "error" { - leadingContainer.backgroundColor = nativeListColor(theme, "strongBackground", "#0000000F") - fallbackLabel.isHidden = true - leadingIconWidth.constant = 24 - leadingIconHeight.constant = 24 - leadingIconImageView.image = nativeListIcon(named: "ImageSquareWavesOutline") - leadingIconImageView.tintColor = UIColor(nativeListHex: "#00000044", fallback: .lightGray) - leadingIconImageView.isHidden = false - } - mainStack.removeArrangedSubview(titleRowStack) - titleRowStack.removeFromSuperview() - mainStack.removeArrangedSubview(subtitleLabel) - subtitleLabel.removeFromSuperview() - mediaMetadataStack.axis = .horizontal - mediaMetadataStack.alignment = .center - mediaMetadataStack.spacing = 8 - mediaMetadataStack.addArrangedSubview(subtitleLabel) - subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - if let networkImage = item.data.dictionary("networkImage") { - mediaMetadataStack.addArrangedSubview(mediaNetworkImage) - // OneKey patch: Preserve badge layout without exposing a loading/error tile. - // mediaNetworkImage.isHidden = false - bindImage( - networkImage, - into: mediaNetworkImage, - token: item.key, - slot: 2, - variant: "network", - hideUntilLoaded: true - ) - } - mainStack.insertArrangedSubview(mediaMetadataStack, at: 0) - mainStack.insertArrangedSubview(titleRowStack, at: 1) - show(subtitleLabel, item.data.string("subtitle"), lines: 1) - subtitleLabel.font = nativeListFont(ofSize: 12) - show(titleLabel, item.data.string("title"), lines: 1) - titleLabel.font = nativeListFont(ofSize: 16, weight: .medium) - if let badge = item.data.dictionary("badge") { - mediaBadgeLabel.text = " \(badge.string("text")) " - mediaBadgeLabel.isHidden = false - } - rootStack.addArrangedSubview(mainStack) - let closeAction = item.data.string("closeActionKey") - if !closeAction.isEmpty { - showAccessory(0, "×", action: (closeAction, nil)) - rootStack.addArrangedSubview(trailingStack) - } - } - private func bindMetricCard(_ item: NativeListItem, theme: [String: Any]?) { rootStack.axis = .vertical rootStack.alignment = .leading @@ -3778,7 +3624,7 @@ final class NativeListCell: NativeListRowHost { let isIcon = kind == "icon" let shape = visual.string( "shape", - default: kind == "image" || mediaHeight.isActive ? "rounded" : "circle" + default: kind == "image" ? "rounded" : "circle" ) let cornerIcon = visual.dictionary("cornerIcon") let fallbackText = String(visual.string("fallbackText").prefix(2)) @@ -4550,9 +4396,7 @@ final class NativeListCell: NativeListRowHost { guard let item = currentItem, accessoryActions.indices.contains(sender.tag) else { return } let action = accessoryActions[sender.tag] guard !action.0.isEmpty else { return } - let source = item.type == "mediaTile" && item.data.string("closeActionKey") == action.0 - ? "mediaClose" - : "trailingAccessory" + let source = "trailingAccessory" onAction?( item, action.0, diff --git a/native-views/react-native-native-list/ios/NativeListMediaTileRenderer.swift b/native-views/react-native-native-list/ios/NativeListMediaTileRenderer.swift new file mode 100644 index 000000000..e59e5dfa2 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListMediaTileRenderer.swift @@ -0,0 +1,164 @@ +import UIKit + +final class NativeListMediaTileCell: NativeListRendererCell { + private let picture = UIView() + private let image = NativeListImageSlot() + private let network = NativeListImageSlot() + private let errorIcon = UIImageView() + private let title = NativeListTextLabel() + private let subtitle = NativeListTextLabel() + private let badge = NativeListTextLabel() + private let metadata = UIStackView() + private let subtitleLine = UIStackView() + private let close = UIButton(type: .system) + private let closeStack = UIStackView() + private let titleLine = UIStackView() + private var closeKey = "" + private var imageStyle: [String: Any] = [:] + private var imageWidth: NSLayoutConstraint! + private var imageHeight: NSLayoutConstraint! + override var defaultCornerRadius: CGFloat { 16 } + override var showsSelection: Bool { false } + override var pressChangesBackground: Bool { false } + override var assetFields: [String] { ["image", "networkImage"] } + override var isHighlighted: Bool { didSet { picture.alpha = isHighlighted ? 0.8 : 1 } } + + override init(frame: CGRect) { + super.init(frame: frame) + root.axis = .vertical + root.alignment = .fill + metadata.axis = .vertical + metadata.alignment = .fill + subtitleLine.axis = .horizontal + subtitleLine.alignment = .center + subtitleLine.spacing = 8 + [subtitle, network.view].forEach(subtitleLine.addArrangedSubview) + titleLine.axis = .horizontal + titleLine.alignment = .center + titleLine.addArrangedSubview(title) + [subtitleLine, titleLine].forEach(metadata.addArrangedSubview) + closeStack.axis = .vertical + closeStack.alignment = .trailing + closeStack.addArrangedSubview(close) + [picture, metadata, closeStack].forEach(root.addArrangedSubview) + picture.translatesAutoresizingMaskIntoConstraints = false + [image.view, errorIcon, badge].forEach { + picture.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + } + imageWidth = image.view.widthAnchor.constraint(equalToConstant: 0) + imageHeight = picture.heightAnchor.constraint(equalToConstant: 160) + NSLayoutConstraint.activate([ + image.view.leadingAnchor.constraint(equalTo: picture.leadingAnchor), + image.view.topAnchor.constraint(equalTo: picture.topAnchor), + image.view.heightAnchor.constraint(equalTo: picture.heightAnchor), imageWidth, imageHeight, + errorIcon.centerXAnchor.constraint(equalTo: image.view.centerXAnchor), + errorIcon.centerYAnchor.constraint(equalTo: image.view.centerYAnchor), + errorIcon.widthAnchor.constraint(equalToConstant: 24), + errorIcon.heightAnchor.constraint(equalToConstant: 24), + badge.trailingAnchor.constraint(equalTo: image.view.trailingAnchor), + badge.bottomAnchor.constraint(equalTo: image.view.bottomAnchor), + badge.heightAnchor.constraint(equalToConstant: 24), + network.view.widthAnchor.constraint(equalToConstant: 14), + network.view.heightAnchor.constraint(equalToConstant: 14), + ]) + errorIcon.contentMode = .scaleAspectFit + badge.textAlignment = .center + badge.layer.cornerRadius = 10 + badge.layer.borderWidth = 2 + badge.clipsToBounds = true + network.view.layer.cornerRadius = 7 + network.view.clipsToBounds = true + subtitle.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + close.contentHorizontalAlignment = .trailing + close.addTarget(self, action: #selector(closePressed), for: .touchUpInside) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func bindContent( + _ item: NativeListItem, theme: [String: Any]?, layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + let style = item.data.dictionary("style") ?? [:] + imageStyle = style.dictionary("image") ?? [:] + let hp = CGFloat(style.double("horizontalPadding", default: 10)) + let vp = CGFloat(style.double("verticalPadding", default: 10)) + contentInsets = UIEdgeInsets(top: vp, left: hp, bottom: vp, right: hp) + root.spacing = 8 + root.setCustomSpacing(CGFloat(style.double("leadingGap", default: 8)), after: picture) + metadata.spacing = CGFloat(style.double("lineGap", default: 2)) + NativeListResolvedText( + item.data.string("title"), style: style.dictionary("title"), size: 16, + weight: .medium, color: nativeListColor(theme, "primaryText", "#202020"), + lineHeight: nativeListFont(ofSize: 16, weight: .medium).lineHeight, + lines: 1 + ).bind(title) + NativeListResolvedText( + item.data.string("subtitle"), style: style.dictionary("subtitle"), size: 12, + color: nativeListColor(theme, "secondaryText", "#646464"), + lineHeight: nativeListFont(ofSize: 12).lineHeight, lines: 1 + ).bind(subtitle) + let badgeText = item.data.dictionary("badge")?.string("text") ?? "" + NativeListResolvedText( + badgeText.isEmpty ? "" : " \(badgeText) ", style: style.dictionary("badge"), size: 14, + weight: .medium, color: nativeListColor(theme, "inverseText", "#FCFCFC"), + lineHeight: nativeListFont(ofSize: 14, weight: .medium).lineHeight, + lines: 1 + ).bind(badge) + if style.dictionary("badge")?["alignment"] == nil { badge.textAlignment = .center } + badge.backgroundColor = nativeListColor(theme, "inverseBackground", "#202020") + badge.layer.borderColor = nativeListColor(theme, "rowBackground", "#FFFFFF").cgColor + let state = item.data.string("imageState") + errorIcon.isHidden = state != "error" + errorIcon.image = nativeListIcon(named: "ImageSquareWavesOutline") + errorIcon.tintColor = UIColor(nativeListHex: "#00000044", fallback: .lightGray) + if state != "empty", state != "error", let source = item.data.dictionary("image") { + image.bind(source, key: "\(item.key):media", fit: imageStyle["contentFit"] as? String) + } else { + image.recycle() + image.view.backgroundColor = + state == "error" ? nativeListColor(theme, "strongBackground", "#0000000F") : .clear + } + if let source = item.data.dictionary("networkImage") { + network.view.isHidden = false + network.view.alpha = 0 + network.bind(source, key: "\(item.key):network", variant: "network") { [weak self] loaded in + self?.network.view.alpha = loaded ? 1 : 0 + } + } else { + network.recycle() + network.view.isHidden = true + } + closeKey = item.data.string("closeActionKey") + closeStack.isHidden = closeKey.isEmpty + close.setTitle("×", for: .normal) + close.titleLabel?.font = nativeListFont(ofSize: 16, weight: .medium) + close.setTitleColor(nativeListColor(theme, "primaryText", "#202020"), for: .normal) + } + override func layoutSubviews() { + let width = CGFloat( + imageStyle.double( + "width", + default: Double(max(0, contentView.bounds.width - contentInsets.left - contentInsets.right)) + )) + imageWidth.constant = width + imageHeight.constant = CGFloat(imageStyle.double("height", default: Double(width))) + let shape = imageStyle.string("shape", default: "rounded") + image.view.layer.cornerRadius = CGFloat( + imageStyle.double( + "cornerRadius", + default: shape == "circle" + ? Double(min(width, imageHeight.constant) / 2) : shape == "square" ? 0 : 10)) + image.view.clipsToBounds = true + super.layoutSubviews() + } + @objc private func closePressed() { + emitAction(closeKey, from: close, source: "mediaClose", slot: 0) + } + override func recycleContent() { + image.recycle() + network.recycle() + closeKey = "" + picture.alpha = 1 + } +} diff --git a/native-views/react-native-native-list/ios/NativeListModels.swift b/native-views/react-native-native-list/ios/NativeListModels.swift index 81f866009..015ab7550 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -4,7 +4,7 @@ import UIKit // Message is the first migrated renderer. Other templates still use the // compatible legacy tree; style, content and row keys do not affect reuse. enum NativeListRendererKey: String, CaseIterable { - case legacy, message, rail + case legacy, message, rail, mediaTile } struct NativeListItem { diff --git a/native-views/react-native-native-list/ios/NativeListRendererCell.swift b/native-views/react-native-native-list/ios/NativeListRendererCell.swift index ef4ac6ffa..97ddff00a 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererCell.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererCell.swift @@ -7,6 +7,7 @@ class NativeListRendererCell: NativeListRowHost { let root = UIStackView() var contentInsets = UIEdgeInsets.zero var defaultCornerRadius: CGFloat { 0 } + var pressChangesBackground: Bool { true } var showsSelection: Bool { true } var defaultSeparatorInset: CGFloat { 12 } func bindContent( @@ -30,6 +31,7 @@ class NativeListRendererCell: NativeListRowHost { } private let separator = UIView() private let fullWidthBackground = CALayer() + private var contentPosition: NSLayoutConstraint? private var rootConstraints: [NSLayoutConstraint] = [] private var item: NativeListItem? private var theme: [String: Any]? @@ -67,11 +69,31 @@ class NativeListRendererCell: NativeListRowHost { recycleContent() isHighlighted = false } + contentPosition?.isActive = false + contentPosition = nil + NSLayoutConstraint.activate(Array(rootConstraints.suffix(2))) bindContent(item, theme: theme, layout: layout, checkboxState: checkboxState) rootConstraints[0].constant = contentInsets.left rootConstraints[1].constant = -contentInsets.right rootConstraints[2].constant = contentInsets.top rootConstraints[3].constant = -contentInsets.bottom + if root.axis == .vertical, + let alignment = item.data.dictionary("style")?.dictionary("container")?[ + "contentVerticalAlignment"] as? String + ?? (item.styledHeight != nil ? "top" : nil) + { + NSLayoutConstraint.deactivate(Array(rootConstraints.suffix(2))) + contentPosition = + alignment == "bottom" + ? root.bottomAnchor.constraint( + equalTo: contentView.bottomAnchor, constant: -contentInsets.bottom) + : alignment == "center" + ? root.centerYAnchor.constraint( + equalTo: contentView.centerYAnchor, + constant: (contentInsets.top - contentInsets.bottom) / 2) + : root.topAnchor.constraint(equalTo: contentView.topAnchor, constant: contentInsets.top) + contentPosition?.isActive = true + } inputSignature = signature } self.item = item @@ -97,6 +119,18 @@ class NativeListRendererCell: NativeListRowHost { } override var isHighlighted: Bool { didSet { applyAppearance() } } + func emitAction( + _ key: String, from view: UIView, source: String, slot: Int? = nil, + target: NativeSelectionTarget? = nil + ) { + guard let item, !key.isEmpty, !item.data.bool("disabled") else { return } + onAction?( + item, key, target, + NativeListActionOrigin( + sourceView: view, ownerCell: self, + bindingEpoch: bindingEpoch, source: source, slot: slot)) + } + private func applyAppearance() { guard let item else { return } let container = item.data.dictionary("style")?.dictionary("container") ?? [:] @@ -109,7 +143,8 @@ class NativeListRendererCell: NativeListRowHost { background = UIColor(nativeListHex: color, fallback: background) } contentView.backgroundColor = - isHighlighted ? nativeListColor(theme, "rowPressedBackground", "#E8E8E8") : background + isHighlighted && pressChangesBackground + ? nativeListColor(theme, "rowPressedBackground", "#E8E8E8") : background contentView.alpha = CGFloat(container.double("opacity", default: item.data.double("opacity", default: 1))) * (item.data.bool("disabled") ? 0.5 : 1) diff --git a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift index b28f59108..ca3b53138 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift @@ -5,7 +5,7 @@ import UIKit enum NativeListRendererRegistry { private static let hosts: [NativeListRendererKey: NativeListRowHost.Type] = [ .legacy: NativeListCell.self, .message: NativeListMessageCell.self, - .rail: NativeListRailCell.self, + .rail: NativeListRailCell.self, .mediaTile: NativeListMediaTileCell.self, ] static func key(for type: String) -> NativeListRendererKey { NativeListRendererKey(rawValue: type) ?? .legacy @@ -22,6 +22,7 @@ enum NativeListRendererRegistry { -> CGFloat? { if item.rendererKey == .rail { return 40 } + if item.rendererKey == .mediaTile { return 244 } guard item.rendererKey == .message else { return nil } return NativeListMessageRenderer.Resolved(item, theme: theme, layout: layout).measure( width: width) diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index ae0bac752..e60b09579 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -1737,7 +1737,6 @@ final class NativeListView: UIView { let base: CGFloat switch item.type { case "activity": base = item.data.dictionaries("footerActions").isEmpty ? 60 : 100 - case "mediaTile": base = 244 case "metricCard": base = item.data.string("variant") == "activity" ? 160 + 1 / UIScreen.main.scale diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index 4ccbd6d09..09e76294c 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -1105,6 +1105,98 @@ describe('web row style', () => { } }); + it('retains MediaTile assets and restores styles, empty states and close actions across family reuse', () => { + const { document } = new JSDOM('').window; + const host = document.createElement('div'); + document.body.appendChild(host); + const media = { + type: 'mediaTile', + variant: 'gallery', + key: 'media-pilot', + title: 'Collectible', + subtitle: 'Collection', + image: { ...image, contentFit: 'contain' }, + networkImage: image, + badge: { key: 'amount', text: '×2' }, + closeActionKey: 'close', + } as const; + const engine = new NativeListWebEngine( + host, + snapshot({ kind: 'sectioned' }, [media]), + {}, + false + ); + const current = () => + host.querySelector( + '[data-native-list-row-key="media-pilot"]' + )!; + try { + const wrapper = current(); + const body = wrapper.firstElementChild as HTMLElement; + const picture = body.querySelector( + '.ok-native-list-media-image' + )!; + const title = body.querySelector('[data-nl-slot="title"]')!; + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [ + { + ...media, + title: 'Updated', + style: { + container: { height: 260 }, + image: { width: 120, height: 100, contentFit: 'cover' }, + title: { fontSize: 13, lines: 2 }, + lineGap: 6, + leadingGap: 14, + }, + }, + ]) + ); + expect(current().firstElementChild).toBe(body); + expect(body.querySelector('.ok-native-list-media-image')).toBe(picture); + expect(title.textContent).toBe('Updated'); + expect(picture.style.width).toBe('120px'); + expect(picture.style.objectFit).toBe('cover'); + expect(current().style.height).toBe('260px'); + engine.applySnapshot(snapshot({ kind: 'sectioned' }, [media])); + expect(title.style.fontSize).toBe(''); + expect(picture.style.width).toBe(''); + expect(picture.style.objectFit).toBe('contain'); + expect(current().style.height).toBe('244px'); + expect( + body + .querySelector('[data-native-list-action="close"]') + ?.getAttribute('data-native-list-anchor-source') + ).toBe('mediaClose'); + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [ + { ...media, imageState: 'error', closeActionKey: undefined }, + ]) + ); + expect(body.querySelector('[data-state="error"]')).not.toBeNull(); + expect(body.querySelector('[data-native-list-action]')).toBeNull(); + expect(picture.isConnected).toBe(false); + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [ + { + type: 'rail', + key: media.key, + title: 'Other', + visual: { kind: 'icon', name: 'star' }, + }, + ]) + ); + expect(current()).not.toBe(wrapper); + engine.applySnapshot(snapshot({ kind: 'sectioned' }, [media])); + expect(current()).toBe(wrapper); + expect(current().firstElementChild).toBe(body); + expect(title.textContent).toBe(media.title); + expect(body.querySelectorAll('img')).toHaveLength(2); + } finally { + engine.destroy(); + } + }); + it('recomputes horizontal Rail placement from styled metrics and restores default widths', () => { const rail = { type: 'rail', diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index 0a8359c03..b5c059646 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -2603,87 +2603,6 @@ function createSystemRow( return body; } -function createMediaRow( - context: RenderContext, - row: Extract -): HTMLElement { - const body = createElement( - context.document, - 'div', - 'ok-native-list-row ok-native-list-media' - ); - if (row.image && !row.imageState) { - const image = createImage(context, row.image, 'ok-native-list-media-image'); - if (image) body.appendChild(image); - } else { - const placeholder = createElement( - context.document, - 'span', - 'ok-native-list-media-image', - row.imageState === 'error' ? '▧' : '' - ); - setData(placeholder, 'state', row.imageState ?? 'empty'); - body.appendChild(placeholder); - } - const metadata = createElement( - context.document, - 'div', - 'ok-native-list-media-meta' - ); - const subtitleLine = createElement( - context.document, - 'div', - 'ok-native-list-media-subtitle-row' - ); - subtitleLine.appendChild( - tagSlot( - createElement( - context.document, - 'span', - 'ok-native-list-media-subtitle', - row.subtitle || '-' - ), - 'subtitle' - ) - ); - if (row.networkImage) { - const network = createImage( - context, - row.networkImage, - 'ok-native-list-media-network' - ); - if (network) subtitleLine.appendChild(network); - } - metadata.appendChild(subtitleLine); - metadata.appendChild( - tagSlot( - createElement( - context.document, - 'div', - 'ok-native-list-media-title', - row.title - ), - 'title' - ) - ); - if (row.badge) metadata.appendChild(createBadge(context, row.badge)); - body.appendChild(metadata); - if (row.closeActionKey) { - const close = createElement( - context.document, - 'button', - 'ok-native-list-media-close', - '×' - ); - close.setAttribute('type', 'button'); - close.setAttribute('aria-label', 'Close'); - setData(close, 'nativeListAction', row.closeActionKey); - markActionAnchorSource(close, 'mediaClose'); - body.appendChild(close); - } - return body; -} - function createMetricCell( context: RenderContext, metric: NonNullable< @@ -3848,17 +3767,14 @@ export function applyRowStyle(body: HTMLElement, row: RowModel): void { row.type === 'metricCard' && (row.variant === 'activity' || row.variant === 'performance'); const vertical = - row.type === 'mediaTile' || row.type === 'metricCard' || (row.type === 'identity' && row.presentation === 'walletSidebar'); const leading = body.querySelector( - ':scope > .ok-native-list-visual, :scope > .ok-native-list-stacked, :scope > .ok-native-list-media-image' + ':scope > .ok-native-list-visual, :scope > .ok-native-list-stacked' ); const defaultGap = body.style.gap || - (row.type === 'mediaTile' - ? '7px' - : row.type === 'metricCard' + (row.type === 'metricCard' ? '5px' : row.type === 'identity' && row.presentation === 'walletSidebar' ? '4px' @@ -3869,17 +3785,11 @@ export function applyRowStyle(body: HTMLElement, row: RowModel): void { const targets = row.type === 'dataRow' ? body.querySelectorAll('.ok-native-list-data-cell') - : row.type === 'mediaTile' - ? body.querySelectorAll('.ok-native-list-media-meta') : row.type === 'metricCard' || (row.type === 'system' && row.variant === 'warning') ? [body] : body.querySelectorAll(':scope > .ok-native-list-flex'); targets.forEach((column) => { - if (row.type === 'mediaTile') { - column.style.display = 'flex'; - column.style.flexDirection = 'column'; - } column.style.rowGap = String(box.lineGap) + 'px'; }); } @@ -3892,12 +3802,7 @@ export function applyRowStyle(body: HTMLElement, row: RowModel): void { ? String(box.lineGap) + 'px' : defaultGap) + ')'; - if (row.type === 'mediaTile') { - const metadata = body.querySelector( - '.ok-native-list-media-meta' - ); - if (metadata) metadata.style.paddingTop = `${box.leadingGap}px`; - } else if (vertical) leading.style.marginBottom = gap; + if (vertical) leading.style.marginBottom = gap; else leading.style.marginInlineEnd = gap; } if (box.titleBadgeGap !== undefined) { @@ -4028,8 +3933,6 @@ export function createRowBody( return createActionRow(context, row); case 'system': return createSystemRow(context, row); - case 'mediaTile': - return createMediaRow(context, row); case 'metricCard': return createMetricRow(context, row); case 'dataRow': @@ -4071,6 +3974,7 @@ export class NativeListWebEngine { legacy: [], message: [], rail: [], + mediaTile: [], }; private readonly rendererKeys = new WeakMap(); private frameHandle: number | undefined; diff --git a/native-views/react-native-native-list/src/web/templates/MediaTileRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/MediaTileRowRenderer.ts new file mode 100644 index 000000000..4f3cd2314 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/MediaTileRowRenderer.ts @@ -0,0 +1,182 @@ +import type { MediaTileRow, NativeListTextStyle } from '../../models'; +import type { RowPrimitives } from './RowVisual'; + +type Views = { + title: HTMLElement; + subtitle: HTMLElement; + badge: HTMLElement; + metadata: HTMLElement; + subtitleLine: HTMLElement; + close: HTMLButtonElement; + image?: HTMLElement; + network?: HTMLElement; + imageKey: string; + networkKey: string; + primitives: RowPrimitives; +}; +const views = new WeakMap(); +function bind(body: HTMLElement, row: MediaTileRow, primitives: RowPrimitives) { + const v = views.get(body)!; + v.primitives = primitives; + const style = row.style; + body.style.cssText = ''; + body.removeAttribute('data-nl-container-background'); + body.removeAttribute('data-nl-container-border'); + if (style?.horizontalPadding !== undefined) + body.style.paddingInline = `${style.horizontalPadding}px`; + if (style?.verticalPadding !== undefined) + body.style.paddingBlock = `${style.verticalPadding}px`; + const text = ( + node: HTMLElement, + value: string, + roleStyle?: NativeListTextStyle + ) => { + node.style.cssText = ''; + node.textContent = value; + if (roleStyle) primitives.textStyle(node, roleStyle); + }; + text(v.title, row.title, style?.title); + text(v.subtitle, row.subtitle || '-', style?.subtitle); + text(v.badge, row.badge?.text ?? '', style?.badge); + if (row.badge) { + v.metadata.appendChild(v.badge); + v.badge.dataset.tone = row.badge.tone ?? ''; + } else v.badge.remove(); + v.metadata.style.cssText = ''; + if (style?.leadingGap !== undefined) + v.metadata.style.paddingTop = `${style.leadingGap}px`; + if (style?.lineGap !== undefined) + Object.assign(v.metadata.style, { + display: 'flex', + flexDirection: 'column', + rowGap: `${style.lineGap}px`, + }); + const imageKey = JSON.stringify([row.key, row.image, row.imageState]); + if (v.imageKey !== imageKey) { + if (v.image) { + primitives.dispose(v.image); + v.image.remove(); + } + v.image = + row.image && !row.imageState + ? primitives.thumbnail(row.image) + : undefined; + if (!v.image) { + v.image = body.ownerDocument.createElement('span'); + v.image.dataset.state = row.imageState ?? 'empty'; + v.image.textContent = row.imageState === 'error' ? '▧' : ''; + } + v.image.className = 'ok-native-list-media-image'; + body.insertBefore(v.image, v.metadata); + v.imageKey = imageKey; + } + const image = v.image!; + // Only restore geometry; loading opacity is owned by the image primitive. + ['width', 'height', 'border-radius', 'overflow', 'object-fit'].forEach( + (key) => image.style.removeProperty(key) + ); + if (style?.image?.width !== undefined) + image.style.width = `${style.image.width}px`; + if (style?.image?.height !== undefined) + image.style.height = `${style.image.height}px`; + const shape = style?.image?.shape; + const radius = + style?.image?.cornerRadius !== undefined + ? `${style.image.cornerRadius}px` + : shape === 'circle' + ? '50%' + : shape === 'square' + ? '0px' + : shape === 'rounded' + ? '10px' + : undefined; + if (radius !== undefined) + image.style.setProperty('border-radius', radius, 'important'); + const fit = style?.image?.contentFit ?? row.image?.contentFit; + if (fit) image.style.objectFit = fit === 'center' ? 'none' : fit; + const networkKey = JSON.stringify([row.key, row.networkImage]); + if (v.networkKey !== networkKey) { + if (v.network) { + primitives.dispose(v.network); + v.network.remove(); + } + v.network = row.networkImage + ? primitives.thumbnail(row.networkImage) + : undefined; + if (v.network) { + v.network.className = 'ok-native-list-media-network'; + v.subtitleLine.appendChild(v.network); + } + v.networkKey = networkKey; + } + if (row.closeActionKey) { + v.close.dataset.nativeListAction = row.closeActionKey; + body.appendChild(v.close); + } else v.close.remove(); +} +function create( + document: Document, + row: MediaTileRow, + primitives: RowPrimitives +) { + const node = (tag: string, className: string, slot?: string) => { + const n = document.createElement(tag); + n.className = className; + if (slot) n.dataset.nlSlot = slot; + return n; + }; + const body = node('div', 'ok-native-list-row ok-native-list-media'); + body.dataset.nlRenderer = 'mediaTile'; + const metadata = node('div', 'ok-native-list-media-meta'); + const subtitleLine = node('div', 'ok-native-list-media-subtitle-row'); + const title = node('div', 'ok-native-list-media-title', 'title'); + const subtitle = node('span', 'ok-native-list-media-subtitle', 'subtitle'); + const badge = node('span', 'ok-native-list-badge', 'badge'); + const close = node( + 'button', + 'ok-native-list-media-close' + ) as HTMLButtonElement; + close.type = 'button'; + close.textContent = '×'; + close.setAttribute('aria-label', 'Close'); + close.dataset.nativeListAnchorSource = 'mediaClose'; + subtitleLine.appendChild(subtitle); + metadata.append(subtitleLine, title); + body.appendChild(metadata); + views.set(body, { + title, + subtitle, + badge, + metadata, + subtitleLine, + close, + primitives, + imageKey: '', + networkKey: '', + }); + bind(body, row, primitives); + return body; +} +function recycle(body: HTMLElement) { + const v = views.get(body); + if (!v) return; + [v.image, v.network].forEach((n) => { + if (n) { + v.primitives.dispose(n); + n.remove(); + } + }); + v.image = v.network = undefined; + v.imageKey = v.networkKey = ''; +} +export const mediaTileRowRenderer = { + key: 'mediaTile' as const, + create, + bind, + recycle, + measure: (_row: MediaTileRow, _width: number) => 244, + measureRendered: ( + _body: HTMLElement, + _row: MediaTileRow + ): number | undefined => undefined, +}; diff --git a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts index 31954a3fc..417c14377 100644 --- a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts +++ b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts @@ -1,4 +1,5 @@ -import type { MessageRow, RailRow, RowModel } from '../../models'; +import { mediaTileRowRenderer } from './MediaTileRowRenderer'; +import type { MessageRow, RailRow, MediaTileRow, RowModel } from '../../models'; import { messageRowRenderer } from './MessageRowRenderer'; import type { RowPrimitives } from './RowVisual'; import { railRowRenderer } from './RailRowRenderer'; @@ -30,8 +31,14 @@ const factories = { message: (row: RowModel) => registration(messageRowRenderer, row as MessageRow), rail: (row: RowModel) => registration(railRowRenderer, row as RailRow), + mediaTile: (row: RowModel) => + registration(mediaTileRowRenderer, row as MediaTileRow), +}; +const renderers = { + message: messageRowRenderer, + rail: railRowRenderer, + mediaTile: mediaTileRowRenderer, }; -const renderers = { message: messageRowRenderer, rail: railRowRenderer }; export type RowRendererKey = 'legacy' | keyof typeof factories; const isRegistered = (key: string): key is keyof typeof factories => Object.prototype.hasOwnProperty.call(factories, key); From a57ef844cd94f032c508249bc74b625a5878f5be Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 19:41:09 +0800 Subject: [PATCH 40/71] refactor(native-list): migrate Action renderer on three platforms --- .../nativelist/NativeListAccessoryStack.kt | 637 ++++++++++++++++++ .../nativelist/NativeListActionRenderer.kt | 147 ++++ .../nativelist/NativeListLeadingVisual.kt | 9 +- .../com/onekey/nativelist/NativeListModels.kt | 6 +- .../nativelist/NativeListRendererRegistry.kt | 2 + .../nativelist/NativeListRendererRowView.kt | 584 ++++++++-------- .../onekey/nativelist/NativeListRowView.kt | 60 +- .../com/onekey/nativelist/NativeListView.kt | 16 +- .../nativelist/NativeListStyleSlotTest.kt | 1 - .../react-native-native-list/docs/DESIGN.md | 22 + .../react-native-native-list/docs/SPEC.md | 15 +- .../ios/NativeListAccessoryStack.swift | 570 ++++++++++++++++ .../ios/NativeListActionRenderer.swift | 92 +++ .../ios/NativeListCell.swift | 55 +- .../ios/NativeListLeadingVisual.swift | 3 +- .../ios/NativeListModels.swift | 2 +- .../ios/NativeListRendererCell.swift | 10 +- .../ios/NativeListRendererRegistry.swift | 19 +- .../ios/RNCNativeListView.swift | 20 +- .../src/__tests__/NativeList.web.test.ts | 104 +++ .../src/web/NativeListWebEngine.ts | 48 +- .../src/web/templates/ActionRowRenderer.ts | 121 ++++ .../src/web/templates/RowRendererRegistry.ts | 4 + .../src/web/templates/RowVisual.ts | 6 + 24 files changed, 2080 insertions(+), 473 deletions(-) create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActionRenderer.kt create mode 100644 native-views/react-native-native-list/ios/NativeListAccessoryStack.swift create mode 100644 native-views/react-native-native-list/ios/NativeListActionRenderer.swift create mode 100644 native-views/react-native-native-list/src/web/templates/ActionRowRenderer.ts diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt new file mode 100644 index 000000000..a1c1d49a1 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt @@ -0,0 +1,637 @@ +package com.margelo.nitro.nativelist + +// OneKey patch: selector checkboxes share the source React Native border/background renderer. +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.TextUtils +import android.text.style.AbsoluteSizeSpan +import android.text.style.ForegroundColorSpan +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import androidx.core.widget.TextViewCompat +import com.facebook.react.uimanager.BackgroundStyleApplicator +import com.facebook.react.uimanager.LengthPercentage +import com.facebook.react.uimanager.LengthPercentageType +import com.facebook.react.uimanager.style.BorderRadiusProp +import com.facebook.react.uimanager.style.LogicalEdge +import kotlin.math.roundToInt +import org.json.JSONArray +import org.json.JSONObject + +// Bounded controls only. Event ownership and epochs stay with the row host. +internal class NativeListAccessoryStack(context: android.content.Context) : LinearLayout(context) { + private val trailingViews = List(2) { NativeListTextView(context) } + private val trailingIcons = List(2) { OneKeyIconView(context) } + private val checkbox = OneKeyCheckboxView(context) + private val spinner = ProgressBar(context) + private var boundCheckboxData: JSONObject? = null + private val semanticValueViews = mutableListOf() + private var selectorUsesSourceScale = false + private var checkboxUsesSelectorStyle = false + private var checkboxCheckedColor = Color.BLACK + private var checkboxUncheckedColor = Color.WHITE + private var checkboxIconColor = Color.WHITE + private var checkboxBorderColor = Color.LTGRAY + private var iconSubduedColor = Color.GRAY + var endInset: Int? = null + var onAction: ((String, View, Int?, NativeSelectionTarget?) -> Unit)? = null + private val selectorAccessibilityDelegate = + object : View.AccessibilityDelegate() { + override fun onInitializeAccessibilityNodeInfo( + host: View, + info: android.view.accessibility.AccessibilityNodeInfo, + ) { + super.onInitializeAccessibilityNodeInfo(host, info) + info.viewIdResourceName = host.getTag(com.facebook.react.R.id.react_test_id) as? String + } + } + + init { + orientation = VERTICAL + gravity = Gravity.END or Gravity.CENTER_VERTICAL + trailingViews.forEach { addView(it) } + trailingIcons.forEach { addView(it) } + addView(checkbox) + addView(spinner) + } + + fun bind( + item: NativeListItem, + descriptors: JSONArray, + theme: JSONObject?, + style: JSONObject, + sourceScale: Boolean, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + selectorUsesSourceScale = sourceScale + reset() + tag = item + checkboxCheckedColor = color(theme, "primaryText", "#1D1D1D") + checkboxUncheckedColor = color(theme, "inverseText", "#FCFCFC") + checkboxIconColor = checkboxUncheckedColor + checkboxBorderColor = Color.argb(0x31, 0, 0, 0) + iconSubduedColor = color(theme, "iconSubdued", "#00000072") + trailingViews.forEach { it.setTextColor(checkboxCheckedColor) } + bindAccessories(item, descriptors, theme, checkboxState) + style.optJSONObject("value")?.let { value -> + semanticValueViews.firstOrNull()?.let { applyStyledText(it, value) } + } + if (style.has("trailingGap")) { + var previous = false + for (i in 0 until childCount) { + val v = getChildAt(i) + if (v.visibility != GONE) { + (v.layoutParams as LayoutParams).topMargin = + if (previous) styleDp(style.optDouble("trailingGap")) else 0 + previous = true + } + } + } + visibility = + if ((0 until childCount).any { getChildAt(it).visibility != GONE }) VISIBLE else GONE + } + + fun updateSelection( + item: NativeListItem, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + tag = item + boundCheckboxData?.let { bindCheckbox(item, it, checkboxState) } + } + + fun anchorInset(view: View): Int = if ((tag as? NativeListItem)?.json?.optString("presentation") == "accountSelector" && view in trailingIcons && (view.layoutParams as MarginLayoutParams).marginStart < 0) dp(7) else 0 + fun reset() { + tag = null + endInset = null + semanticValueViews.clear() + boundCheckboxData = null + trailingViews.forEach { v -> + v.visibility = GONE + v.text = "" + v.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + v.setOnClickListener(null) + v.background = null + v.setPadding(0, 0, 0, 0) + v.gravity = Gravity.END or Gravity.CENTER_VERTICAL + v.opticalOffsetY = 0f + v.maxLines = 1 + v.setHorizontallyScrolling(false) + v.ellipsize = TextUtils.TruncateAt.END + v.setLineSpacing(0f, 1f) + v.letterSpacing = 0f + } + trailingIcons.forEach { + it.visibility = GONE + it.setOnClickListener(null) + it.background = null + it.useSourceScale = selectorUsesSourceScale + it.glyphSizeDp = null + it.alpha = 1f + } + checkbox.visibility = GONE + checkbox.setOnClickListener(null) + checkbox.alpha = 1f + checkbox.layoutParams = LayoutParams(dp(20), dp(20)) + spinner.visibility = GONE + spinner.alpha = 1f + spinner.layoutParams = LayoutParams(dp(20), dp(20)) + } + + private fun emitAction( + item: NativeListItem, + key: String, + target: NativeSelectionTarget?, + view: View, + source: String, + slot: Int? = null, + ) { + if (!item.json.optBoolean("disabled")) onAction?.invoke(key, view, slot, target) + } + + private fun dp(value: Int) = + if (selectorUsesSourceScale) (value * resources.displayMetrics.density).roundToInt() + else NativeListScale.dp(resources, value) + + private fun scaledDp(value: Float) = + if (selectorUsesSourceScale) value * resources.displayMetrics.density + else NativeListScale.dp(resources, value) + + private fun sp(value: Float) = + if (selectorUsesSourceScale) value else NativeListScale.font(resources, value) + + private fun styleDp(value: Double) = (value * resources.displayMetrics.density).roundToInt() + + private fun bindAccessories( + item: NativeListItem, + accessories: JSONArray?, + theme: JSONObject?, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + if (accessories == null) return + var textIndex = 0 + for (index in 0 until minOf(2, accessories.length())) { + val accessory = accessories.getJSONObject(index) + when (accessory.optString("kind")) { + "value" -> { + showTrailing( + textIndex, + accessory.optString("text"), + !accessory.optBoolean("secondary", false), + ) + semanticValueViews.add(trailingViews[textIndex]) + applyValueSegments(trailingViews[textIndex], accessory.optJSONArray("textSegments")) + textIndex++ + } + "valuePair" -> { + showTrailingValuePair(textIndex, accessory, theme) + semanticValueViews.add(trailingViews[textIndex++]) + } + "checkbox" -> bindCheckbox(item, accessory, checkboxState) + "radio" -> + showTrailing( + textIndex++, + if (accessory.optBoolean("checked")) "●" else "○", + true, + accessory.optString("actionKey").takeUnless { accessory.optBoolean("disabled", false) }, + ) + "switch" -> + showTrailing( + textIndex++, + if (accessory.optBoolean("value")) "ON" else "OFF", + true, + accessory.optString("actionKey").takeUnless { accessory.optBoolean("disabled", false) }, + ) + "chevron" -> + showTrailingIcon( + textIndex++, + item, + JSONObject(accessory.toString()).apply { + put("name", "ChevronRightSmallOutline") + if (optString("actionKey").isEmpty()) put("actionKey", "press") + }, + theme, + ) + "menu" -> showTrailingMenu(textIndex++, accessory.optString("actionKey")) + "drag" -> + showTrailingIcon( + textIndex++, + item, + JSONObject(accessory.toString()).apply { put("name", "DragOutline") }, + theme, + ) + "icon" -> showTrailingIcon(textIndex++, item, accessory, theme) + "spinner" -> spinner.visibility = VISIBLE + "progress" -> + showTrailing(textIndex++, "${(accessory.optDouble("value") * 100).toInt()}%", false) + } + } + } + + private fun bindCheckbox( + item: NativeListItem, + json: JSONObject, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + boundCheckboxData = json + val targetJson = json.optJSONObject("target") + val scope = targetJson?.optString("scope", "row") ?: "row" + val target = + NativeSelectionTarget( + scope, + when (scope) { + "section" -> targetJson?.optString("sectionKey") + "row" -> item.key + else -> null + }, + ) + val state = checkboxState(item, target, json.optString("state", "unchecked")) + val accessoryDisabled = json.optBoolean("disabled", false) + if (json.optBoolean("loading", false)) { + checkbox.visibility = GONE + checkbox.setOnClickListener(null) + spinner.visibility = VISIBLE + spinner.alpha = + if (!item.json.optBoolean("disabled", false) && accessoryDisabled) 0.5f else 1f + return + } + checkbox.visibility = VISIBLE + checkbox.setState(state, checkboxIconColor) + val usesSourceCheckboxGeometry = checkboxUsesSelectorStyle && item.json.has("height") + checkbox.usesSelectorGeometry = usesSourceCheckboxGeometry + if (usesSourceCheckboxGeometry) { + // OneKey patch: source Yoga children may round into padding; retain their complete border. + clipToPadding = false + // OneKey patch: even a transparent source border changes RN's background clipping path. + checkbox.background = null + BackgroundStyleApplicator.setBackgroundColor( + checkbox, + if (state == "unchecked") checkboxUncheckedColor else checkboxCheckedColor, + ) + BackgroundStyleApplicator.setBorderWidth(checkbox, LogicalEdge.ALL, 2f) + BackgroundStyleApplicator.setBorderColor( + checkbox, + LogicalEdge.ALL, + if (state == "unchecked") checkboxBorderColor else Color.TRANSPARENT, + ) + BackgroundStyleApplicator.setBorderRadius( + checkbox, + BorderRadiusProp.BORDER_RADIUS, + LengthPercentage(4f, LengthPercentageType.POINT), + ) + } else { + checkbox.background = + if (state == "unchecked") { + if (checkboxUsesSelectorStyle) + GradientDrawable().apply { + setColor(checkboxUncheckedColor) + setStroke(dp(2), checkboxBorderColor) + cornerRadius = scaledDp(4f) + } + else roundedStroke(checkboxBorderColor, checkboxUncheckedColor, 4f) + } else { + roundedFill(checkboxCheckedColor, 4f) + } + } + // Row-level disabled opacity already applies to this child. Only apply a + // local 0.5 when the accessory alone is disabled, never 0.5 * 0.5. + checkbox.alpha = if (!item.json.optBoolean("disabled", false) && accessoryDisabled) 0.5f else 1f + checkbox.isEnabled = + !item.json.optBoolean("disabled", false) && + !accessoryDisabled && + !json.optBoolean("loading", false) + checkbox.setOnClickListener { + emitAction( + item, + json.optString("actionKey", "selection"), + target, + checkbox, + "trailingAccessory", + ) + } + } + + private fun showTrailing( + index: Int, + value: String, + primary: Boolean, + actionKey: String? = null, + textColor: Int? = null, + ) { + if (index !in trailingViews.indices || value.isEmpty()) return + val view = trailingViews[index] + view.maxLines = 1 + view.text = value + view.textSize = sp(if (primary) 16f else 14f) + view.typeface = + if (primary) NativeListFonts.medium(context) else NativeListFonts.regular(context) + view.includeFontPadding = false + view.fontFeatureSettings = "tnum" + TextViewCompat.setLineHeight(view, dp(if (primary) 24 else 20)) + textColor?.let(view::setTextColor) + view.visibility = VISIBLE + if (!actionKey.isNullOrEmpty()) { + view.setOnClickListener { + (tag as? NativeListItem) + ?.takeUnless { item -> item.json.optBoolean("disabled", false) } + ?.let { item -> emitAction(item, actionKey, null, view, "trailingAccessory", index) } + } + } + } + + private fun showTrailingValuePair(index: Int, accessory: JSONObject, theme: JSONObject?) { + if (index !in trailingViews.indices) return + val primary = accessory.optString("primary") + val secondary = accessory.optString("secondary") + if (primary.isEmpty() && secondary.isEmpty()) return + val value = SpannableStringBuilder() + val primaryStart = value.length + value.append(primary) + value.setSpan( + ForegroundColorSpan(accessoryTextColor(accessory.optString("primaryTone"), "primary", theme)), + primaryStart, + value.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + value.setSpan( + AbsoluteSizeSpan( + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp(16f), resources.displayMetrics) + .roundToInt() + ), + primaryStart, + value.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + if (primary.isNotEmpty() && secondary.isNotEmpty()) value.append('\n') + val secondaryStart = value.length + value.append(secondary) + value.setSpan( + ForegroundColorSpan( + accessoryTextColor(accessory.optString("secondaryTone"), "secondary", theme) + ), + secondaryStart, + value.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + value.setSpan( + AbsoluteSizeSpan( + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp(14f), resources.displayMetrics) + .roundToInt() + ), + secondaryStart, + value.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + val view = trailingViews[index] + view.text = value + view.maxLines = 2 + view.gravity = Gravity.END + view.includeFontPadding = false + view.typeface = NativeListFonts.regular(context) + view.fontFeatureSettings = "tnum" + TextViewCompat.setLineHeight(view, dp(20)) + view.visibility = VISIBLE + } + + private fun showTrailingMenu(index: Int, actionKey: String) { + showTrailing(index, "⋮", true, actionKey) + if (index !in trailingViews.indices) return + trailingViews[index].apply { + gravity = Gravity.CENTER + textSize = sp(20f) + layoutParams = + LayoutParams(dp(24), dp(24)).apply { + if (this@NativeListAccessoryStack.orientation == HORIZONTAL && index > 0) + marginStart = dp(8) + } + } + } + + private fun accessoryTextColor(tone: String, defaultTone: String, theme: JSONObject?): Int = + when (tone.ifEmpty { defaultTone }) { + "positive" -> color(theme, "positive", "#00713FDE") + "negative" -> color(theme, "negative", "#C40006D3") + "secondary" -> color(theme, "secondaryText", "#0000009B") + else -> color(theme, "primaryText", "#000000DF") + } + + private fun showTrailingIcon( + index: Int, + item: NativeListItem, + data: JSONObject, + theme: JSONObject?, + ) { + if (index !in trailingIcons.indices) return + val icon = trailingIcons[index] + icon.iconName = data.optString("name") + icon.tintColor = safeColor(data.optString("tintColor"), iconSubduedColor) + // OneKey patch: preserve the original 38dp press target around its 24dp layout slot. + icon.setTag( + com.facebook.react.R.id.react_test_id, + data.optString("testID").takeIf { it.isNotEmpty() }, + ) + icon.accessibilityDelegate = selectorAccessibilityDelegate + icon.contentDescription = data.optString("accessibilityLabel").takeIf { it.isNotEmpty() } + if (item.json.optString("presentation") == "accountSelector") { + icon.glyphSizeDp = 24 + val isSourceMenu = item.json.has("height") && icon.iconName == "DotHorOutline" + val size = if (item.json.has("height") && icon.iconName == "PlusSmallOutline") 36 else 38 + icon.layoutParams = + LayoutParams(dp(size), dp(size)).apply { + gravity = Gravity.CENTER_VERTICAL + marginStart = -dp(7) + marginEnd = -dp(7) + } + // The frame overhangs its 24dp layout slot by 7dp on each side. The column + // clips children and the row clips to padding by default, which would cut + // the pressed circle down to a 24dp-wide pill. + this@NativeListAccessoryStack.clipChildren = false + clipToPadding = false + if (isSourceMenu && data.optString("actionKey").isNotEmpty()) { + icon.background = + StateListDrawable().apply { + addState( + intArrayOf(android.R.attr.state_pressed), + roundedFill(color(theme, "rowPressedBackground", "#00000017"), 19f), + ) + addState(intArrayOf(), roundedFill(Color.TRANSPARENT, 19f)) + } + } + } else if (icon.iconName == "ChevronRightSmallOutline") { + icon.glyphSizeDp = null + // ListItem.DrillIn is a 24dp icon with mx=-6, for a 12dp layout footprint. + icon.layoutParams = + LayoutParams(dp(24), dp(24)).apply { + marginStart = -dp(6) + marginEnd = -dp(6) + } + } else { + // ListItem.IconButton medium keeps a 24dp glyph in a 36dp frame. Its + // m=-7 moves the last frame 7dp through the row's trailing padding. For + // the Bookmark XStack, gap=$6 plus both negative margins leaves 10dp + // between the physical frames (46dp between glyph centers). + icon.glyphSizeDp = 24 + endInset = dp(5) + icon.layoutParams = + LayoutParams(dp(36), dp(36)).apply { + gravity = Gravity.END + if (this@NativeListAccessoryStack.orientation == HORIZONTAL && index > 0) { + marginStart = dp(10) + } + } + } + icon.visibility = VISIBLE + icon.isEnabled = !data.optBoolean("disabled", false) + icon.alpha = if (icon.isEnabled) 1f else 0.4f + val actionKey = data.optString("actionKey") + if (icon.isEnabled && actionKey.isNotEmpty()) { + icon.setOnClickListener { + emitAction(item, actionKey, null, icon, "trailingAccessory", index) + } + } + } + + private fun applyValueSegments( + view: TextView, + segments: JSONArray?, + fontSize: Int = 16, + lineHeight: Int = 24, + medium: Boolean = true, + ) { + if (segments == null || segments.length() == 0) return + val value = SpannableStringBuilder() + for (index in 0 until segments.length()) { + val segment = segments.getJSONObject(index) + val start = value.length + value.append(segment.optString("text")) + if (segment.optString("style") == "subscript") { + val size = kotlin.math.ceil(fontSize * 0.6).toFloat() + val span = + if (selectorUsesSourceScale) + AbsoluteSizeSpan( + kotlin.math.ceil((size * resources.displayMetrics.density).toDouble()).toInt(), + false, + ) + else AbsoluteSizeSpan(sp(size).roundToInt(), true) + value.setSpan(span, start, value.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + view.textSize = sp(fontSize.toFloat()) + view.typeface = + if (medium) NativeListFonts.medium(context) else NativeListFonts.regular(context) + TextViewCompat.setLineHeight(view, dp(lineHeight)) + view.text = value + } + + private fun applyStyledText(view: TextView, style: JSONObject) { + if (style.has("fontSize")) { + view.setTextSize( + TypedValue.COMPLEX_UNIT_PX, + (style.optDouble("fontSize") * resources.displayMetrics.density).toFloat(), + ) + val text = SpannableStringBuilder(view.text) + text.getSpans(0, text.length, AbsoluteSizeSpan::class.java).forEach(text::removeSpan) + view.text = text + } + style.optString("fontWeight").takeIf(String::isNotEmpty)?.let { + view.typeface = marketTypeface(it, "regular") + } + style.optString("color").takeIf(String::isNotEmpty)?.let { + val color = safeColor(it, view.currentTextColor) + view.setTextColor(color) + val text = SpannableStringBuilder(view.text) + text.getSpans(0, text.length, ForegroundColorSpan::class.java).forEach(text::removeSpan) + view.text = text + } + if (style.has("lineHeight")) { + val text = SpannableStringBuilder(view.text) + text.getSpans(0, text.length, NativeListLineHeightSpan::class.java).forEach(text::removeSpan) + text.setSpan( + NativeListLineHeightSpan(styleDp(style.optDouble("lineHeight"))), + 0, + text.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + view.setLineSpacing(0f, 1f) + view.text = text + } + if (style.has("lines")) { + view.maxLines = style.optInt("lines").coerceIn(1, 3) + view.ellipsize = TextUtils.TruncateAt.END + } + if (style.has("truncate") || style.has("lines")) { + view.setHorizontallyScrolling(view.maxLines == 1) + view.ellipsize = + if (style.optString("truncate", "tail") == "clip") null else TextUtils.TruncateAt.END + if (view.maxLines == 1) { + val text = SpannableStringBuilder(view.text) + Regex("\\r\\n|[\\r\\n]").findAll(text).toList().asReversed().forEach { match -> + text.replace(match.range.first, match.range.last + 1, " ") + } + view.text = text + } + } + if (style.has("verticalAlignment")) { + view.gravity = + (view.gravity and Gravity.VERTICAL_GRAVITY_MASK.inv()) or + when (style.optString("verticalAlignment")) { + "top" -> Gravity.TOP + "bottom" -> Gravity.BOTTOM + else -> Gravity.CENTER_VERTICAL + } + } + if (style.has("offsetY")) + (view as? NativeListTextView)?.opticalOffsetY = + (style.optDouble("offsetY") * resources.displayMetrics.density).toFloat() + style.optString("alignment").takeIf(String::isNotEmpty)?.let { + view.gravity = + (view.gravity and Gravity.VERTICAL_GRAVITY_MASK) or + when (it) { + "center" -> Gravity.CENTER_HORIZONTAL + "end" -> Gravity.END + else -> Gravity.START + } + } + } + + // OneKey patch: keep a small idle member pool after reuse or a direct rebind. + // The compact proxy/expansion keeps every member until it leaves that state. + + private fun marketTypeface(weight: String, fallback: String): Typeface = + when (weight.ifEmpty { fallback }) { + "regular" -> NativeListFonts.regular(context) + "semibold" -> NativeListFonts.semibold(context) + "bold" -> NativeListFonts.bold(context) + else -> NativeListFonts.medium(context) + } + + private fun safeColor(value: String?, fallback: Int): Int = + try { + if (value.isNullOrEmpty()) fallback else parseNativeListColor(value) + } catch (_: IllegalArgumentException) { + fallback + } + + private fun color(theme: JSONObject?, key: String, fallback: String): Int = + safeColor(theme?.optString(key, fallback), parseNativeListColor(fallback)) + + private fun roundedFill(color: Int, radiusDp: Float) = + GradientDrawable().apply { + setColor(color) + cornerRadius = scaledDp(radiusDp) + } + + private fun roundedStroke(stroke: Int, fill: Int, radiusDp: Float) = + GradientDrawable().apply { + setColor(fill) + setStroke(dp(2), stroke) + cornerRadius = scaledDp(radiusDp) + } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActionRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActionRenderer.kt new file mode 100644 index 000000000..29cecc56e --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActionRenderer.kt @@ -0,0 +1,147 @@ +package com.margelo.nitro.nativelist + +import android.view.Gravity +import com.facebook.react.uimanager.ThemedReactContext +import org.json.JSONArray +import org.json.JSONObject + +internal class NativeListActionRowView(context: ThemedReactContext) : + NativeListRendererRowView(context) { + private val visual = NativeListLeadingVisual(context) + private val title = NativeListTextView(context) + private val accessories = NativeListAccessoryStack(context) + override val assetFields = listOf("icon") + + init { + visual.glyphSize = 24 + visual.iconBorder = false + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(visual) + addView(title, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + addView(accessories) + accessories.onAction = { key, view, slot, target -> + emitAction(key, view, "trailingAccessory", slot, target, accessories.anchorInset(view)) + } + } + + override fun defaultHeight(item: NativeListItem, layout: String) = + when { + item.json.optString("presentation") == "accountSelector" -> 48 + item.json.has("icon") -> 60 + else -> 44 + } + + override fun bindContent( + item: NativeListItem, + theme: JSONObject?, + layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + val style = item.json.optJSONObject("style") ?: JSONObject() + val icon = item.json.optJSONObject("icon") + val alignment = style.optJSONObject("container")?.optString("contentVerticalAlignment") + gravity = + when (alignment) { + "top" -> Gravity.TOP + "bottom" -> Gravity.BOTTOM + else -> Gravity.CENTER_VERTICAL + } + val selector = item.json.optString("presentation") == "accountSelector" + visual.roundedGlyphOrigin = selector && item.json.has("height") + val descriptors = + item.json.optJSONObject("checkbox")?.let { JSONArray().put(it) } + ?: item.json.optJSONArray("trailing") + ?: JSONArray() + accessories.bind(item, descriptors, theme, style, sourceScale, checkboxState) + val hp = + if (style.has("horizontalPadding")) stylePx(style.optDouble("horizontalPadding")) + else dp(if (layout == "table") 16 else 12) + val vp = + if (style.has("verticalPadding")) stylePx(style.optDouble("verticalPadding")) else dp(8) + setPadding(hp, vp, if (style.has("horizontalPadding")) hp else accessories.endInset ?: hp, vp) + val titleParams = title.layoutParams as LayoutParams + titleParams.marginEnd = if (accessories.visibility == GONE) 0 else dp(12) + title.layoutParams = titleParams + val tone = item.json.optString("tone") + val token = + if (selector && tone != "primary") "secondaryText" + else if (!selector && tone == "danger") "negative" else "primaryText" + val fallback = + if (token == "negative") "#C40006D3" + else if (token == "secondaryText") "#0000009B" else "#1D1D1D" + NativeListResolvedText.resolve( + context, + item.json.optString("title"), + style.optJSONObject("title"), + 16f, + if (selector && icon == null) "regular" else "medium", + parseNativeListColor(theme?.optString(token, fallback) ?: fallback), + 24, + 1, + sourceScale, + ) + .bind(title) + title.ellipsize = android.text.TextUtils.TruncateAt.END + visual.visibility = if (icon == null) GONE else VISIBLE + if (icon != null) { + val source = JSONObject(icon.toString()) + if (!source.has("backgroundColor")) + source.put( + "backgroundColor", + if (selector) theme?.optString("strongBackground", "#0000000F") ?: "#0000000F" + else "#00000000", + ) + val image = JSONObject(style.optJSONObject("image")?.toString() ?: "{}") + if (selector && !image.has("cornerRadius") && !image.has("shape")) + image.put("cornerRadius", 8) + visual.layoutParams = + LayoutParams( + if (image.has("width")) stylePx(image.optDouble("width")) + else dp(if (selector) 32 else 40), + if (image.has("height")) stylePx(image.optDouble("height")) + else dp(if (selector) 32 else 40), + ) + .apply { + marginEnd = + if (style.has("leadingGap")) stylePx(style.optDouble("leadingGap")) else dp(12) + } + visual.bind(source, image, item.key, theme, false, sourceScale) + } else visual.recycle() + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + super.onLayout(changed, l, t, r, b) + val item = tag as? NativeListItem ?: return + if ( + item.json.has("height") && + item.json.optString("presentation") == "accountSelector" && + item.json + .optJSONObject("style") + ?.optJSONObject("container") + ?.has("contentVerticalAlignment") != true + ) { + for (view in listOf(title, accessories)) if (view.visibility != GONE) + view.offsetTopAndBottom( + paddingTop + (height - paddingTop - paddingBottom - view.height + 1) / 2 - view.top + ) + } + } + + override fun bindSelectionContent( + item: NativeListItem, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + accessories.updateSelection(item, checkboxState) + } + + override fun recycleContent() { + visual.recycle() + accessories.reset() + } + + override fun disposeContent() { + visual.dispose() + accessories.reset() + } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt index 1a96e60a4..705438960 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt @@ -31,6 +31,9 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont private var visual = JSONObject() private var style = JSONObject() private var sources = emptyList>() + var glyphSize = 18 + var roundedGlyphOrigin = false + var iconBorder = true private var sourceScale = false private fun dp(value: Int) = @@ -132,7 +135,7 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont val backgroundColor = if (sourceFallback) color(placeholder) else visualBackground background = fill(backgroundColor, 0f).apply { - if (kind == "icon") setStroke(1, color("#0000001F")) + if (kind == "icon" && iconBorder) setStroke(1, color("#0000001F")) if (visual.optString("borderStyle") == "dashed") setStroke( dp(2), @@ -299,8 +302,8 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont if (visual.optJSONObject("fallbackIcon")?.optString("name") == "GlobusOutline") 1.2f else 1f) .roundToInt() - else dp(18) - place(icon, (w - iconSize) / 2, (h - iconSize) / 2, iconSize, iconSize) + else dp(glyphSize) + place(icon, (w - iconSize + if(roundedGlyphOrigin) 1 else 0) / 2, (h - iconSize + if(roundedGlyphOrigin) 1 else 0) / 2, iconSize, iconSize) val tokenPair = kind == "token" && sources.size > 1 slots.take(sources.size).forEachIndexed { index, slot -> val size = diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index 4a406a7b2..c812d3e60 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -66,10 +66,6 @@ internal fun nativeListStyleSlot(type: String, variant: String, field: String): "title", "subtitle", "value" -> field else -> null } - "action" -> when (field) { - "title", "value" -> field - else -> null - } "system" -> when (field) { "title" -> if (variant == "warning") "title" else null // Only the warning variant renders a separate title; every other variant @@ -106,7 +102,7 @@ private fun updateLengthPrefixed(digest: MessageDigest, value: String) { // Message and Rail own dedicated renderers. Remaining templates still share // a compatible legacy tree; keys and style values never partition the pool. -internal enum class NativeListRendererKey { LEGACY, MESSAGE, RAIL, MEDIA_TILE } +internal enum class NativeListRendererKey { LEGACY, MESSAGE, RAIL, MEDIA_TILE, ACTION } internal data class NativeListItem( val key: String, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt index 1f7f97c6a..0a276a496 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt @@ -9,6 +9,7 @@ internal object NativeListRendererRegistry { NativeListRendererKey.MESSAGE to ::NativeListMessageRowView, NativeListRendererKey.RAIL to ::NativeListRailRowView, NativeListRendererKey.MEDIA_TILE to ::NativeListMediaTileRowView, + NativeListRendererKey.ACTION to ::NativeListActionRowView, ) private val keys = @@ -16,6 +17,7 @@ internal object NativeListRendererRegistry { "message" to NativeListRendererKey.MESSAGE, "rail" to NativeListRendererKey.RAIL, "mediaTile" to NativeListRendererKey.MEDIA_TILE, + "action" to NativeListRendererKey.ACTION, ) fun key(type: String) = keys[type] ?: NativeListRendererKey.LEGACY diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt index cea3334ce..04dc5723a 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt @@ -12,327 +12,327 @@ import kotlin.math.roundToInt import org.json.JSONObject internal enum class NativeListRendererUpdate { - UNCHANGED, - CONTENT, - ASSETS, - REPLACE, + UNCHANGED, + CONTENT, + ASSETS, + REPLACE, } // Shared chrome and binding lifetime; each renderer owns its content views. internal abstract class NativeListRendererRowView(protected val reactContext: ThemedReactContext) : - NativeListRowHost(reactContext) { - protected abstract fun bindContent( - item: NativeListItem, - theme: JSONObject?, - layout: String, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - ) + NativeListRowHost(reactContext) { + protected abstract fun bindContent( + item: NativeListItem, + theme: JSONObject?, + layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) - protected abstract fun recycleContent() + protected open fun bindSelectionContent( + item: NativeListItem, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) {} - protected abstract fun disposeContent() + protected abstract fun recycleContent() - protected open val assetFields: List = emptyList() + protected abstract fun disposeContent() - private fun update(old: NativeListItem?, next: NativeListItem): NativeListRendererUpdate { - if (old == null || old.key != next.key || old.rendererKey != next.rendererKey) - return NativeListRendererUpdate.REPLACE - if (old.content == next.content) return NativeListRendererUpdate.UNCHANGED - if ( - assetFields.any { - nativeListCanonical(old.json.opt(it)) != nativeListCanonical(next.json.opt(it)) - } - ) - return NativeListRendererUpdate.ASSETS - return NativeListRendererUpdate.CONTENT - } + protected open val assetFields: List = emptyList() - protected open fun defaultHeight(item: NativeListItem, layout: String) = 0 + private fun update(old: NativeListItem?, next: NativeListItem): NativeListRendererUpdate { + if (old == null || old.key != next.key || old.rendererKey != next.rendererKey) + return NativeListRendererUpdate.REPLACE + if (old.content == next.content) return NativeListRendererUpdate.UNCHANGED + if ( + assetFields.any { + nativeListCanonical(old.json.opt(it)) != nativeListCanonical(next.json.opt(it)) + } + ) + return NativeListRendererUpdate.ASSETS + return NativeListRendererUpdate.CONTENT + } - protected open fun horizontalWidth(item: NativeListItem) = dp(280) + protected open fun defaultHeight(item: NativeListItem, layout: String) = 0 - protected open val defaultCornerRadius = 0 - protected open val pressChangesBackground = true + protected open fun horizontalWidth(item: NativeListItem) = dp(280) - protected open fun pressContent(pressed: Boolean) {} + protected open val defaultCornerRadius = 0 + protected open val pressChangesBackground = true - protected open val showsSelection = true - protected open val pressedColorName = "rowPressedBackground" - protected open val pressedColorFallback = "#00000017" - private var current: NativeListItem? = null - private var theme: JSONObject? = null - private var listLayout = "linear" - private var selected = false - protected var sourceScale = false - private var inputSignature = "" - private var explicitHeight: Int? = null - private var touchPressed = false - private var reorderActive = false - private val separator = Paint(Paint.ANTI_ALIAS_FLAG) + protected open fun pressContent(pressed: Boolean) {} - protected fun dp(value: Int) = - if (sourceScale) (value * resources.displayMetrics.density).roundToInt() - else NativeListScale.dp(resources, value) + protected open val showsSelection = true + protected open val pressedColorName = "rowPressedBackground" + protected open val pressedColorFallback = "#00000017" + private var current: NativeListItem? = null + private var theme: JSONObject? = null + private var listLayout = "linear" + private var selected = false + protected var sourceScale = false + private var inputSignature = "" + private var explicitHeight: Int? = null + private var touchPressed = false + private var reorderActive = false + private val separator = Paint(Paint.ANTI_ALIAS_FLAG) - protected fun stylePx(value: Double) = (value * resources.displayMetrics.density).roundToInt() + protected fun dp(value: Int) = + if (sourceScale) (value * resources.displayMetrics.density).roundToInt() + else NativeListScale.dp(resources, value) - private fun color(value: String, fallback: Int = Color.TRANSPARENT) = - runCatching { parseNativeListColor(value) }.getOrDefault(fallback) + protected fun stylePx(value: Double) = (value * resources.displayMetrics.density).roundToInt() - init { - orientation = HORIZONTAL - setOnClickListener { - current - ?.takeIf { it.isRowPressEnabled } - ?.let { - onRowPress?.invoke(it, NativeListActionOrigin(this, this, bindingEpoch, "row")) - } - } - setOnTouchListener { _, event -> - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> touchPressed = current?.isRowPressEnabled == true - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL -> touchPressed = false - } - appearance() - false - } - } + private fun color(value: String, fallback: Int = Color.TRANSPARENT) = + runCatching { parseNativeListColor(value) }.getOrDefault(fallback) - override fun bind( - item: NativeListItem, - theme: JSONObject?, - layout: String, - listOrientation: String, - itemIndex: Int?, - selected: Boolean, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - useSourceScale: Boolean, - ) { - val update = update(current, item) - val signature = - nativeListCanonical( - JSONObject() - .put("theme", theme) - .put("layout", layout) - .put("orientation", listOrientation) - .put("sourceScale", useSourceScale) - .put("listStyle", listStyle) - ) - sourceScale = useSourceScale - if (update != NativeListRendererUpdate.UNCHANGED || signature != inputSignature) { - if (current != null) onBindingInvalidated?.invoke(this, bindingEpoch) - bindingEpoch++ - if (update == NativeListRendererUpdate.REPLACE) { - recycleContent() - touchPressed = false - } - bindContent(item, theme, layout, checkboxState) - inputSignature = signature - } - current = item - tag = item - this.theme = theme - listLayout = layout - this.selected = selected - explicitHeight = - item.styledHeight?.let(::stylePx) - ?: if (item.json.has("height")) - when (item.json.optString("heightRounding")) { - "floor" -> - (item.json.optDouble("height") * resources.displayMetrics.density) - .toInt() - "nearest" -> stylePx(item.json.optDouble("height")) - else -> dp(item.json.optInt("height")) - } - else null - val sizeDelta = - when (item.json.optString("size")) { - "small" -> -8 - "large" -> 12 - else -> 0 - } - minimumHeight = - if (explicitHeight == null) - dp((defaultHeight(item, layout) + sizeDelta).coerceAtLeast(0)) - else 0 - layoutParams = - (layoutParams - ?: android.view.ViewGroup.LayoutParams( - LayoutParams.MATCH_PARENT, - LayoutParams.WRAP_CONTENT, - )) - .apply { - width = - if (listOrientation == "horizontal") horizontalWidth(item) - else LayoutParams.MATCH_PARENT - height = LayoutParams.WRAP_CONTENT - } - contentDescription = item.json.optString("accessibilityLabel", item.json.optString("title")) - setTag( - com.facebook.react.R.id.react_test_id, - item.json.optString("testID").takeIf { it.isNotEmpty() }, - ) - isEnabled = !item.json.optBoolean("disabled") - isClickable = item.isWholeRowInteractive - isFocusable = isClickable - if (!isEnabled) touchPressed = false - appearance() - requestLayout() + init { + orientation = HORIZONTAL + setOnClickListener { + current + ?.takeIf { it.isRowPressEnabled } + ?.let { onRowPress?.invoke(it, NativeListActionOrigin(this, this, bindingEpoch, "row")) } } - - override fun bindSelection( - item: NativeListItem, - theme: JSONObject?, - layout: String, - itemIndex: Int?, - selected: Boolean, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - ) { - if (current?.key != item.key) return - current = item - tag = item - this.theme = theme - this.selected = selected - listLayout = layout - appearance() + setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> touchPressed = current?.isRowPressEnabled == true + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> touchPressed = false + } + appearance() + false } + } - protected fun emitAction( - key: String, - view: View, - source: String, - slot: Int? = null, - target: NativeSelectionTarget? = null, - ) { - val item = current ?: return - if (key.isNotEmpty() && !item.json.optBoolean("disabled")) - onAction?.invoke( - item, - key, - target, - NativeListActionOrigin(view, this, bindingEpoch, source, slot), - ) + override fun bind( + item: NativeListItem, + theme: JSONObject?, + layout: String, + listOrientation: String, + itemIndex: Int?, + selected: Boolean, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + useSourceScale: Boolean, + ) { + val update = update(current, item) + val signature = + nativeListCanonical( + JSONObject() + .put("theme", theme) + .put("layout", layout) + .put("orientation", listOrientation) + .put("sourceScale", useSourceScale) + .put("listStyle", listStyle) + ) + sourceScale = useSourceScale + if (update != NativeListRendererUpdate.UNCHANGED || signature != inputSignature) { + if (current != null) onBindingInvalidated?.invoke(this, bindingEpoch) + bindingEpoch++ + if (update == NativeListRendererUpdate.REPLACE) { + recycleContent() + touchPressed = false + } + bindContent(item, theme, layout, checkboxState) + inputSignature = signature } - - private fun appearance() { - val item = current ?: return - val pressed = touchPressed || reorderActive - pressContent(pressed) - val container = item.json.optJSONObject("style")?.optJSONObject("container") ?: JSONObject() - val showSelected = - showsSelection && - selected && - (listLayout != "sectioned" || item.json.optBoolean("selected")) - val backgroundName = - if (pressed && pressChangesBackground) pressedColorName - else if (showSelected) "rowSelectedBackground" else "rowBackground" - val backgroundFallback = - if (pressed && pressChangesBackground) pressedColorFallback - else if (showSelected) "#0000000F" else "#FFFFFF" - var fill = color(theme?.optString(backgroundName, backgroundFallback) ?: backgroundFallback) - if (!pressed || !pressChangesBackground) { - if (item.json.has("backgroundColor")) - fill = color(item.json.optString("backgroundColor"), fill) - if (container.has("backgroundColor")) - fill = color(container.optString("backgroundColor"), fill) + current = item + tag = item + this.theme = theme + listLayout = layout + this.selected = selected + explicitHeight = + item.styledHeight?.let(::stylePx) + ?: if (item.json.has("height")) + when (item.json.optString("heightRounding")) { + "floor" -> (item.json.optDouble("height") * resources.displayMetrics.density).toInt() + "nearest" -> stylePx(item.json.optDouble("height")) + else -> dp(item.json.optInt("height")) + } + else null + val sizeDelta = + when (item.json.optString("size")) { + "small" -> -8 + "large" -> 12 + else -> 0 + } + minimumHeight = + if (explicitHeight == null) dp((defaultHeight(item, layout) + sizeDelta).coerceAtLeast(0)) + else 0 + layoutParams = + (layoutParams + ?: android.view.ViewGroup.LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.WRAP_CONTENT, + )) + .apply { + width = + if (listOrientation == "horizontal") horizontalWidth(item) + else LayoutParams.MATCH_PARENT + height = LayoutParams.WRAP_CONTENT } - val position = item.json.optString("groupPosition") - val radius = - if (container.has("cornerRadius")) - stylePx(container.optDouble("cornerRadius")).toFloat() - else if (defaultCornerRadius > 0) dp(defaultCornerRadius).toFloat() - else if (listStyle?.has("groupCornerRadius") == true) - stylePx(listStyle!!.optDouble("groupCornerRadius")).toFloat() - else dp(12).toFloat() - val top = - container.has("cornerRadius") || - defaultCornerRadius > 0 || - position in setOf("first", "single") - val bottom = - container.has("cornerRadius") || - defaultCornerRadius > 0 || - position in setOf("last", "single") - val radii = FloatArray(8) { if (it < 4 && top || it >= 4 && bottom) radius else 0f } - outlineProvider = ViewOutlineProvider.BACKGROUND - clipToOutline = container.has("cornerRadius") - background = - GradientDrawable().apply { - setColor(fill) - cornerRadii = radii - } - foreground = - if (container.has("borderWidth")) - GradientDrawable().apply { - setColor(Color.TRANSPARENT) - cornerRadii = radii - setStroke( - stylePx(container.optDouble("borderWidth")), - color(container.optString("borderColor", "#00000000")), - ) - } - else null - alpha = - container.optDouble("opacity", item.json.optDouble("opacity", 1.0)).toFloat() * - if (isEnabled) 1f else 0.5f - separator.color = - color( - listStyle - ?.optJSONObject("separator") - ?.optString("color", theme?.optString("separator", "#0000001F") ?: "#0000001F") - ?: theme?.optString("separator", "#0000001F") - ?: "#0000001F" - ) - separator.strokeWidth = 1f - invalidate() - } + contentDescription = item.json.optString("accessibilityLabel", item.json.optString("title")) + setTag( + com.facebook.react.R.id.react_test_id, + item.json.optString("testID").takeIf { it.isNotEmpty() }, + ) + isEnabled = !item.json.optBoolean("disabled") + isClickable = item.isWholeRowInteractive + isFocusable = isClickable + if (!isEnabled) touchPressed = false + bindSelectionContent(item, checkboxState) + appearance() + requestLayout() + } - override fun setReorderActive(active: Boolean) { - reorderActive = active - appearance() - } + override fun bindSelection( + item: NativeListItem, + theme: JSONObject?, + layout: String, + itemIndex: Int?, + selected: Boolean, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + if (current?.key != item.key) return + current = item + tag = item + this.theme = theme + this.selected = selected + listLayout = layout + bindSelectionContent(item, checkboxState) + appearance() + } - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - // Android's native measurement consumes the exact resolved text/image views. - super.onMeasure( - widthMeasureSpec, - explicitHeight?.let { MeasureSpec.makeMeasureSpec(it, MeasureSpec.EXACTLY) } - ?: heightMeasureSpec, - ) - } + protected fun emitAction( + key: String, + view: View, + source: String, + slot: Int? = null, + target: NativeSelectionTarget? = null, + anchorInset: Int = 0, + ) { + val item = current ?: return + if (key.isNotEmpty() && !item.json.optBoolean("disabled")) + onAction?.invoke( + item, + key, + target, + NativeListActionOrigin(view, this, bindingEpoch, source, slot, anchorInset), + ) + } - override fun dispatchDraw(canvas: Canvas) { - super.dispatchDraw(canvas) - current?.takeIf(::nativeListLegacyShowsSeparator)?.let { - val start = - listStyle - ?.optJSONObject("separator") - ?.takeIf { it.has("inset") } - ?.let { stylePx(it.optDouble("inset")) } ?: dp(12) - canvas.drawLine(start.toFloat(), height - 1f, width.toFloat(), height - 1f, separator) - } + private fun appearance() { + val item = current ?: return + val pressed = touchPressed || reorderActive + pressContent(pressed) + val container = item.json.optJSONObject("style")?.optJSONObject("container") ?: JSONObject() + val showSelected = + showsSelection && selected && (listLayout != "sectioned" || item.json.optBoolean("selected")) + val backgroundName = + if (pressed && pressChangesBackground) pressedColorName + else if (showSelected) "rowSelectedBackground" else "rowBackground" + val backgroundFallback = + if (pressed && pressChangesBackground) pressedColorFallback + else if (showSelected) "#0000000F" else "#FFFFFF" + var fill = color(theme?.optString(backgroundName, backgroundFallback) ?: backgroundFallback) + if (!pressed || !pressChangesBackground) { + if (item.json.has("backgroundColor")) + fill = color(item.json.optString("backgroundColor"), fill) + if (container.has("backgroundColor")) + fill = color(container.optString("backgroundColor"), fill) } + val position = item.json.optString("groupPosition") + val radius = + if (container.has("cornerRadius")) stylePx(container.optDouble("cornerRadius")).toFloat() + else if (defaultCornerRadius > 0) dp(defaultCornerRadius).toFloat() + else if (listStyle?.has("groupCornerRadius") == true) + stylePx(listStyle!!.optDouble("groupCornerRadius")).toFloat() + else dp(12).toFloat() + val top = + container.has("cornerRadius") || + defaultCornerRadius > 0 || + position in setOf("first", "single") + val bottom = + container.has("cornerRadius") || + defaultCornerRadius > 0 || + position in setOf("last", "single") + val radii = FloatArray(8) { if (it < 4 && top || it >= 4 && bottom) radius else 0f } + outlineProvider = ViewOutlineProvider.BACKGROUND + clipToOutline = container.has("cornerRadius") + background = + GradientDrawable().apply { + setColor(fill) + cornerRadii = radii + } + foreground = + if (container.has("borderWidth")) + GradientDrawable().apply { + setColor(Color.TRANSPARENT) + cornerRadii = radii + setStroke( + stylePx(container.optDouble("borderWidth")), + color(container.optString("borderColor", "#00000000")), + ) + } + else null + alpha = + container.optDouble("opacity", item.json.optDouble("opacity", 1.0)).toFloat() * + if (isEnabled) 1f else 0.5f + separator.color = + color( + listStyle + ?.optJSONObject("separator") + ?.optString("color", theme?.optString("separator", "#0000001F") ?: "#0000001F") + ?: theme?.optString("separator", "#0000001F") + ?: "#0000001F" + ) + separator.strokeWidth = 1f + invalidate() + } - override fun onInitializeAccessibilityNodeInfo( - info: android.view.accessibility.AccessibilityNodeInfo - ) { - super.onInitializeAccessibilityNodeInfo(info) - info.viewIdResourceName = getTag(com.facebook.react.R.id.react_test_id) as? String - } + override fun setReorderActive(active: Boolean) { + reorderActive = active + appearance() + } - override fun recycle() { - if (current != null) onBindingInvalidated?.invoke(this, bindingEpoch) - bindingEpoch++ - current = null - tag = null - inputSignature = "" - touchPressed = false - reorderActive = false - recycleContent() - } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + // Android's native measurement consumes the exact resolved text/image views. + super.onMeasure( + widthMeasureSpec, + explicitHeight?.let { MeasureSpec.makeMeasureSpec(it, MeasureSpec.EXACTLY) } + ?: heightMeasureSpec, + ) + } - override fun dispose() { - recycle() - disposeContent() + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + current?.takeIf(::nativeListLegacyShowsSeparator)?.let { + val start = + listStyle + ?.optJSONObject("separator") + ?.takeIf { it.has("inset") } + ?.let { stylePx(it.optDouble("inset")) } ?: dp(12) + canvas.drawLine(start.toFloat(), height - 1f, width.toFloat(), height - 1f, separator) } + } + + override fun onInitializeAccessibilityNodeInfo( + info: android.view.accessibility.AccessibilityNodeInfo + ) { + super.onInitializeAccessibilityNodeInfo(info) + info.viewIdResourceName = getTag(com.facebook.react.R.id.react_test_id) as? String + } + + override fun recycle() { + if (current != null) onBindingInvalidated?.invoke(this, bindingEpoch) + bindingEpoch++ + current = null + tag = null + inputSignature = "" + touchPressed = false + reorderActive = false + recycleContent() + } + + override fun dispose() { + recycle() + disposeContent() + } } diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index a935c681b..9597c1e9c 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -852,17 +852,11 @@ internal class NativeListRowView( val icon = trailingIcons[0] trailingColumn.offsetTopAndBottom(dp(18) - dp(7) - trailingColumn.top - icon.top) } - if (item.type == "action" && item.json.has("height") && item.json.optString("presentation") == "accountSelector" && leadingIcon.visibility == VISIBLE) { - // OneKey patch: Yoga rounds the 4dp padding inside the 32dp Add account icon upward. - leadingIcon.offsetLeftAndRight((leadingFrame.width - leadingIcon.width + 1) / 2 - leadingIcon.left) - leadingIcon.offsetTopAndBottom((leadingFrame.height - leadingIcon.height + 1) / 2 - leadingIcon.top) - } if (!item.json.has("height") || hasContainerAlignment) return val isNetworkIdentity = item.type == "identity" && item.json.optString("presentation") == "networkSelector" - val isAccountAction = item.type == "action" && item.json.optString("presentation") == "accountSelector" val isNetworkSummary = item.type == "sectionHeader" && item.json.optString("presentation") == "networkSelector" && item.json.optString("variant") == "summary" val centeredColumns = when { - isNetworkIdentity || isAccountAction -> listOf(mainColumn, trailingColumn) + isNetworkIdentity -> listOf(mainColumn, trailingColumn) isNetworkSummary -> listOf(trailingColumn) else -> return } @@ -984,7 +978,6 @@ internal class NativeListRowView( "market" -> bindMarket(item, theme) "metricCard" -> bindMetricCard(item, theme) "sectionHeader" -> bindSectionHeader(item, theme, checkboxState) - "action" -> bindAction(item, theme, checkboxState) "system" -> bindSystem(item, theme) } // Keep passive rows out of accessibility and keyboard focus while allowing @@ -1158,7 +1151,7 @@ internal class NativeListRowView( null -> continue "dataPrimary", "dataSecondary" -> Unit // Independent column labels are styled above. "value", "valueSecondary" -> { - val view = if (item.type in setOf("identity", "action")) semanticValueViews.getOrNull(if (slot == "value") 0 else 1) else styledSlotView(slot) + val view = if (item.type == "identity") semanticValueViews.getOrNull(if (slot == "value") 0 else 1) else styledSlotView(slot) view?.let { applyStyledText(it, slotStyle) } } "status" -> applyStyledText(if (item.type == "activity" && item.json.optString("status") == "Failed") badgeLine else status, slotStyle) @@ -3103,39 +3096,6 @@ internal class NativeListRowView( view.text = value } - private fun bindAction( - item: NativeListItem, - theme: JSONObject?, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - ) { - val isAccountSelector = item.json.optString("presentation") == "accountSelector" - item.json.optJSONObject("icon")?.let { icon -> - addLeading(icon, if (isAccountSelector) 32 else 40) - leadingIcon.layoutParams = FrameLayout.LayoutParams(dp(24), dp(24), Gravity.CENTER) - if (!icon.has("backgroundColor")) leadingFrame.background = null - if (isAccountSelector) leadingFrame.background = roundedFill(safeColor(icon.optString("backgroundColor"), color(theme, "strongBackground", "#0000000F")), 8f) - } - addView(mainColumn, weighted()) - showText(title, item.json.optString("title"), 1) - if (isAccountSelector) { - title.typeface = if (item.json.has("icon")) NativeListFonts.medium(context) else NativeListFonts.regular(context) - title.setTextColor(color(theme, if (item.json.optString("tone") == "primary") "primaryText" else "secondaryText", "#0000009B")) - } else if (item.json.optString("tone") == "danger") { - title.setTextColor(color(theme, "negative", "#C40006D3")) - } - item.json.optJSONObject("checkbox")?.let { - bindCheckbox(item, it, checkboxState) - addView(trailingColumn, wrap()) - } ?: item.json.optJSONArray("trailing")?.let { accessories -> - if (accessories.length() > 0) { - addView(trailingColumn, wrap()) - bindAccessories(item, accessories, theme, checkboxState) - } - } - setOnClickListener { - onRowPress?.invoke(item, actionOrigin(this, "row")) - } - } private fun bindSystem(item: NativeListItem, theme: JSONObject?) { val variant = item.json.optString("variant") @@ -4001,8 +3961,6 @@ internal class NativeListRowView( item.type == "identity" && item.json.optString("presentation") == "accountSelector" val isNetworkSelectorIdentity = item.type == "identity" && item.json.optString("presentation") == "networkSelector" - val isAccountSelectorAction = - item.type == "action" && item.json.optString("presentation") == "accountSelector" val isNetworkSelectorSection = item.type == "sectionHeader" && item.json.optString("presentation") == "networkSelector" title.textSize = sp( @@ -4024,9 +3982,7 @@ internal class NativeListRowView( } }, ) - title.typeface = if (isAccountSelectorAction && item.json.has("icon")) { - NativeListFonts.medium(context) - } else if (isWalletSidebar || isAccountSelectorIdentity || isAccountSelectorAction) { + title.typeface = if (isWalletSidebar || isAccountSelectorIdentity) { NativeListFonts.regular(context) } else { when (item.type) { @@ -4070,8 +4026,7 @@ internal class NativeListRowView( TextViewCompat.setLineHeight(title, dp(24)) TextViewCompat.setLineHeight(subtitle, dp(20)) TextViewCompat.setLineHeight(tertiary, dp(20)) - } else if (item.type == "action") { - TextViewCompat.setLineHeight(title, dp(24)) + } else if (item.type == "system") { TextViewCompat.setLineHeight(title, dp(20)) @@ -4144,11 +4099,6 @@ internal class NativeListRowView( "end" -> if (item.json.optString("presentation") == "market") 44 else 36 else -> 56 } - "action" -> when { - item.json.optString("presentation") == "accountSelector" -> 48 - item.json.has("icon") -> 60 - else -> 44 - } "dataRow" -> if (currentLayout == "table") { 60 } else if ((0 until item.json.getJSONArray("columns").length()).any { @@ -4542,7 +4492,7 @@ internal class OneKeyIconView(context: android.content.Context) : View(context) } } -private class OneKeyCheckboxView(context: android.content.Context) : View(context) { +internal class OneKeyCheckboxView(context: android.content.Context) : View(context) { var usesSelectorGeometry = false private val glyphPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } private var state = "unchecked" diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt index dff5bde32..dc9003790 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt @@ -226,7 +226,8 @@ class NativeListView( mass = REORDER_SPRING_MASS, durationSeconds = REORDER_SPRING_DURATION_MS / 1_000.0, ) - private val footerView = NativeListRowView(reactContext) + private var footerView: NativeListRowHost = NativeListRendererRegistry.create(reactContext, NativeListRendererKey.LEGACY.ordinal) + private var footerRendererKey = NativeListRendererKey.LEGACY private val sectionIndexView = NativeListSectionIndexView(context) private val sectionIndexPreview = TextView(context) private var config: NativeListConfig? = null @@ -1479,6 +1480,19 @@ class NativeListView( footerView.visibility = GONE footerView.recycle() } else { + if (footer.rendererKey != footerRendererKey) { + val previous = footerView + val position = indexOfChild(previous) + previous.recycle() + previous.dispose() + footerView = NativeListRendererRegistry.create(reactContext, footer.rendererKey.ordinal) + footerView.onRowPress = ::handleRowPress + footerView.onAction = ::handleAction + footerView.onBindingInvalidated = ::handleBindingInvalidated + removeView(previous) + addView(footerView, position, previous.layoutParams) + footerRendererKey = footer.rendererKey + } footerView.visibility = VISIBLE footerView.listStyle = next.listStyle footerView.bind( diff --git a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt index 9f1665dba..39a09a6be 100644 --- a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt +++ b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt @@ -61,7 +61,6 @@ class NativeListStyleSlotTest { Triple("dataRow", "", listOf("columns", "columnSecondary", "index")), Triple("metricCard", "", listOf("title", "value", "subtitle", "trend")), Triple("sectionHeader", "", listOf("title", "subtitle", "value")), - Triple("action", "", listOf("title", "value")), // Only `warning` carries both a title and a message; the other variants // have no title field at all, so their message owning the title view is // not a collision. diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index be7eb3ece..164adbd36 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -316,3 +316,25 @@ Evidence: external validation runtime `ios-media-*`, `android-media-*` and Android unit tests pass; package typecheck and all 154 tests pass. Focused ESLint has no errors (six pre-existing engine warnings). Remaining stage 3 templates: Action, System, Activity, DataRow and MetricCard. + +### Stage 3: Action acceptance (2026-09-23) + +Action uses a dedicated renderer on all three platforms. Native accessories +are a bounded primitive (two accessory slots plus checkbox/spinner), shared +through the host action epoch and a selection-only presentation hook. Legacy +Action dispatch, typography, style-slot and default-height branches are removed. +Fixed-footer creation was migrated to the same registry after runtime QA found +that the old direct legacy allocation no longer rendered Action. Footer family +replacement invalidates the previous host and keeps list-owned gestures/events. + +On the task's iOS and Android simulators, default and value-pair/menu bounds +match the pre-migration captures. Style set/clear, same-key accessory variants, +menu and select-all checkbox actions, end/top, empty/repopulate, Media/Action +family replacement and fixed-footer rendering/clicks were exercised. The iOS +event counter reached 3 after menu, checkbox and footer; Web checked select-all +state and footer dispatch in headed Chrome. Evidence is `ios-action-*`, +`android-action-*` and `web-action-*` in the external validation runtime. +Build/type/unit checks pass; 155 package tests include style restoration, stable +Action title/icon nodes, selection echoes and incompatible-family reuse. +An Android input timeout also affected Launcher; its captured main stack was in the display event loop. After restarting the task emulator, menu, checkbox, scroll, repopulate and footer checks passed (action counter 3). This does not establish a library-level ANR cause. +Remaining stage 3 templates: System, Activity, DataRow and MetricCard. diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index ba315065b..4b73db980 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -44,7 +44,7 @@ UI thread; [DESIGN.md](DESIGN.md) describes update and event ownership. `row.key` identifies data; renderer reuse identity describes compatible view structure. Content, styles, height and placement must not enter reuse identity. -The implemented families are `message`, `rail`, `mediaTile` and `legacy`; the latter serves all +The implemented families are `message`, `rail`, `mediaTile`, `action` and `legacy`; the latter serves all unmigrated templates. Same-key family changes must replace the incompatible host. OneKeyImage owns image caches; NativeList owns row/slot binding identity. @@ -67,7 +67,7 @@ migration adds no new input limits, retry policy or automatic layout correction. ## Performance and resources Snapshots and patches cross the bridge in batches. Visible rows use platform -recycling/windowing; shared primitives own image cancellation. Message, Rail and MediaTile use dedicated lightweight native hosts and persistent Web bodies. +recycling/windowing; shared primitives own image cancellation. Message, Rail, MediaTile and Action use dedicated lightweight native hosts and persistent Web bodies. None allocates the legacy native tree. This structural change is not a measured scrolling-performance result; performance claims still need a separate workload and measurements. @@ -78,12 +78,12 @@ separate workload and measurements. | --- | --- | --- | | 1. Baseline | Complete: [implicit keys, defaults, special updates and acceptance baseline](MIGRATION_BASELINE.md) inventoried | Preserve each existing rule or explicitly migrate its caller | | 2. Message pilot | Complete: registered lightweight native hosts, persistent Web body, resolved styling/measurement and lifecycle acceptance | Lightweight native hosts, resolved styling/measurement, update classification and lifecycle acceptance | -| 3. Simple templates | In progress (2/7): Rail and MediaTile migrated on all three platforms; five templates remain | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | +| 3. Simple templates | In progress (3/7): Rail, MediaTile and Action migrated on all three platforms; four templates remain | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | | 4. Complex templates | Not started | Migrate Market, Identity and SectionHeader while preserving quote, selection and header behavior | | 5. WalletGroup | Not started | Independent member renderers; verify member events/styles, compact dragging and height restoration | | 6. Cleanup | Not started | Remove migrated key inference and global style maps; container no longer manipulates template internals | -The closed internal registry selects Message, Rail, MediaTile or the legacy family. Message owns +The closed internal registry selects Message, Rail, MediaTile, Action or the legacy family. Message owns its text column, optional leading visual and thumbnail; its native host does not allocate Market, WalletGroup or table views. Web preserves the Message body, text nodes and unchanged image elements when rebinding. Remaining templates @@ -140,3 +140,10 @@ host epoch with `mediaClose` source. Grid and horizontal allocation remain container-owned. Default iOS grid height stays width + 48, including existing compression when a close control is present; explicit container heights let the caller allocate more room. No overflow fitting is added. + +Action owns its title, optional icon and bounded accessory controls. Native +checkbox presentation can update without rebinding the title/icon; all actions +carry the shared host epoch and preserve the original account-control anchor +inset. Fixed footers now also resolve their host through the closed registry +and replace it when their renderer family changes. Placement and gesture +routing remain list-owned. diff --git a/native-views/react-native-native-list/ios/NativeListAccessoryStack.swift b/native-views/react-native-native-list/ios/NativeListAccessoryStack.swift new file mode 100644 index 000000000..1e0fd3c84 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListAccessoryStack.swift @@ -0,0 +1,570 @@ +import UIKit + +// Bounded trailing controls shared by migrated templates. The owning host supplies +// action epochs; selection-only updates touch only the existing checkbox. +final class NativeListAccessoryStack: UIStackView { + private let accessoryButtons = (0..<2).map { _ in NativeListAccessoryButton(type: .system) } + private let checkboxButton = UIButton(type: .system) + private let spinner = UIActivityIndicatorView(style: .medium) + private var accessoryActions: [(String, NativeSelectionTarget?)] = [] + private var checkboxAction: (String, NativeSelectionTarget?)? + private var boundCheckboxData: [String: Any]? + private var boundCheckboxTarget: NativeSelectionTarget? + private var semanticValueButtons: [UIButton] = [] + private var accessorySizeConstraints: [NSLayoutConstraint] = [] + private var currentItem: NativeListItem? + private var currentTheme: [String: Any]? + private var checkboxCheckedColor = UIColor.black + private var checkboxUncheckedColor = UIColor.white + private var checkboxIconColor = UIColor.white + private var checkboxBorderColor = UIColor.lightGray + var endInset: CGFloat? + var contentGap: CGFloat? + var onAction: ((String, UIView, Int?, NativeSelectionTarget?) -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + axis = .vertical + alignment = .trailing + setContentHuggingPriority(.required, for: .horizontal) + setContentCompressionResistancePriority(.required, for: .horizontal) + for (index, button) in accessoryButtons.enumerated() { + button.tag = index + button.adjustsImageWhenDisabled = false + button.tintAdjustmentMode = .normal + button.addTarget(self, action: #selector(pressed(_:)), for: .touchUpInside) + addArrangedSubview(button) + } + checkboxButton.layer.borderWidth = 2 + checkboxButton.layer.cornerRadius = 4 + checkboxButton.imageView?.contentMode = .center + checkboxButton.addTarget(self, action: #selector(checkboxPressed), for: .touchUpInside) + NSLayoutConstraint.activate([ + checkboxButton.widthAnchor.constraint(equalToConstant: 20), + checkboxButton.heightAnchor.constraint(equalToConstant: 20), + ]) + addArrangedSubview(checkboxButton) + addArrangedSubview(spinner) + } + required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func bind( + _ item: NativeListItem, descriptors: [[String: Any]], theme: [String: Any]?, + style: [String: Any], checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + reset() + currentItem = item + currentTheme = theme + spacing = CGFloat(style.double("trailingGap", default: 2)) + checkboxCheckedColor = nativeListColor(theme, "primaryText", "#202020") + checkboxUncheckedColor = nativeListColor(theme, "inverseText", "#FCFCFC") + checkboxIconColor = checkboxUncheckedColor + checkboxBorderColor = UIColor(nativeListHex: "#00000031", fallback: .lightGray) + accessoryButtons.forEach { + $0.setTitleColor(nativeListColor(theme, "primaryText", "#202020"), for: .normal) + } + bindAccessories(item, descriptors, theme, checkboxState) + if let value = style.dictionary("value"), let button = semanticValueButtons.first { + applyStyledButton(button, value) + } + isHidden = arrangedSubviews.allSatisfy { $0.isHidden } + } + func updateSelection( + _ item: NativeListItem, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + currentItem = item + if let data = boundCheckboxData { bindCheckbox(item, data, checkboxState) } + } + func anchorInset(for view: UIView) -> CGFloat { + currentItem?.data.string("presentation") == "accountSelector" && accessoryButtons.contains { $0 === view } && view.bounds.width == 38 ? 7 : 0 + } + func reset() { + NSLayoutConstraint.deactivate(accessorySizeConstraints) + accessorySizeConstraints.removeAll() + accessoryActions.removeAll() + semanticValueButtons.removeAll() + checkboxAction = nil + boundCheckboxData = nil + boundCheckboxTarget = nil + endInset = nil + contentGap = nil + currentItem = nil + for (index, button) in accessoryButtons.enumerated() { + button.setAttributedTitle(nil, for: .normal) + button.setTitle(nil, for: .normal) + button.setImage(nil, for: .normal) + button.setImage(nil, for: .disabled) + button.titleLabel?.font = nativeListFont( + ofSize: index == 0 ? 16 : 14, weight: index == 0 ? .medium : .regular) + button.titleLabel?.numberOfLines = 1 + button.titleLabel?.lineBreakMode = .byTruncatingTail + button.contentHorizontalAlignment = .center + button.contentVerticalAlignment = .center + button.rowTextOffsetY = 0 + button.marketLineHeight = nil + button.selectorSummaryLineHeight = nil + button.backgroundColor = .clear + button.pressedBackgroundColor = nil + button.layer.cornerRadius = 0 + button.isHidden = true + button.isEnabled = true + button.alpha = 1 + button.accessibilityIdentifier = nil + button.accessibilityLabel = nil + } + checkboxButton.isHidden = true + spinner.stopAnimating() + spinner.alpha = 1 + } + @objc private func pressed(_ button: UIButton) { + guard accessoryActions.indices.contains(button.tag) else { return } + let action = accessoryActions[button.tag] + if !action.0.isEmpty { onAction?(action.0, button, button.tag, action.1) } + } + @objc private func checkboxPressed() { + if let action = checkboxAction { onAction?(action.0, checkboxButton, nil, action.1) } + } + private func bindAccessories( + _ item: NativeListItem, + _ accessories: [[String: Any]], + _ theme: [String: Any]?, + _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + var textIndex = 0 + for accessory in accessories.prefix(2) { + switch accessory.string("kind") { + case "value": + showAccessory(textIndex, accessory.string("text")) + semanticValueButtons.append(accessoryButtons[textIndex]) + if item.data.string("presentation") == "networkSelector" && item.data["height"] != nil { + accessoryButtons[textIndex].contentHorizontalAlignment = .trailing + accessoryButtons[textIndex].titleLabel?.textAlignment = .right + } + applyValueSegments( + accessory.dictionaries("textSegments"), to: accessoryButtons[textIndex], theme: theme) + textIndex += 1 + case "valuePair": + showValuePairAccessory(textIndex, accessory, theme: theme) + semanticValueButtons.append(accessoryButtons[textIndex]) + textIndex += 1 + case "checkbox": bindCheckbox(item, accessory, checkboxState) + case "radio": + showAccessory( + textIndex, accessory.bool("checked") ? "●" : "○", action: accessoryAction(accessory)) + textIndex += 1 + case "switch": + showAccessory( + textIndex, accessory.bool("value") ? "ON" : "OFF", action: accessoryAction(accessory)) + textIndex += 1 + case "chevron": + var icon = accessory + icon["name"] = "ChevronRightSmallOutline" + if icon["tintColor"] == nil { + icon["tintColor"] = theme?["iconSubdued"] as? String ?? "#8D8D8D" + } + if icon.string("actionKey").isEmpty { icon["actionKey"] = "press" } + showAccessoryIcon(textIndex, icon) + textIndex += 1 + case "menu": + showMenuAccessory(textIndex, action: accessoryAction(accessory)) + textIndex += 1 + case "drag": + var icon = accessory + icon["name"] = "DragOutline" + if icon["tintColor"] == nil { + icon["tintColor"] = theme?["iconSubdued"] as? String ?? "#8D8D8D" + } + showAccessoryIcon(textIndex, icon) + textIndex += 1 + case "icon": + var icon = accessory + if icon["tintColor"] == nil { + icon["tintColor"] = theme?["iconSubdued"] as? String ?? "#8D8D8D" + } + showAccessoryIcon(textIndex, icon) + textIndex += 1 + case "spinner": spinner.startAnimating() + case "progress": + showAccessory(textIndex, "\(Int(accessory.double("value") * 100))%") + textIndex += 1 + default: break + } + } + } + + private func bindCheckbox( + _ item: NativeListItem, + _ data: [String: Any], + _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + let targetData = data.dictionary("target") + let scope = targetData?.string("scope", default: "row") ?? "row" + let key = + scope == "section" ? targetData?.string("sectionKey") : scope == "row" ? item.key : nil + let target = NativeSelectionTarget(scope: scope, key: key) + let state = checkboxState(item, target, data.string("state", default: "unchecked")) + boundCheckboxData = data + boundCheckboxTarget = target + updateCheckboxPresentation(item, data, target: target, state: state) + } + + private func updateCheckboxPresentation( + _ item: NativeListItem, + _ data: [String: Any], + target: NativeSelectionTarget, + state: String + ) { + let accessoryDisabled = data.bool("disabled") + if data.bool("loading") { + checkboxButton.isHidden = true + spinner.startAnimating() + spinner.alpha = item.data.bool("disabled") ? 1 : accessoryDisabled ? 0.5 : 1 + checkboxAction = nil + return + } + checkboxButton.isHidden = false + checkboxButton.backgroundColor = + state == "unchecked" ? checkboxUncheckedColor : checkboxCheckedColor + checkboxButton.layer.borderColor = + state == "unchecked" + ? checkboxBorderColor.cgColor + : UIColor.clear.cgColor + let glyphName = + state == "indeterminate" + ? "CheckboxIndeterminateCustom" + : "CheckboxCheckedCustom" + checkboxButton.setImage( + state == "unchecked" ? nil : nativeListIcon(named: glyphName), + for: .normal + ) + checkboxButton.tintColor = checkboxIconColor + // A disabled ListItem already applies 0.5 to its complete content. Avoid + // multiplying that opacity on the nested control a second time. + checkboxButton.alpha = item.data.bool("disabled") ? 1 : accessoryDisabled ? 0.5 : 1 + checkboxButton.isEnabled = + !item.data.bool("disabled") && !accessoryDisabled && !data.bool("loading") + checkboxAction = (data.string("actionKey", default: "selection"), target) + } + + private func showAccessory( + _ index: Int, + _ value: String, + action: (String, NativeSelectionTarget?)? = nil, + color: UIColor? = nil + ) { + guard accessoryButtons.indices.contains(index), !value.isEmpty else { return } + let button = accessoryButtons[index] + button.isHidden = false + button.setTitle(value, for: .normal) + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + if let color { button.setTitleColor(color, for: .normal) } + while accessoryActions.count <= index { accessoryActions.append(("", nil)) } + accessoryActions[index] = action ?? ("", nil) + } + + private func showValuePairAccessory( + _ index: Int, + _ accessory: [String: Any], + theme: [String: Any]? + ) { + guard accessoryButtons.indices.contains(index) else { return } + let primary = accessory.string("primary") + let secondary = accessory.string("secondary") + guard !primary.isEmpty || !secondary.isEmpty else { return } + let button = accessoryButtons[index] + let text = NSMutableAttributedString() + let primaryParagraph = NSMutableParagraphStyle() + primaryParagraph.alignment = .right + primaryParagraph.minimumLineHeight = 20 + primaryParagraph.maximumLineHeight = 20 + text.append( + NSAttributedString( + string: primary, + attributes: [ + .font: nativeListFont(ofSize: 16, weight: .medium), + .foregroundColor: accessoryTextColor( + accessory.string("primaryTone"), + defaultTone: "primary", + theme: theme + ), + .paragraphStyle: primaryParagraph, + ] + )) + if !primary.isEmpty, !secondary.isEmpty { text.append(NSAttributedString(string: "\n")) } + let secondaryParagraph = NSMutableParagraphStyle() + secondaryParagraph.alignment = .right + secondaryParagraph.minimumLineHeight = 20 + secondaryParagraph.maximumLineHeight = 20 + text.append( + NSAttributedString( + string: secondary, + attributes: [ + .font: nativeListFont(ofSize: 14), + .foregroundColor: accessoryTextColor( + accessory.string("secondaryTone"), + defaultTone: "secondary", + theme: theme + ), + .paragraphStyle: secondaryParagraph, + ] + )) + button.isHidden = false + button.titleLabel?.numberOfLines = 2 + button.contentHorizontalAlignment = .trailing + button.setAttributedTitle(text, for: .normal) + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + while accessoryActions.count <= index { accessoryActions.append(("", nil)) } + accessoryActions[index] = ("", nil) + } + + private func showMenuAccessory( + _ index: Int, + action: (String, NativeSelectionTarget?)? + ) { + showAccessory(index, "⋮", action: action) + guard accessoryButtons.indices.contains(index) else { return } + let button = accessoryButtons[index] + button.titleLabel?.font = nativeListFont(ofSize: 20, weight: .medium) + accessorySizeConstraints.append(contentsOf: [ + button.widthAnchor.constraint(equalToConstant: 24), + button.heightAnchor.constraint(equalToConstant: 24), + ]) + NSLayoutConstraint.activate(Array(accessorySizeConstraints.suffix(2))) + } + + private func accessoryTextColor( + _ tone: String, + defaultTone: String, + theme: [String: Any]? + ) -> UIColor { + switch tone.isEmpty ? defaultTone : tone { + case "positive": return nativeListColor(theme, "positive", "#218358") + case "negative": return nativeListColor(theme, "negative", "#CE2C31") + // OneKey patch: account warnings and hidden balances use existing theme tokens. + case "disabled": return nativeListColor(theme, "disabledText", "#8D8D8D") + case "caution": return nativeListColor(theme, "caution", "#AB6400") + case "secondary": return nativeListColor(theme, "secondaryText", "#646464") + default: return nativeListColor(theme, "primaryText", "#202020") + } + } + + private func showAccessoryIcon(_ index: Int, _ data: [String: Any]) { + guard accessoryButtons.indices.contains(index) else { return } + let button = accessoryButtons[index] + button.isHidden = false + button.isEnabled = !data.bool("disabled") + button.alpha = button.isEnabled ? 1 : 0.4 + let isAccountCreate = + currentItem?.data.string("presentation") == "accountSelector" + && currentItem?.data["height"] != nil && data.string("name") == "PlusSmallOutline" + let tintColor = + data["tintColor"] == nil && isAccountCreate + ? nativeListColor(currentTheme, "iconSubdued", "#8D8D8D") + : UIColor(nativeListHex: data.string("tintColor", default: "#646464"), fallback: .darkGray) + button.tintColor = tintColor + if let image = nativeListIcon(named: data.string("name")) { + button.setImage(image, for: .normal) + button.setImage( + image.withTintColor(tintColor, renderingMode: .alwaysOriginal), + for: .disabled + ) + } + let isDrillIn = data.string("kind") == "chevron" + let isAccountIcon = currentItem?.data.string("presentation") == "accountSelector" && !isDrillIn + let size: CGFloat = isDrillIn ? 24 : isAccountIcon && !isAccountCreate ? 38 : 36 + if isAccountCreate { button.layer.cornerRadius = 8 } + if isAccountIcon && !isAccountCreate && !data.string("actionKey").isEmpty { + button.pressedBackgroundColor = nativeListColor( + currentTheme, "rowPressedBackground", "#E8E8E8") + button.layer.cornerRadius = size / 2 + } + if isAccountIcon { contentGap = 5 } + button.accessibilityIdentifier = data["testID"] as? String + button.accessibilityLabel = data["accessibilityLabel"] as? String + if !isDrillIn, !data.string("actionKey").isEmpty { + // Reproduce the trailing edge of IconButton's m=-7 while keeping its + // full 36-point frame for padding/highlight behavior. + endInset = 5 + } + accessorySizeConstraints.append(contentsOf: [ + button.widthAnchor.constraint(equalToConstant: size), + button.heightAnchor.constraint(equalToConstant: size), + ]) + NSLayoutConstraint.activate(Array(accessorySizeConstraints.suffix(2))) + while accessoryActions.count <= index { accessoryActions.append(("", nil)) } + accessoryActions[index] = + data.bool("disabled") + ? ("", nil) + : (data.string("actionKey"), nil) + } + + private func accessoryAction( + _ accessory: [String: Any], + defaultKey: String = "" + ) -> (String, NativeSelectionTarget?)? { + if accessory.bool("disabled") { return nil } + let key = accessory.string("actionKey", default: defaultKey) + return key.isEmpty ? nil : (key, nil) + } + + private func applyValueSegments( + _ segments: [[String: Any]], to button: UIButton, theme: [String: Any]? + ) { + guard !segments.isEmpty else { return } + let value = NSMutableAttributedString(string: "") + let color = nativeListColor(theme, "primaryText", "#202020") + // OneKey patch: rich currency changes font runs without dropping the established line baseline. + let current = button.attributedTitle(for: .normal) + var attributes = + current.flatMap { $0.length > 0 ? $0.attributes(at: 0, effectiveRange: nil) : nil } ?? [:] + attributes[.foregroundColor] = color + if currentItem?.data.string("presentation") == "networkSelector" + && currentItem?.data["height"] != nil + { + let paragraph = + (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() + as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() + paragraph.alignment = .right + attributes[.paragraphStyle] = paragraph + } + for segment in segments { + attributes[.font] = nativeListTabularFont( + ofSize: segment.string("style") == "subscript" ? 10 : 16, weight: .medium) + value.append(NSAttributedString(string: segment.string("text"), attributes: attributes)) + } + button.setAttributedTitle(value, for: .normal) + } + + private func applyStyledButton(_ button: UIButton, _ style: [String: Any]) { + let text = + button.attributedTitle(for: .normal)?.string + ?? button.title(for: .normal) + ?? "" + guard !text.isEmpty, !style.isEmpty else { return } + let originalText = button.attributedTitle(for: .normal) + let accessory = button as? NativeListAccessoryButton + let baseFont = + (originalText?.attribute(.font, at: 0, effectiveRange: nil) as? UIFont) ?? button.titleLabel? + .font ?? nativeListFont(ofSize: 14) + let size = CGFloat(style.double("fontSize", default: Double(baseFont.pointSize))) + let font: UIFont + if let weightName = style["fontWeight"] as? String { + font = nativeListFont( + ofSize: size, + weight: marketFontWeight(weightName, fallback: .regular) + ) + } else { + font = baseFont.withSize(size) + } + let fallbackColor = button.titleColor(for: .normal) ?? .black + let color = + (style["color"] as? String) + .map { UIColor(nativeListHex: $0, fallback: fallbackColor) } + ?? fallbackColor + let attributed = + button.attributedTitle(for: .normal) + .map { NSMutableAttributedString(attributedString: $0) } + ?? NSMutableAttributedString(string: text) + let paragraph = + (attributed.attribute(.paragraphStyle, at: 0, effectiveRange: nil) + as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() + if let alignmentName = style["alignment"] as? String { + paragraph.alignment = marketTextAlignment(alignmentName) + button.contentHorizontalAlignment = + alignmentName == "start" + ? .leading + : alignmentName == "end" ? .trailing : .center + } + var attributes: [NSAttributedString.Key: Any] = [:] + applyStyledFont(attributed, style, fallback: baseFont) + if style["color"] != nil { attributes[.foregroundColor] = color } + if style["alignment"] != nil || style["lineHeight"] != nil || style["lines"] != nil + || style["truncate"] != nil + { + attributes[.paragraphStyle] = paragraph + } + if style["lineHeight"] != nil { + let box = CGFloat(style.double("lineHeight")) + paragraph.minimumLineHeight = box + paragraph.maximumLineHeight = box + attributes[.baselineOffset] = max(0, (box - font.lineHeight) / 2) + if accessory?.marketLineHeight != nil { accessory?.marketLineHeight = box } + if accessory?.selectorSummaryLineHeight != nil { accessory?.selectorSummaryLineHeight = box } + } + if style["lines"] != nil { + button.titleLabel?.numberOfLines = min(3, max(1, style.int("lines", default: 1))) + } + if style["lines"] != nil || style["truncate"] != nil { + let mode = styledLineBreakMode(style, lines: button.titleLabel?.numberOfLines ?? 1) + button.titleLabel?.lineBreakMode = mode + paragraph.lineBreakMode = mode + if button.titleLabel?.numberOfLines == 1 { normalizeSingleLine(attributed) } + } + if let alignment = style["verticalAlignment"] as? String { + button.contentVerticalAlignment = + alignment == "top" ? .top : alignment == "bottom" ? .bottom : .center + } + if style["offsetY"] != nil { + (button as? NativeListAccessoryButton)?.rowTextOffsetY = CGFloat(style.double("offsetY")) + } + button.titleLabel?.font = font + attributed.addAttributes(attributes, range: NSRange(location: 0, length: attributed.length)) + button.setAttributedTitle(attributed, for: .normal) + } + + private func applyStyledFont( + _ text: NSMutableAttributedString, _ style: [String: Any], fallback: UIFont + ) { + guard style["fontSize"] != nil || style["fontWeight"] != nil else { return } + text.enumerateAttribute(.font, in: NSRange(location: 0, length: text.length)) { + value, range, _ in + let original = (value as? UIFont) ?? fallback + let size = CGFloat(style.double("fontSize", default: Double(original.pointSize))) + let font = + style["fontWeight"] == nil + ? original.withSize(size) + : nativeListFont( + ofSize: size, weight: marketFontWeight(style.string("fontWeight"), fallback: .regular)) + text.addAttribute(.font, value: font, range: range) + } + } + + private func normalizeSingleLine(_ text: NSMutableAttributedString) { + let pattern = try! NSRegularExpression(pattern: "\\r\\n|[\\r\\n]") + for match in pattern.matches(in: text.string, range: NSRange(location: 0, length: text.length)) + .reversed() + { + text.replaceCharacters(in: match.range, with: " ") + } + } + + private func styledLineBreakMode(_ style: [String: Any], lines: Int) -> NSLineBreakMode { + if style.string("truncate", default: "tail") != "clip" { return .byTruncatingTail } + // Clipping disables wrapping; multi-line clip must wrap without adding an ellipsis. + return lines == 1 ? .byClipping : .byWordWrapping + } + + private func marketFontWeight(_ value: String, fallback: NativeListFontWeight) + -> NativeListFontWeight + { + switch value { + case "regular": return .regular + case "semibold": return .semibold + case "bold": return .bold + case "medium": return .medium + default: return fallback + } + } + + private func marketTextAlignment(_ value: String) -> NSTextAlignment { + switch value { + case "center": return .center + case "start": return effectiveUserInterfaceLayoutDirection == .rightToLeft ? .right : .left + case "end": return effectiveUserInterfaceLayoutDirection == .rightToLeft ? .left : .right + default: return .natural + } + } +} diff --git a/native-views/react-native-native-list/ios/NativeListActionRenderer.swift b/native-views/react-native-native-list/ios/NativeListActionRenderer.swift new file mode 100644 index 000000000..f7577cb9c --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListActionRenderer.swift @@ -0,0 +1,92 @@ +import UIKit + +final class NativeListActionCell: NativeListRendererCell { + private let visual = NativeListLeadingVisual(frame: .zero) + private let title = NativeListTextLabel() + private let accessories = NativeListAccessoryStack(frame: .zero) + private var visualWidth: NSLayoutConstraint! + private var visualHeight: NSLayoutConstraint! + override var assetFields: [String] { ["icon"] } + + override init(frame: CGRect) { + super.init(frame: frame) + visual.glyphSize = 24 + root.axis = .horizontal + root.alignment = .center + [visual, title, accessories].forEach(root.addArrangedSubview) + visual.translatesAutoresizingMaskIntoConstraints = false + visualWidth = visual.widthAnchor.constraint(equalToConstant: 40) + visualHeight = visual.heightAnchor.constraint(equalToConstant: 40) + NSLayoutConstraint.activate([visualWidth, visualHeight]) + title.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal) + title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + accessories.onAction = { [weak self] key, view, slot, target in + self?.emitAction(key, from: view, source: "trailingAccessory", slot: slot, target: target, anchorInset: self?.accessories.anchorInset(for: view) ?? 0) + } + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func bindContent( + _ item: NativeListItem, theme: [String: Any]?, layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + let style = item.data.dictionary("style") ?? [:] + root.alignment = + style.dictionary("container")?.string("contentVerticalAlignment") == "top" + ? .top + : style.dictionary("container")?.string("contentVerticalAlignment") == "bottom" + ? .bottom : .center + let selector = item.data.string("presentation") == "accountSelector" + let icon = item.data.dictionary("icon") + let descriptors = + item.data.dictionary("checkbox").map { [$0] } ?? item.data.dictionaries("trailing") + accessories.bind( + item, descriptors: descriptors, theme: theme, style: style, checkboxState: checkboxState) + let hp = CGFloat(style.double("horizontalPadding", default: layout == "table" ? 16 : 12)) + let vp = CGFloat(style.double("verticalPadding", default: 8)) + let end = + descriptors.count == 1 && descriptors[0].string("kind") == "chevron" + ? 6 : accessories.endInset ?? hp + contentInsets = UIEdgeInsets( + top: vp, left: hp, bottom: vp, right: style["horizontalPadding"] == nil ? end : hp) + root.spacing = 12 + root.setCustomSpacing(CGFloat(style.double("leadingGap", default: 12)), after: visual) + root.setCustomSpacing(accessories.contentGap ?? 12, after: title) + let tone = item.data.string("tone") + let color = + selector + ? nativeListColor( + theme, tone == "primary" ? "primaryText" : "secondaryText", + tone == "primary" ? "#202020" : "#646464") + : nativeListColor( + theme, tone == "danger" ? "negative" : "primaryText", + tone == "danger" ? "#CE2C31" : "#202020") + NativeListResolvedText( + item.data.string("title"), style: style.dictionary("title"), size: 16, + weight: selector && icon == nil ? .regular : .medium, color: color, lineHeight: 24, lines: 1 + ).bind(title) + visual.isHidden = icon == nil + if var icon { + if icon["backgroundColor"] == nil { icon["backgroundColor"] = "#00000000" } + var image = style.dictionary("image") ?? [:] + if selector && image["cornerRadius"] == nil && image["shape"] == nil { + image["cornerRadius"] = 8 + } + visualWidth.constant = CGFloat(image.double("width", default: selector ? 32 : 40)) + visualHeight.constant = CGFloat(image.double("height", default: selector ? 32 : 40)) + visual.bind(icon, style: image, key: item.key, theme: theme, isUnread: false) + visual.layer.borderWidth = 0 + } else { + visual.recycle() + } + } + override func bindSelectionContent( + _ item: NativeListItem, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + accessories.updateSelection(item, checkboxState: checkboxState) + } + override func recycleContent() { + visual.recycle() + accessories.reset() + } +} diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index f684898a3..ad5ef47e9 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -100,7 +100,7 @@ final class NativeListActionOrigin { } // OneKey patch: explicit summary actions use the source text's physical-pixel line box. -private final class NativeListAccessoryButton: UIButton { +final class NativeListAccessoryButton: UIButton { var pressedBackgroundColor: UIColor? var rowTextOffsetY: CGFloat = 0 @@ -959,7 +959,6 @@ final class NativeListCell: NativeListRowHost { case "market": bindMarket(item, theme: theme) case "metricCard": bindMetricCard(item, theme: theme) case "sectionHeader": bindSectionHeader(item, theme: theme, layout: layout, checkboxState) - case "action": bindAction(item, theme: theme, checkboxState) case "system": bindSystem(item, theme: theme) default: break } @@ -2170,8 +2169,6 @@ final class NativeListCell: NativeListRowHost { } case "sectionHeader": return ["title", "subtitle", "value"].contains(field) ? field : nil - case "action": - return ["title", "value"].contains(field) ? field : nil case "system": switch field { case "title": return variant == "warning" ? "title" : nil @@ -2308,7 +2305,7 @@ final class NativeListCell: NativeListRowHost { case "dataPrimary", "dataSecondary": break // Applied to independent column labels above. case "value", "valueSecondary": let index = slot == "value" ? 0 : 1 - let buttons = ["identity", "action"].contains(item.type) ? semanticValueButtons : accessoryButtons + let buttons = item.type == "identity" ? semanticValueButtons : accessoryButtons if buttons.indices.contains(index) { applyStyledButton(buttons[index], slotStyle) } default: break } @@ -3349,54 +3346,6 @@ final class NativeListCell: NativeListRowHost { button.setAttributedTitle(value, for: .normal) } - private func bindAction( - _ item: NativeListItem, - theme: [String: Any]?, - _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String - ) { - let isAccountSelector = item.data.string("presentation") == "accountSelector" - if let icon = item.data.dictionary("icon") { - leadingWidth.constant = isAccountSelector ? 32 : 40 - leadingHeight.constant = isAccountSelector ? 32 : 40 - leadingIconWidth.constant = 24 - leadingIconHeight.constant = 24 - addLeading(icon, key: item.key) - if isAccountSelector { - leadingContainer.layer.cornerCurve = .continuous - leadingContainer.layer.cornerRadius = 8 - leadingContainer.layer.borderWidth = 0 - } - if icon["backgroundColor"] == nil { - leadingContainer.backgroundColor = .clear - leadingContainer.layer.borderWidth = 0 - leadingContainer.layer.borderColor = nil - } - } - rootStack.addArrangedSubview(mainStack) - show(titleLabel, item.data.string("title"), lines: 1) - if isAccountSelector { - // OneKey patch: ListItem.Text is medium; empty-search actions use regular body text. - titleLabel.font = nativeListFont(ofSize: 16, weight: item.data.dictionary("icon") == nil ? .regular : .medium) - titleLabel.textColor = nativeListColor(theme, item.data.string("tone") == "primary" ? "primaryText" : "secondaryText", item.data.string("tone") == "primary" ? "#202020" : "#646464") - } else if item.data.string("tone") == "danger" { - titleLabel.textColor = nativeListColor(theme, "negative", "#CE2C31") - } - setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) - if let checkbox = item.data.dictionary("checkbox") { - bindCheckbox(item, checkbox, checkboxState) - rootStack.addArrangedSubview(trailingStack) - } else { - let accessories = item.data.dictionaries("trailing") - if !accessories.isEmpty { - rootStack.addArrangedSubview(trailingStack) - bindAccessories(item, accessories, theme, checkboxState) - if accessories.count == 1, accessories[0].string("kind") == "chevron" { - // ListItem.DrillIn uses mx=-6 around its 24-point icon. - rootTrailingConstraint.constant = -6 - } - } - } - } // OneKey patch: preserve the actual title frame for Popover placement. @objc private func selectorTitlePressed() { diff --git a/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift b/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift index 9e6a1d66f..d4e929bff 100644 --- a/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift +++ b/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift @@ -3,6 +3,7 @@ import UIKit // Bounded visual primitive: three source slots, two optional overlays. It knows // visual descriptors and request lifetimes, never row types or business keys. final class NativeListLeadingVisual: UIView { + var glyphSize: CGFloat = 18 private let fallback = UILabel() private let icon = UIImageView() private let unread = UIView() @@ -204,7 +205,7 @@ final class NativeListLeadingVisual: UIView { let iconSize: CGFloat = !sources.isEmpty && fallbackIcon != nil ? min(bounds.width, bounds.height) - * (fallbackIcon?.string("name") == "GlobusOutline" ? 1.2 : 1) : 18 + * (fallbackIcon?.string("name") == "GlobusOutline" ? 1.2 : 1) : glyphSize icon.frame = CGRect( x: (bounds.width - iconSize) / 2, y: (bounds.height - iconSize) / 2, width: iconSize, height: iconSize) diff --git a/native-views/react-native-native-list/ios/NativeListModels.swift b/native-views/react-native-native-list/ios/NativeListModels.swift index 015ab7550..38d764516 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -4,7 +4,7 @@ import UIKit // Message is the first migrated renderer. Other templates still use the // compatible legacy tree; style, content and row keys do not affect reuse. enum NativeListRendererKey: String, CaseIterable { - case legacy, message, rail, mediaTile + case legacy, message, rail, mediaTile, action } struct NativeListItem { diff --git a/native-views/react-native-native-list/ios/NativeListRendererCell.swift b/native-views/react-native-native-list/ios/NativeListRendererCell.swift index 97ddff00a..e0fd2b935 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererCell.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererCell.swift @@ -15,6 +15,10 @@ class NativeListRendererCell: NativeListRowHost { checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String ) {} func recycleContent() {} + func bindSelectionContent( + _ item: NativeListItem, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) {} var assetFields: [String] { [] } private func update(from old: NativeListItem?, to new: NativeListItem) -> NativeListRendererUpdate { @@ -100,6 +104,7 @@ class NativeListRendererCell: NativeListRowHost { self.theme = theme self.layout = layout self.selectedState = selected + bindSelectionContent(item, checkboxState: checkboxState) accessibilityLabel = item.data.string("accessibilityLabel", default: item.data.string("title")) accessibilityIdentifier = item.data["testID"] as? String isUserInteractionEnabled = !item.data.bool("disabled") @@ -115,20 +120,21 @@ class NativeListRendererCell: NativeListRowHost { guard self.item?.key == item.key else { return } self.item = item self.selectedState = selected + bindSelectionContent(item, checkboxState: checkboxState) applyAppearance() } override var isHighlighted: Bool { didSet { applyAppearance() } } func emitAction( _ key: String, from view: UIView, source: String, slot: Int? = nil, - target: NativeSelectionTarget? = nil + target: NativeSelectionTarget? = nil, anchorInset: CGFloat = 0 ) { guard let item, !key.isEmpty, !item.data.bool("disabled") else { return } onAction?( item, key, target, NativeListActionOrigin( sourceView: view, ownerCell: self, - bindingEpoch: bindingEpoch, source: source, slot: slot)) + bindingEpoch: bindingEpoch, source: source, slot: slot, anchorInset: anchorInset)) } private func applyAppearance() { diff --git a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift index ca3b53138..c1d10b605 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift @@ -3,10 +3,15 @@ import UIKit // Closed internal registry. Legacy templates keep their existing implementation // until their own migration; no public plugin or bridge surface is introduced. enum NativeListRendererRegistry { - private static let hosts: [NativeListRendererKey: NativeListRowHost.Type] = [ - .legacy: NativeListCell.self, .message: NativeListMessageCell.self, - .rail: NativeListRailCell.self, .mediaTile: NativeListMediaTileCell.self, - ] + private static let hosts: + [NativeListRendererKey: (type: NativeListRowHost.Type, create: () -> NativeListRowHost)] = [ + .legacy: (NativeListCell.self, { NativeListCell(frame: .zero) }), + .message: (NativeListMessageCell.self, { NativeListMessageCell(frame: .zero) }), + .rail: (NativeListRailCell.self, { NativeListRailCell(frame: .zero) }), + .mediaTile: (NativeListMediaTileCell.self, { NativeListMediaTileCell(frame: .zero) }), + .action: (NativeListActionCell.self, { NativeListActionCell(frame: .zero) }), + ] + static func create(_ key: NativeListRendererKey) -> NativeListRowHost { hosts[key]!.create() } static func key(for type: String) -> NativeListRendererKey { NativeListRendererKey(rawValue: type) ?? .legacy } @@ -15,12 +20,16 @@ enum NativeListRendererRegistry { } static func register(in collectionView: UICollectionView) { for (key, host) in hosts { - collectionView.register(host, forCellWithReuseIdentifier: reuseIdentifier(for: key)) + collectionView.register(host.type, forCellWithReuseIdentifier: reuseIdentifier(for: key)) } } static func measure(_ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String) -> CGFloat? { + if item.rendererKey == .action { + return item.data.string("presentation") == "accountSelector" + ? 48 : item.data.dictionary("icon") == nil ? 44 : 60 + } if item.rendererKey == .rail { return 40 } if item.rendererKey == .mediaTile { return 244 } guard item.rendererKey == .message else { return nil } diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index e60b09579..b8a8a3535 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -62,7 +62,8 @@ final class NativeListView: UIView { private let flowLayout = NativeListFlowLayout() private lazy var collectionView = NativeListCollectionView(frame: .zero, collectionViewLayout: flowLayout) private let footerContainer = UIView() - private let footerCell = NativeListCell(frame: .zero) + private var footerCell: NativeListRowHost = NativeListRendererRegistry.create(.legacy) + private var footerRendererKey = NativeListRendererKey.legacy private let sectionIndexView = NativeListSectionIndexView() private let sectionIndexPreview = NativeListSectionIndexPreviewView() private var sectionIndexLayoutConstraints: [NSLayoutConstraint] = [] @@ -1320,6 +1321,19 @@ final class NativeListView: UIView { footerCell.isHidden = true return } + if footer.rendererKey != footerRendererKey { + let previous = footerCell + previous.prepareForReuse() + footerCell = NativeListRendererRegistry.create(footer.rendererKey) + footerCell.onAction = previous.onAction + footerCell.onBindingInvalidated = previous.onBindingInvalidated + for recognizer in previous.gestureRecognizers ?? [] { footerCell.addGestureRecognizer(recognizer) } + previous.removeFromSuperview() + footerContainer.addSubview(footerCell) + footerCell.frame = footerContainer.bounds + footerCell.autoresizingMask = [.flexibleWidth, .flexibleHeight] + footerRendererKey = footer.rendererKey + } footerCell.isHidden = false footerHeightConstraint.constant = rowHeight(footer) bind(cell: footerCell, item: footer, itemIndex: nil) @@ -1774,10 +1788,6 @@ final class NativeListView: UIView { default: base = 56 } } - case "action": - base = item.data.string("presentation") == "accountSelector" - ? 48 - : item.data.dictionary("icon") == nil ? 44 : 60 case "dataRow": base = item.data.dictionaries("columns").contains { !$0.string("secondaryText").isEmpty diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index 09e76294c..09099d3f1 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -1197,6 +1197,110 @@ describe('web row style', () => { } }); + it('preserves Action views through style clearing and selection echoes', () => { + const { document } = new JSDOM('').window; + const host = document.createElement('div'); + document.body.appendChild(host); + const action = { + type: 'action', + key: 'action-pilot', + title: 'Action', + actionKey: 'open', + icon: { kind: 'icon', name: 'PlusSmallOutline' }, + checkbox: { + kind: 'checkbox', + state: 'unchecked', + target: { scope: 'list' }, + }, + trailing: [ + { + kind: 'value', + text: '$0.012', + textSegments: [ + { text: '$0.0' }, + { text: '5', style: 'subscript' }, + { text: '12' }, + ], + }, + { kind: 'checkbox', state: 'unchecked', target: { scope: 'row' } }, + ], + } as const; + const selectable = { + type: 'identity', + key: 'selectable', + title: 'Other', + leading: { kind: 'icon', name: 'star' }, + } as const; + const engine = new NativeListWebEngine( + host, + snapshot({ kind: 'sectioned' }, [action, selectable]), + {}, + false + ); + const current = () => + host.querySelector( + '[data-native-list-row-key="action-pilot"]' + )!; + try { + const wrapper = current(); + const body = wrapper.firstElementChild as HTMLElement; + const title = body.querySelector('[data-nl-slot="title"]'); + const icon = body.querySelector('.ok-native-list-visual'); + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [ + { + ...action, + style: { + container: { height: 100 }, + title: { fontSize: 20, lines: 2 }, + value: { fontSize: 12, lines: 1 }, + leadingGap: 18, + }, + }, + ]) + ); + expect(current().firstElementChild).toBe(body); + expect(body.querySelector('[data-nl-slot="title"]')).toBe(title); + expect(body.querySelector('.ok-native-list-visual')).toBe(icon); + expect(current().style.height).toBe('100px'); + const selected = snapshot({ kind: 'sectioned' }, [action, selectable]); + engine.applySnapshot({ + ...selected, + selection: { ...selected.selection!, selectedKeys: [selectable.key] }, + }); + expect(current().firstElementChild).toBe(body); + expect(current().style.height).toBe('60px'); + expect( + body.querySelector('[data-nl-slot="title"]')!.style + .fontSize + ).toBe(''); + expect( + body.querySelector('[role="checkbox"]')?.getAttribute('aria-checked') + ).toBe('true'); + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [ + { + type: 'message', + key: action.key, + title: 'Other', + body: 'Body', + time: '', + }, + ]) + ); + expect(current()).not.toBe(wrapper); + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [action, selectable]) + ); + expect(current()).toBe(wrapper); + expect( + body.querySelector('[role="checkbox"]')?.getAttribute('aria-checked') + ).toBe('false'); + } finally { + engine.destroy(); + } + }); + it('recomputes horizontal Rail placement from styled metrics and restores default widths', () => { const rail = { type: 'rail', diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index b5c059646..08e3f9b92 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -548,9 +548,6 @@ export function estimateWebRowHeight( case 'activity': base = row.footerActions?.length ? 100 : 60; break; - case 'mediaTile': - base = 244; - break; case 'metricCard': base = row.variant === 'activity' @@ -598,9 +595,6 @@ export function estimateWebRowHeight( ? 44 : 56; break; - case 'action': - base = row.presentation === 'accountSelector' ? 48 : row.icon ? 60 : 44; - break; case 'dataRow': base = row.columns.some((column) => column.secondaryText) ? 60 : 56; break; @@ -2396,43 +2390,6 @@ function createSectionHeader( return body; } -function createActionRow( - context: RenderContext, - row: Extract -): HTMLElement { - const body = createElement( - context.document, - 'div', - [ - 'ok-native-list-row', - 'ok-native-list-action-row', - row.presentation === 'accountSelector' - ? 'ok-native-list-account-action-row' - : '', - ] - .filter(Boolean) - .join(' ') - ); - if (row.icon) body.appendChild(createVisual(context, row.icon)!); - const title = tagSlot( - createElement( - context.document, - 'span', - 'ok-native-list-action-title', - row.title - ), - 'title' - ); - setData(title, 'tone', row.tone); - if (row.presentation === 'accountSelector' && row.icon) - title.style.fontWeight = '500'; - body.appendChild(title); - if (row.checkbox) - body.appendChild(createCheckbox(context, row.key, row.checkbox)); - appendAccessories(body, context, row.key, row.trailing); - return body; -} - function createSystemRow( context: RenderContext, row: Extract @@ -3908,6 +3865,8 @@ export function applyRowStyle(body: HTMLElement, row: RowModel): void { function rendererPrimitives(context: RenderContext) { return { + accessory: (key: string, descriptor: TrailingAccessory, slot: number) => + createAccessory(context, key, descriptor, slot), visual: (source: LeadingVisual | undefined) => createVisual(context, source), thumbnail: (source: ImageSource) => @@ -3929,8 +3888,6 @@ export function createRowBody( return createWalletGroupRow(context, row); case 'sectionHeader': return createSectionHeader(context, row); - case 'action': - return createActionRow(context, row); case 'system': return createSystemRow(context, row); case 'metricCard': @@ -3975,6 +3932,7 @@ export class NativeListWebEngine { message: [], rail: [], mediaTile: [], + action: [], }; private readonly rendererKeys = new WeakMap(); private frameHandle: number | undefined; diff --git a/native-views/react-native-native-list/src/web/templates/ActionRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/ActionRowRenderer.ts new file mode 100644 index 000000000..767f6abe4 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/ActionRowRenderer.ts @@ -0,0 +1,121 @@ +import type { ActionRow } from '../../models'; +import { RowVisualStyle, type RowPrimitives } from './RowVisual'; + +type Views = { + title: HTMLElement; + controls: HTMLElement; + visual?: HTMLElement; + visualStyle?: RowVisualStyle; + visualKey: string; + primitives: RowPrimitives; +}; +const views = new WeakMap(); +function bind(body: HTMLElement, row: ActionRow, primitives: RowPrimitives) { + const v = views.get(body)!; + v.primitives = primitives; + body.className = + 'ok-native-list-row ok-native-list-action-row' + + (row.presentation === 'accountSelector' + ? ' ok-native-list-account-action-row' + : ''); + body.style.cssText = ''; + body.removeAttribute('data-nl-container-background'); + body.removeAttribute('data-nl-container-border'); + const style = row.style; + if (style?.horizontalPadding !== undefined) + body.style.paddingInline = `${style.horizontalPadding}px`; + if (style?.verticalPadding !== undefined) + body.style.paddingBlock = `${style.verticalPadding}px`; + v.title.style.cssText = ''; + v.title.textContent = row.title; + v.title.dataset.tone = row.tone ?? ''; + if (row.presentation === 'accountSelector' && row.icon) + v.title.style.fontWeight = '500'; + if (style?.title) primitives.textStyle(v.title, style.title); + const visualKey = JSON.stringify([row.key, row.icon]); + if (v.visualKey !== visualKey) { + if (v.visual) { + primitives.dispose(v.visual); + v.visual.remove(); + } + v.visual = primitives.visual(row.icon); + v.visualStyle = v.visual ? new RowVisualStyle(v.visual) : undefined; + if (v.visual) body.insertBefore(v.visual, v.title); + v.visualKey = visualKey; + } + if (v.visualStyle && v.visual) { + const size = row.presentation === 'accountSelector' ? 32 : 40; + v.visualStyle.apply( + style?.image, + size, + size, + (style?.leadingGap ?? 12) - 12 + ); + } + v.controls.replaceChildren(); + v.controls.style.cssText = ''; + v.controls.removeAttribute('data-native-list-account-control'); + const descriptors = row.trailing ?? []; + if (row.checkbox) + v.controls.appendChild(primitives.accessory(row.key, row.checkbox, 0)); + if (descriptors.length) { + const trailing = body.ownerDocument.createElement('span'); + trailing.className = 'ok-native-list-accessories'; + if (style?.trailingGap !== undefined) + trailing.style.gap = `${style.trailingGap}px`; + if ( + row.height !== undefined && + row.presentation === 'accountSelector' && + descriptors.length === 1 && + descriptors[0]?.kind === 'icon' && + descriptors[0].name === 'PlusSmallOutline' + ) + trailing.dataset.nativeListAccountControl = 'createAddress'; + let valueIndex = 0; + descriptors.forEach((descriptor, index) => { + const node = primitives.accessory(row.key, descriptor, index); + if (descriptor.kind === 'value' || descriptor.kind === 'valuePair') { + node.dataset.nlSlot = valueIndex++ === 0 ? 'value' : 'valueSecondary'; + if (node.dataset.nlSlot === 'value' && style?.value) + primitives.textStyle(node, style.value); + } + trailing.appendChild(node); + }); + v.controls.appendChild(trailing); + } + v.controls.style.display = 'contents'; +} +function create(document: Document, row: ActionRow, primitives: RowPrimitives) { + const body = document.createElement('div'); + body.dataset.nlRenderer = 'action'; + const title = document.createElement('span'); + title.className = 'ok-native-list-action-title'; + title.dataset.nlSlot = 'title'; + const controls = document.createElement('span'); + body.append(title, controls); + views.set(body, { title, controls, visualKey: '', primitives }); + bind(body, row, primitives); + return body; +} +function recycle(body: HTMLElement) { + const v = views.get(body); + if (!v) return; + if (v.visual) { + v.primitives.dispose(v.visual); + v.visual.remove(); + } + v.visual = undefined; + v.visualStyle = undefined; + v.visualKey = ''; + v.controls.replaceChildren(); +} +export const actionRowRenderer = { + key: 'action' as const, + create, + bind, + recycle, + measure: (row: ActionRow, _width: number) => + row.presentation === 'accountSelector' ? 48 : row.icon ? 60 : 44, + measureRendered: (_body: HTMLElement, _row: ActionRow): number | undefined => + undefined, +}; diff --git a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts index 417c14377..f3937d2dc 100644 --- a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts +++ b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts @@ -1,3 +1,5 @@ +import type { ActionRow } from '../../models'; +import { actionRowRenderer } from './ActionRowRenderer'; import { mediaTileRowRenderer } from './MediaTileRowRenderer'; import type { MessageRow, RailRow, MediaTileRow, RowModel } from '../../models'; import { messageRowRenderer } from './MessageRowRenderer'; @@ -28,6 +30,7 @@ function registration(renderer: Renderer, row: R) { }; } const factories = { + action: (row: RowModel) => registration(actionRowRenderer, row as ActionRow), message: (row: RowModel) => registration(messageRowRenderer, row as MessageRow), rail: (row: RowModel) => registration(railRowRenderer, row as RailRow), @@ -35,6 +38,7 @@ const factories = { registration(mediaTileRowRenderer, row as MediaTileRow), }; const renderers = { + action: actionRowRenderer, message: messageRowRenderer, rail: railRowRenderer, mediaTile: mediaTileRowRenderer, diff --git a/native-views/react-native-native-list/src/web/templates/RowVisual.ts b/native-views/react-native-native-list/src/web/templates/RowVisual.ts index 04e5cb8f9..a4c6a145f 100644 --- a/native-views/react-native-native-list/src/web/templates/RowVisual.ts +++ b/native-views/react-native-native-list/src/web/templates/RowVisual.ts @@ -1,4 +1,5 @@ import type { + TrailingAccessory, ImageSource, LeadingVisual, NativeListTextStyle, @@ -6,6 +7,11 @@ import type { } from '../../models'; export type RowPrimitives = Readonly<{ + accessory: ( + rowKey: string, + descriptor: TrailingAccessory, + slot: number + ) => HTMLElement; visual: (source: LeadingVisual | undefined) => HTMLElement | undefined; thumbnail: (source: ImageSource) => HTMLElement | undefined; textStyle: (element: HTMLElement, style: NativeListTextStyle) => void; From 46639d919976ae97cb895e10cab2b70bd914c6fe Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 19:49:29 +0800 Subject: [PATCH 41/71] refactor(native-list): isolate System states and measurement --- .../com/onekey/nativelist/NativeListModels.kt | 10 +- .../nativelist/NativeListRendererRegistry.kt | 2 + .../nativelist/NativeListRendererRowView.kt | 26 +- .../onekey/nativelist/NativeListRowView.kt | 256 +------- .../nativelist/NativeListSystemRenderer.kt | 439 ++++++++++++++ .../nativelist/NativeListStyleSlotTest.kt | 4 +- .../react-native-native-list/docs/DESIGN.md | 17 + .../react-native-native-list/docs/SPEC.md | 2 +- .../ios/NativeListCell.swift | 305 +--------- .../ios/NativeListModels.swift | 2 +- .../ios/NativeListRendererRegistry.swift | 5 + .../ios/NativeListSystemRenderer.swift | 553 ++++++++++++++++++ .../ios/NativeListTextStyles.swift | 208 +++++++ .../ios/RNCNativeListView.swift | 44 +- .../src/__tests__/NativeList.web.test.ts | 74 +++ .../src/web/NativeListWebEngine.ts | 227 +------ .../src/web/templates/RowElements.ts | 35 ++ .../src/web/templates/RowRendererRegistry.ts | 6 + .../src/web/templates/RowVisual.ts | 7 +- .../src/web/templates/SystemRowRenderer.ts | 287 +++++++++ 20 files changed, 1663 insertions(+), 846 deletions(-) create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSystemRenderer.kt create mode 100644 native-views/react-native-native-list/ios/NativeListSystemRenderer.swift create mode 100644 native-views/react-native-native-list/ios/NativeListTextStyles.swift create mode 100644 native-views/react-native-native-list/src/web/templates/RowElements.ts create mode 100644 native-views/react-native-native-list/src/web/templates/SystemRowRenderer.ts diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index c812d3e60..5e087130b 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -66,14 +66,6 @@ internal fun nativeListStyleSlot(type: String, variant: String, field: String): "title", "subtitle", "value" -> field else -> null } - "system" -> when (field) { - "title" -> if (variant == "warning") "title" else null - // Only the warning variant renders a separate title; every other variant - // puts its message in the title view. - "message" -> if (variant == "warning") "subtitle" else "title" - "actionText" -> "value" - else -> null - } // Market owns its own richer style path. else -> null } @@ -102,7 +94,7 @@ private fun updateLengthPrefixed(digest: MessageDigest, value: String) { // Message and Rail own dedicated renderers. Remaining templates still share // a compatible legacy tree; keys and style values never partition the pool. -internal enum class NativeListRendererKey { LEGACY, MESSAGE, RAIL, MEDIA_TILE, ACTION } +internal enum class NativeListRendererKey { LEGACY, MESSAGE, RAIL, MEDIA_TILE, ACTION, SYSTEM } internal data class NativeListItem( val key: String, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt index 0a276a496..f25a7c0cd 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt @@ -9,6 +9,7 @@ internal object NativeListRendererRegistry { NativeListRendererKey.MESSAGE to ::NativeListMessageRowView, NativeListRendererKey.RAIL to ::NativeListRailRowView, NativeListRendererKey.MEDIA_TILE to ::NativeListMediaTileRowView, + NativeListRendererKey.SYSTEM to ::NativeListSystemRowView, NativeListRendererKey.ACTION to ::NativeListActionRowView, ) @@ -17,6 +18,7 @@ internal object NativeListRendererRegistry { "message" to NativeListRendererKey.MESSAGE, "rail" to NativeListRendererKey.RAIL, "mediaTile" to NativeListRendererKey.MEDIA_TILE, + "system" to NativeListRendererKey.SYSTEM, "action" to NativeListRendererKey.ACTION, ) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt index 04dc5723a..dea18a9e6 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt @@ -54,6 +54,18 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th protected open fun defaultHeight(item: NativeListItem, layout: String) = 0 + protected open fun minimumContentHeight(item: NativeListItem, layout: String, sizeDelta: Int) = + dp((defaultHeight(item, layout) + sizeDelta).coerceAtLeast(0)) + + protected open fun modelHeight(item: NativeListItem, layout: String): Int? = + if (item.json.has("height")) + when (item.json.optString("heightRounding")) { + "floor" -> (item.json.optDouble("height") * resources.displayMetrics.density).toInt() + "nearest" -> stylePx(item.json.optDouble("height")) + else -> dp(item.json.optInt("height")) + } + else null + protected open fun horizontalWidth(item: NativeListItem) = dp(280) protected open val defaultCornerRadius = 0 @@ -138,24 +150,14 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th this.theme = theme listLayout = layout this.selected = selected - explicitHeight = - item.styledHeight?.let(::stylePx) - ?: if (item.json.has("height")) - when (item.json.optString("heightRounding")) { - "floor" -> (item.json.optDouble("height") * resources.displayMetrics.density).toInt() - "nearest" -> stylePx(item.json.optDouble("height")) - else -> dp(item.json.optInt("height")) - } - else null + explicitHeight = item.styledHeight?.let(::stylePx) ?: modelHeight(item, layout) val sizeDelta = when (item.json.optString("size")) { "small" -> -8 "large" -> 12 else -> 0 } - minimumHeight = - if (explicitHeight == null) dp((defaultHeight(item, layout) + sizeDelta).coerceAtLeast(0)) - else 0 + minimumHeight = if (explicitHeight == null) minimumContentHeight(item, layout, sizeDelta) else 0 layoutParams = (layoutParams ?: android.view.ViewGroup.LayoutParams( diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index 9597c1e9c..98091936e 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -51,69 +51,6 @@ import org.json.JSONArray import org.json.JSONObject import kotlin.math.roundToInt -// Market/TokenListSkeleton: source geometry and the native Skeleton's 3s shimmer. -private class NativeListMarketSkeleton(context: android.content.Context, backgroundColor: Int) : View(context) { - private val marks = Array(5) { RectF() } - private val paint = Paint(Paint.ANTI_ALIAS_FLAG) - private val matrix = Matrix() - private val shaders = arrayOfNulls(5) - private val dark = Color.red(backgroundColor) * 0.299 + Color.green(backgroundColor) * 0.587 + Color.blue(backgroundColor) * 0.114 < 128 - private val colors = intArrayOf( - Color.parseColor(if (dark) "#111111" else "#FAFAFA"), - Color.parseColor(if (dark) "#333333" else "#CDCDCD"), - Color.parseColor(if (dark) "#111111" else "#FAFAFA"), - ) - private var phase = 0f - private val animator = ValueAnimator.ofFloat(0f, 1f).apply { - duration = 3000 - repeatCount = ValueAnimator.INFINITE - interpolator = LinearInterpolator() - addUpdateListener { phase = it.animatedValue as Float; invalidate() } - } - - private fun dp(value: Float) = NativeListScale.dp(resources, value) - - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - super.onSizeChanged(w, h, oldw, oldh) - marks[0].set(0f, 0f, dp(32f), dp(32f)) - marks[1].set(dp(44f), 0f, dp(124f), dp(16f)) - marks[2].set(dp(44f), dp(20f), dp(104f), dp(32f)) - marks[3].set(w - dp(168f), dp(7f), w - dp(88f), dp(25f)) - marks[4].set(w - dp(80f), dp(7f), w.toFloat(), dp(25f)) - marks.forEachIndexed { index, mark -> - shaders[index] = LinearGradient(0f, 0f, mark.width(), 0f, colors, floatArrayOf(0f, 0.5f, 1f), Shader.TileMode.CLAMP) - } - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - marks.forEachIndexed { index, mark -> - matrix.setTranslate(mark.left - mark.width() + phase * mark.width() * 3f, 0f) - shaders[index]?.setLocalMatrix(matrix) - paint.shader = shaders[index] - val radius = dp(if (index == 0) 16f else 8f) - canvas.drawRoundRect(mark, radius, radius, paint) - } - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - if (windowVisibility == VISIBLE) animator.start() - } - - override fun onDetachedFromWindow() { - animator.cancel() - super.onDetachedFromWindow() - } - - override fun onWindowVisibilityChanged(visibility: Int) { - super.onWindowVisibilityChanged(visibility) - if (visibility == VISIBLE && isAttachedToWindow) { - if (!animator.isStarted) animator.start() - } else animator.cancel() - } -} - internal data class NativeListActionOrigin( val sourceView: View, val ownerRowView: NativeListRowHost, @@ -474,8 +411,6 @@ internal class NativeListRowView( private val tableDataContainer = LinearLayout(context) private val tableDataColumns = List(4) { NativeListTableColumnView(context) } private val unreadDot = View(context) - private val skeletonPrimary = View(context) - private val skeletonSecondary = View(context) private val walletGroupRows = mutableListOf() private val walletGroupDragBadgeBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG) private val walletGroupDragBadgeBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { @@ -889,7 +824,7 @@ internal class NativeListRowView( touchPressed = shouldRestorePressed currentLayout = layout tag = item - selectorUsesSourceScale = item.type == "market" || item.type == "system" && item.json.optString("presentation") == "market" && item.json.optString("variant") == "retry" || item.usesSelectorSourceScale || useSourceScale + selectorUsesSourceScale = item.type == "market" || item.usesSelectorSourceScale || useSourceScale reorderActive = false if (!reusesImageIdentity) { selectorImages.forEach(OneKeyImageReusableView::prepareForReuse) @@ -978,7 +913,6 @@ internal class NativeListRowView( "market" -> bindMarket(item, theme) "metricCard" -> bindMetricCard(item, theme) "sectionHeader" -> bindSectionHeader(item, theme, checkboxState) - "system" -> bindSystem(item, theme) } // Keep passive rows out of accessibility and keyboard focus while allowing // their independently bound accessory controls to remain interactive. @@ -986,14 +920,6 @@ internal class NativeListRowView( isClickable = wholeRowPressEnabled isFocusable = wholeRowPressEnabled applySize(item) - if (item.type == "system" && item.json.optString("variant") == "warning") { - title.typeface = NativeListFonts.medium(context) - title.textSize = sp(14f) - subtitle.textSize = sp(14f) - TextViewCompat.setLineHeight(title, dp(20)) - TextViewCompat.setLineHeight(subtitle, dp(20)) - minimumHeight = 0 - } applyListOrientation(item, listOrientation) applySelectorTypography(item) applyRowStyle(item) @@ -1427,7 +1353,7 @@ internal class NativeListRowView( // OneKey patch: match SizableText TABULAR_NUMS on dynamic labels without replacing their typeface. private fun applySelectorTypography(item: NativeListItem) { - val usesSelectorTypography = item.json.optString("presentation") in setOf("accountSelector", "networkSelector", "walletSidebar") || item.type == "system" && item.json.optString("variant") == "warning" + val usesSelectorTypography = item.json.optString("presentation") in setOf("accountSelector", "networkSelector", "walletSidebar") fun visit(view: View) { if (view is OneKeyIconView) view.useSourceScale = selectorUsesSourceScale if (view is DottedUnderlineTextView) view.useSourceScale = selectorUsesSourceScale @@ -1437,7 +1363,7 @@ internal class NativeListRowView( if (!selectorOriginalFontFeatures.containsKey(view)) selectorOriginalFontFeatures[view] = original view.fontFeatureSettings = if (original.isNullOrEmpty()) "tnum" else if (original.contains("tnum")) original else "$original, 'tnum' 1" // OneKey patch: SizableText disables font scaling and rounds font sizes to whole pixels. - val sourceTypography = selectorUsesSourceScale || item.type == "system" && item.json.optString("variant") == "warning" + val sourceTypography = selectorUsesSourceScale if (sourceTypography) { // OneKey patch: React Native CustomStyleSpan disables hinting and preserves fractional advances. selectorOriginalPaintFlags.putIfAbsent(view, view.paintFlags) @@ -1702,8 +1628,6 @@ internal class NativeListRowView( leadingFallback.visibility = GONE leadingFallback.background = null showsSeparator = false - mainColumn.removeView(skeletonPrimary) - mainColumn.removeView(skeletonSecondary) setOnClickListener { view -> (view.tag as? NativeListItem)?.let { item -> if (item.type == "market" && marketLongPressFired) { @@ -3097,139 +3021,6 @@ internal class NativeListRowView( } - private fun bindSystem(item: NativeListItem, theme: JSONObject?) { - val variant = item.json.optString("variant") - val isMarket = item.json.optString("presentation") == "market" - if (isMarket) setPadding(dp(20), dp(12), dp(20), dp(12)) - if (variant == "loading" && item.json.optString("loadingStyle") == "skeleton") { - setPadding(dp(20), dp(12), dp(20), dp(12)) - addView( - NativeListMarketSkeleton(context, color(theme, "background", "#FFFFFF")), - LayoutParams(LayoutParams.MATCH_PARENT, dp(32)), - ) - return - } - if (variant == "loading" && item.json.optString("loadingStyle") == "spinner") { - gravity = Gravity.CENTER - setPadding(0, dp(16), 0, dp(16)) - addView( - ProgressBar(context, null, android.R.attr.progressBarStyleSmall).apply { - isIndeterminate = true - indeterminateTintList = android.content.res.ColorStateList.valueOf(color(theme, "icon", "#0000009B")) - }, - LayoutParams(dp(20), dp(20)), - ) - return - } - if (isMarket && variant == "retry") { - val message = item.json.optString("message") - orientation = VERTICAL - gravity = Gravity.CENTER - setPadding(dp(32), dp(if (message.isEmpty()) 11 else 32), dp(32), dp(if (message.isEmpty()) 11 else 27)) - if (message.isNotEmpty()) { - showText(title, message, 2) - val style = JSONObject().put("fontSize", 16).put("lineHeight", 24) - applyMarketTextStyle(title, style, 16f, 24, "regular", color(theme, "secondaryText", "#0000009B"), "center") - applyMarketTextMetrics(title, style) - addView(mainColumn, wrap().apply { bottomMargin = kotlin.math.ceil(7.0 * resources.displayMetrics.density).toInt() }) - } - showTrailing(0, item.json.optString("actionText", "Retry"), true, item.json.optString("actionKey")) - val button = trailingViews[0] - val style = JSONObject().put("fontSize", 14).put("lineHeight", 20) - applyMarketTextStyle(button, style, 14f, 20, "medium", color(theme, "secondaryText", "#0000009B"), "center") - applyMarketTextMetrics(button, style) - button.background = null - // Match Yoga's text rounding before adding the tertiary Button's border/padding. - val density = resources.displayMetrics.density - val buttonWidth = (kotlin.math.ceil(button.paint.measureText(button.text.toString()).toDouble()) + 18 * density).roundToInt() - val buttonHeight = kotlin.math.ceil(kotlin.math.ceil(20.0 * density) + 10 * density).toInt() - button.setPadding(0, 0, 0, 0) - button.layoutParams = LayoutParams(buttonWidth, buttonHeight) - addView(trailingColumn, wrap()) - return - } - if (isMarket && variant == "noMatch") { - gravity = Gravity.CENTER - setPadding(dp(32), dp(32), dp(32), dp(32)) - showText(title, item.json.optString("message"), 1) - val style = JSONObject().put("fontSize", 16).put("lineHeight", 24) - applyMarketTextStyle(title, style, 16f, 24, "regular", color(theme, "secondaryText", "#0000009B"), "center") - applyMarketTextMetrics(title, style) - addView(mainColumn, wrap()) - return - } - // OneKey patch: warning title/description wrap inside the actual scroll content. - if (variant == "warning") { - setPadding(dp(12), dp(14), dp(12), dp(14)) - addView(mainColumn, weighted()) - showText(title, item.json.optString("title"), Int.MAX_VALUE) - showText(subtitle, item.json.optString("message"), Int.MAX_VALUE) - title.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) - subtitle.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { topMargin = dp(4) } - titleLine.packsChildrenAtStart = false - return - } - if (variant == "spacer") { - minimumHeight = dp(item.json.optInt("height", 0)) - return - } - if (variant == "end") { - // components/ListEndIndicator: py=16, gap=8, 80x1 lines and a 4dp dot. - gravity = Gravity.CENTER - setPadding(0, dp(16), 0, dp(16)) - val indicatorColor = color(theme, "separator", "#0000001F") - addView(View(context).apply { setBackgroundColor(indicatorColor) }, LayoutParams(dp(80), 1)) - addView( - View(context).apply { background = roundedFill(indicatorColor, 2f) }, - LayoutParams(dp(4), dp(4)).apply { - marginStart = dp(8) - marginEnd = dp(8) - }, - ) - addView(View(context).apply { setBackgroundColor(indicatorColor) }, LayoutParams(dp(80), 1)) - return - } - gravity = Gravity.CENTER - if (variant == "loading") { - addLeading(JSONObject().put("kind", "skeleton"), if (isMarket) 32 else 40) - leadingFallback.text = "" - leadingFallback.background = roundedFill( - color(theme, "strongBackground", "#0000000F"), - if (isMarket) 16f else 20f, - ) - addSkeleton(skeletonPrimary, 120, 12, theme, bottomMarginDp = 8) - addSkeleton(skeletonSecondary, 80, 12, theme) - } - showText(title, item.json.optString("message"), 2) - title.gravity = if (variant == "retry" || variant == "noMatch") { - Gravity.START - } else { - Gravity.CENTER - } - mainColumn.gravity = Gravity.CENTER_VERTICAL - if (variant == "noMatch") { - setPadding(dp(12), 0, dp(12), 0) - title.setTextColor(color(theme, "secondaryText", "#0000009B")) - } else if (variant == "retry") { - setPadding(dp(12), dp(8), dp(12), dp(8)) - title.setTextColor(color(theme, "secondaryText", "#0000009B")) - } - addView( - mainColumn, - if (variant == "retry" || variant == "noMatch") weighted() else wrap(), - ) - if (variant == "retry") { - setOnClickListener { - onRowPress?.invoke(item, actionOrigin(this, "row")) - } - showTrailing(0, item.json.optString("actionText", "Retry"), true, item.json.optString("actionKey")) - trailingViews[0].textSize = sp(14f) - trailingViews[0].background = roundedFill(color(theme, "strongBackground", "#0000000F"), 16f) - trailingViews[0].setPadding(dp(10), dp(4), dp(10), dp(4)) - TextViewCompat.setLineHeight(trailingViews[0], dp(20)) - addView(trailingColumn, wrap().apply { marginStart = dp(12) }) - } - } private fun addLeading( visual: JSONObject?, @@ -3929,13 +3720,6 @@ internal class NativeListRowView( } private fun applySize(item: NativeListItem) { - if (item.type == "system" && item.json.optString("presentation") == "market" && - item.json.optString("variant") in setOf("retry", "noMatch")) { - val defaultHeight = if (item.json.optString("variant") == "noMatch") 88.0 else if (item.json.optString("message").isEmpty()) 52.0 else 120.0 - minimumHeight = (item.json.optDouble("height", defaultHeight).toFloat() * resources.displayMetrics.density).roundToInt() - selectorHeight = if (item.json.has("height")) minimumHeight else null - return - } if (item.type == "market") { val style = item.json.optJSONObject("style") val imageHeight = style?.optJSONObject("image")?.optDouble( @@ -3976,7 +3760,6 @@ internal class NativeListRowView( item.json.optString("variant") == "history" || item.sectionKey?.startsWith("history-") == true -> 12f else -> 14f } - "system" -> 14f "metricCard" -> if (item.json.optString("size") == "large") 24f else 18f else -> 16f } @@ -3994,7 +3777,6 @@ internal class NativeListRowView( item.sectionKey in setOf("linear-tokens", "action-tokens") -> NativeListFonts.regular(context) else -> NativeListFonts.semibold(context) } - "system" -> NativeListFonts.regular(context) "metricCard" -> NativeListFonts.semibold(context) else -> NativeListFonts.medium(context) } @@ -4027,9 +3809,6 @@ internal class NativeListRowView( TextViewCompat.setLineHeight(subtitle, dp(20)) TextViewCompat.setLineHeight(tertiary, dp(20)) - } else if (item.type == "system") { - TextViewCompat.setLineHeight(title, dp(20)) - } status.textSize = sp(12f) badgeLine.textSize = sp(12f) @@ -4039,9 +3818,7 @@ internal class NativeListRowView( val isSelectorLetter = isNetworkSelectorSection && item.json.has("height") && item.json.optString("variant") != "summary" && item.json.optString("titleActionKey").isEmpty() && item.json.optJSONObject("checkbox") == null - val isSelectorSectionSpacer = selectorUsesSourceScale && currentLayout == "sectioned" && - item.type == "system" && item.json.optString("variant") == "spacer" - if (isSelectorLetter || isSelectorSectionSpacer) { + if (isSelectorLetter) { selectorHeight = (item.json.optInt("height") * resources.displayMetrics.density).toInt() } if (isSelectorLetter) { @@ -4062,7 +3839,6 @@ internal class NativeListRowView( } val baseHeight = when { item.json.has("height") -> item.json.optInt("height") - item.type == "system" && item.json.optString("variant") == "spacer" -> item.json.optInt("height", 0) item.type == "walletGroup" -> { val childCount = item.json.optJSONArray("children")?.length() ?: 0 // OneKey patch: include badge heights in the group layout. @@ -4088,17 +3864,6 @@ internal class NativeListRowView( item.json.optString("value").isNotEmpty() && item.json.optJSONObject("checkbox") != null -> 40 else -> 36 } - "system" -> when (item.json.optString("variant")) { - "loading" -> when (item.json.optString("loadingStyle")) { - "skeleton" -> 56 - "spinner" -> 52 - else -> if (item.json.optString("presentation") == "market") 68 else 56 - } - "noMatch", "retry" -> if (item.json.optString("presentation") == "market") 44 else if (item.json.optString("variant") == "noMatch") 36 else 44 - "warning" -> 0 - "end" -> if (item.json.optString("presentation") == "market") 44 else 36 - else -> 56 - } "dataRow" -> if (currentLayout == "table") { 60 } else if ((0 until item.json.getJSONArray("columns").length()).any { @@ -4134,19 +3899,6 @@ internal class NativeListRowView( minimumHeight = dp((baseHeight + modifier + sectionSpacing + tableAdjustment).coerceAtLeast(0)) } - private fun addSkeleton( - view: View, - widthDp: Int, - heightDp: Int, - theme: JSONObject?, - bottomMarginDp: Int = 0, - ) { - view.background = roundedFill(color(theme, "strongBackground", "#0000000F"), 6f) - mainColumn.addView( - view, - LayoutParams(dp(widthDp), dp(heightDp)).apply { bottomMargin = dp(bottomMarginDp) }, - ) - } private fun applyListOrientation(item: NativeListItem, listOrientation: String) { val params = layoutParams ?: return diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSystemRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSystemRenderer.kt new file mode 100644 index 000000000..344c9b83b --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSystemRenderer.kt @@ -0,0 +1,439 @@ +package com.margelo.nitro.nativelist + +import android.animation.ValueAnimator +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.LinearGradient +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.drawable.GradientDrawable +import android.view.Gravity +import android.view.View +import android.view.animation.LinearInterpolator +import android.widget.LinearLayout +import android.widget.ProgressBar +import com.facebook.react.uimanager.ThemedReactContext +import kotlin.math.ceil +import kotlin.math.roundToInt +import org.json.JSONObject + +/** System owns status content; placement and actions remain in the list host. */ +internal class NativeListSystemRowView(context: ThemedReactContext) : + NativeListRendererRowView(context) { + private val title = NativeListTextView(context) + private val message = NativeListTextView(context) + private val action = NativeListTextView(context) + private val column = LinearLayout(context).apply { orientation = VERTICAL } + private val leading = View(context) + private val bars = List(2) { View(context) } + + override fun defaultHeight(item: NativeListItem, layout: String): Int = + when (item.json.optString("variant")) { + "warning", + "spacer" -> 0 + "loading" -> + when (item.json.optString("loadingStyle")) { + "skeleton" -> 56 + "spinner" -> 52 + else -> if (item.json.optString("presentation") == "market") 68 else 56 + } + "noMatch", + "end" -> if (item.json.optString("presentation") == "market") 44 else 36 + "retry" -> 44 + else -> 56 + } + + override fun minimumContentHeight(item: NativeListItem, layout: String, sizeDelta: Int): Int { + val variant = item.json.optString("variant") + if (variant == "warning") return 0 + if (item.json.optString("presentation") == "market" && variant in setOf("retry", "noMatch")) { + return stylePx( + if (variant == "noMatch") 88.0 + else if (item.json.optString("message").isEmpty()) 52.0 else 120.0 + ) + } + return super.minimumContentHeight(item, layout, sizeDelta) + } + + override fun modelHeight(item: NativeListItem, layout: String): Int? { + if ( + sourceScale && + layout == "sectioned" && + item.json.optString("variant") == "spacer" && + !item.json.has("heightRounding") + ) + return (item.json.optDouble("height") * resources.displayMetrics.density).toInt() + return super.modelHeight(item, layout) + } + + private fun color(theme: JSONObject?, name: String, fallback: String) = + parseNativeListColor(theme?.optString(name, fallback) ?: fallback) + + private fun fill(color: Int, radius: Int) = + GradientDrawable().apply { + setColor(color) + cornerRadius = dp(radius).toFloat() + } + + private fun wrap() = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + + override fun bindContent( + item: NativeListItem, + theme: JSONObject?, + layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + recycleContent() + val data = item.json + val variant = data.optString("variant") + val market = data.optString("presentation") == "market" + if (market && variant == "retry") sourceScale = true + val style = data.optJSONObject("style") ?: JSONObject() + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + val hp = dp(if (layout == "table") 16 else 12) + setPadding(hp, dp(8), hp, dp(8)) + if (market) setPadding(dp(20), dp(12), dp(20), dp(12)) + column.gravity = Gravity.CENTER_VERTICAL + val primary = color(theme, "primaryText", "#1D1D1D") + val secondary = color(theme, "secondaryText", "#0000009B") + fun text( + view: NativeListTextView, + value: String, + slot: String, + size: Float = 14f, + weight: String = "regular", + line: Int = 20, + lines: Int = 2, + alignment: String = "start", + foreground: Int = secondary, + physical: Boolean = false, + ) { + val override = style.optJSONObject(slot) + val merged = + JSONObject(override?.toString() ?: "{}").apply { + if (!has("alignment")) put("alignment", alignment) + } + var resolved = + NativeListResolvedText.resolve( + context, + value, + merged, + size, + weight, + foreground, + line, + lines, + sourceScale, + ) + // Warning defaults wrap without a cap; explicit style line limits remain bounded. + if (override?.has("lines") != true) resolved = resolved.copy(lines = lines) + if (physical) + resolved = + resolved.copy( + fontSize = + if (override?.has("fontSize") == true) resolved.fontSize + else + ceil( + (resolved.fontSize * resources.displayMetrics.density / + resources.displayMetrics.scaledDensity) + .toDouble() + ) + .toFloat(), + explicitLineHeight = true, + ) + resolved.bind(view) + view.paintFlags = + if (physical) view.paintFlags or Paint.SUBPIXEL_TEXT_FLAG or Paint.LINEAR_TEXT_FLAG + else view.paintFlags and Paint.LINEAR_TEXT_FLAG.inv() + view.layoutParams = wrap() + } + when { + variant == "loading" && data.optString("loadingStyle") == "skeleton" -> { + setPadding(dp(20), dp(12), dp(20), dp(12)) + addView( + NativeListMarketSkeleton(context, color(theme, "background", "#FFFFFF")), + LayoutParams(LayoutParams.MATCH_PARENT, dp(32)), + ) + } + variant == "loading" && data.optString("loadingStyle") == "spinner" -> { + gravity = Gravity.CENTER + setPadding(0, dp(16), 0, dp(16)) + addView( + ProgressBar(context, null, android.R.attr.progressBarStyleSmall).apply { + isIndeterminate = true + indeterminateTintList = + android.content.res.ColorStateList.valueOf(color(theme, "icon", "#0000009B")) + }, + LayoutParams(dp(20), dp(20)), + ) + } + market && variant == "retry" -> { + val value = data.optString("message") + orientation = VERTICAL + gravity = Gravity.CENTER + setPadding( + dp(32), + dp(if (value.isEmpty()) 11 else 32), + dp(32), + dp(if (value.isEmpty()) 11 else 27), + ) + if (value.isNotEmpty()) { + text(title, value, "message", 16f, line = 24, alignment = "center", physical = true) + column.addView(title) + addView( + column, + wrap().apply { bottomMargin = ceil(7.0 * resources.displayMetrics.density).toInt() }, + ) + } + text( + action, + data.optString("actionText", "Retry"), + "actionText", + 14f, + "medium", + 20, + 1, + "center", + physical = true, + ) + val density = resources.displayMetrics.density + val width = + (ceil(action.paint.measureText(action.text.toString()).toDouble()) + 18 * density) + .roundToInt() + val height = ceil(ceil(20.0 * density) + 10 * density).toInt() + action.gravity = action.gravity or Gravity.CENTER_VERTICAL + addView(action, LayoutParams(width, height)) + } + market && variant == "noMatch" -> { + gravity = Gravity.CENTER + setPadding(dp(32), dp(32), dp(32), dp(32)) + text( + title, + data.optString("message"), + "message", + 16f, + line = 24, + lines = 1, + alignment = "center", + physical = true, + ) + column.addView(title) + addView(column, wrap()) + } + variant == "warning" -> { + setPadding(dp(12), dp(14), dp(12), dp(14)) + text( + title, + data.optString("title"), + "title", + 14f, + "medium", + lines = Int.MAX_VALUE, + foreground = primary, + physical = true, + ) + text(message, data.optString("message"), "message", lines = Int.MAX_VALUE, physical = true) + column.addView(title, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) + column.addView( + message, + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { + topMargin = if (style.has("lineGap")) stylePx(style.optDouble("lineGap")) else dp(4) + }, + ) + addView(column, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + } + variant == "spacer" -> Unit + variant == "end" -> { + gravity = Gravity.CENTER + setPadding(0, dp(16), 0, dp(16)) + val ink = color(theme, "separator", "#0000001F") + listOf(80, 4, 80).forEach { w -> + addView( + View(context).apply { background = fill(ink, if (w == 4) 2 else 0) }, + LayoutParams(dp(w), if (w == 4) dp(4) else 1).apply { + if (w == 4) { + marginStart = dp(8) + marginEnd = dp(8) + } + }, + ) + } + } + else -> { + gravity = Gravity.CENTER + if (variant == "loading") { + val size = if (market) 32 else 40 + leading.background = fill(color(theme, "strongBackground", "#0000000F"), size / 2) + addView( + leading, + LayoutParams(dp(size), dp(size)).apply { + marginEnd = + if (style.has("leadingGap")) stylePx(style.optDouble("leadingGap")) else dp(12) + }, + ) + } + text( + title, + data.optString("message"), + "message", + alignment = if (variant in setOf("retry", "noMatch")) "start" else "center", + foreground = if (variant == "loading") primary else secondary, + ) + column.addView(title) + if (variant == "loading") + bars.forEachIndexed { index, view -> + view.background = fill(color(theme, "strongBackground", "#0000000F"), 4) + column.addView( + view, + LayoutParams(dp(if (index == 0) 120 else 80), dp(12)).apply { + if (index == 0) + bottomMargin = + if (style.has("lineGap")) stylePx(style.optDouble("lineGap")) else dp(8) + }, + ) + } + if (variant == "noMatch") setPadding(dp(12), 0, dp(12), 0) + if (variant == "retry") setPadding(dp(12), dp(8), dp(12), dp(8)) + addView( + column, + if (variant in setOf("retry", "noMatch")) LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) + else wrap(), + ) + if (variant == "retry") { + text( + action, + data.optString("actionText", "Retry"), + "actionText", + 14f, + "medium", + 20, + 1, + "end", + primary, + ) + action.background = fill(color(theme, "strongBackground", "#0000000F"), 16) + action.setPadding(dp(10), dp(4), dp(10), dp(4)) + addView(action, wrap().apply { marginStart = dp(12) }) + } + } + } + action.setOnClickListener { + emitAction(data.optString("actionKey"), action, "trailingAccessory", 0) + } + if (style.has("horizontalPadding")) { + val padding = stylePx(style.optDouble("horizontalPadding")) + setPadding(padding, paddingTop, padding, paddingBottom) + } + if (style.has("verticalPadding")) { + val padding = stylePx(style.optDouble("verticalPadding")) + setPadding(paddingLeft, padding, paddingRight, padding) + } + style + .optJSONObject("container") + ?.optString("contentVerticalAlignment") + ?.takeIf { it.isNotEmpty() } + ?.let { alignment -> + gravity = + (gravity and Gravity.VERTICAL_GRAVITY_MASK.inv()) or + when (alignment) { + "top" -> Gravity.TOP + "bottom" -> Gravity.BOTTOM + else -> Gravity.CENTER_VERTICAL + } + } + } + + override fun recycleContent() { + removeAllViews() + column.removeAllViews() + action.background = null + action.setPadding(0, 0, 0, 0) + action.setOnClickListener(null) + } + + override fun disposeContent() = recycleContent() +} + +// Market/TokenListSkeleton: source geometry and the native Skeleton's 3s shimmer. +private class NativeListMarketSkeleton(context: android.content.Context, backgroundColor: Int) : + View(context) { + private val marks = Array(5) { RectF() } + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val matrix = Matrix() + private val shaders = arrayOfNulls(5) + private val dark = + Color.red(backgroundColor) * 0.299 + + Color.green(backgroundColor) * 0.587 + + Color.blue(backgroundColor) * 0.114 < 128 + private val colors = + intArrayOf( + Color.parseColor(if (dark) "#111111" else "#FAFAFA"), + Color.parseColor(if (dark) "#333333" else "#CDCDCD"), + Color.parseColor(if (dark) "#111111" else "#FAFAFA"), + ) + private var phase = 0f + private val animator = + ValueAnimator.ofFloat(0f, 1f).apply { + duration = 3000 + repeatCount = ValueAnimator.INFINITE + interpolator = LinearInterpolator() + addUpdateListener { + phase = it.animatedValue as Float + invalidate() + } + } + + private fun dp(value: Float) = NativeListScale.dp(resources, value) + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + marks[0].set(0f, 0f, dp(32f), dp(32f)) + marks[1].set(dp(44f), 0f, dp(124f), dp(16f)) + marks[2].set(dp(44f), dp(20f), dp(104f), dp(32f)) + marks[3].set(w - dp(168f), dp(7f), w - dp(88f), dp(25f)) + marks[4].set(w - dp(80f), dp(7f), w.toFloat(), dp(25f)) + marks.forEachIndexed { index, mark -> + shaders[index] = + LinearGradient( + 0f, + 0f, + mark.width(), + 0f, + colors, + floatArrayOf(0f, 0.5f, 1f), + Shader.TileMode.CLAMP, + ) + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + marks.forEachIndexed { index, mark -> + matrix.setTranslate(mark.left - mark.width() + phase * mark.width() * 3f, 0f) + shaders[index]?.setLocalMatrix(matrix) + paint.shader = shaders[index] + val radius = dp(if (index == 0) 16f else 8f) + canvas.drawRoundRect(mark, radius, radius, paint) + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (windowVisibility == VISIBLE) animator.start() + } + + override fun onDetachedFromWindow() { + animator.cancel() + super.onDetachedFromWindow() + } + + override fun onWindowVisibilityChanged(visibility: Int) { + super.onWindowVisibilityChanged(visibility) + if (visibility == VISIBLE && isAttachedToWindow) { + if (!animator.isStarted) animator.start() + } else animator.cancel() + } +} diff --git a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt index 39a09a6be..ff9a7bccd 100644 --- a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt +++ b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt @@ -36,8 +36,8 @@ class NativeListStyleSlotTest { Case(type = "system", field = "title", variant = "retry", expected = null), Case(type = "sectionHeader", field = "value", expected = "value"), // Only the warning variant renders a separate title. - Case(type = "system", field = "message", variant = "warning", expected = "subtitle"), - Case(type = "system", field = "message", variant = "noMatch", expected = "title"), + Case(type = "system", field = "message", variant = "warning", expected = null), + Case(type = "system", field = "message", variant = "noMatch", expected = null), // A field the template does not render is ignored, never remapped onto // whichever view happens to be free. Case(type = "identity", field = "price", expected = null), diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index 164adbd36..ed3afa4ac 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -338,3 +338,20 @@ Build/type/unit checks pass; 155 package tests include style restoration, stable Action title/icon nodes, selection echoes and incompatible-family reuse. An Android input timeout also affected Launcher; its captured main stack was in the display event loop. After restarting the task emulator, menu, checkbox, scroll, repopulate and footer checks passed (action counter 3). This does not establish a library-level ANR cause. Remaining stage 3 templates: System, Activity, DataRow and MetricCard. + +### Stage 3: System acceptance (2026-09-23) + +System owns loading, retry, warning, noMatch, end and spacer rendering and +warning measurement on all three platforms. Its skeleton views and legacy +binders are removed from the universal host. Native source pixel rounding, +warning wrapping and existing per-platform default geometries remain intact. +The shared footer factory can reuse System and Action through the same registry. + +The dedicated iOS and Android runtimes exercised default retry, style set/clear, +warning, skeleton, spinner, Market retry/noMatch/end, spacer, retry action, +empty/repopulate and end/top scrolling. Default retry captures match the old +geometry. Headed Chrome exercised the same variant cycle and retry action; +regression tests cover style clearing, retained warning nodes and variant reuse. +Evidence: external runtime `ios-system-*`, `android-system-*`, `web-system-*`. +iOS/Android Debug builds, Android unit tests and 156 package tests pass. +Remaining stage 3 templates: Activity, DataRow and MetricCard. diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index 4b73db980..e8abf317f 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -78,7 +78,7 @@ separate workload and measurements. | --- | --- | --- | | 1. Baseline | Complete: [implicit keys, defaults, special updates and acceptance baseline](MIGRATION_BASELINE.md) inventoried | Preserve each existing rule or explicitly migrate its caller | | 2. Message pilot | Complete: registered lightweight native hosts, persistent Web body, resolved styling/measurement and lifecycle acceptance | Lightweight native hosts, resolved styling/measurement, update classification and lifecycle acceptance | -| 3. Simple templates | In progress (3/7): Rail, MediaTile and Action migrated on all three platforms; four templates remain | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | +| 3. Simple templates | In progress (4/7): Rail, MediaTile, Action and System migrated on all three platforms; three templates remain | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | | 4. Complex templates | Not started | Migrate Market, Identity and SectionHeader while preserving quote, selection and header behavior | | 5. WalletGroup | Not started | Independent member renderers; verify member events/styles, compact dragging and height restoration | | 6. Cleanup | Not started | Remove migrated key inference and global style maps; container no longer manipulates template internals | diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index ad5ef47e9..f9c1c392d 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -5,73 +5,6 @@ import CryptoKit import OneKeyImage import UIKit -// Market/TokenListSkeleton: source geometry and the native Skeleton's 3s shimmer. -private final class NativeListMarketSkeleton: UIView { - private let marks = (0..<5).map { _ in UIView() } - private let gradients = (0..<5).map { _ in CAGradientLayer() } - - init(background: UIColor) { - super.init(frame: .zero) - var white: CGFloat = 1 - background.getWhite(&white, alpha: nil) - let base = UIColor(nativeListHex: white < 0.5 ? "#111111" : "#FAFAFA", fallback: .white) - let highlight = UIColor(nativeListHex: white < 0.5 ? "#333333" : "#CDCDCD", fallback: .lightGray) - for (index, mark) in marks.enumerated() { - mark.backgroundColor = base - mark.clipsToBounds = true - mark.layer.cornerRadius = index == 0 ? 16 : 8 - let gradient = gradients[index] - gradient.cornerRadius = mark.layer.cornerRadius - gradient.colors = [base.cgColor, highlight.cgColor, base.cgColor] - gradient.locations = [0, 0.5, 1] - gradient.startPoint = CGPoint(x: 0, y: 0.5) - gradient.endPoint = CGPoint(x: 1, y: 0.5) - mark.layer.addSublayer(gradient) - addSubview(mark) - } - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - override func layoutSubviews() { - super.layoutSubviews() - let frames = [ - CGRect(x: 0, y: 0, width: 32, height: 32), - CGRect(x: 44, y: 0, width: 80, height: 16), - CGRect(x: 44, y: 20, width: 60, height: 12), - CGRect(x: bounds.width - 168, y: 7, width: 80, height: 18), - CGRect(x: bounds.width - 80, y: 7, width: 80, height: 18), - ] - CATransaction.begin() - CATransaction.setDisableActions(true) - for (index, frame) in frames.enumerated() { - marks[index].frame = frame - gradients[index].frame = marks[index].bounds - } - CATransaction.commit() - updateAnimation() - } - - override func didMoveToWindow() { - super.didMoveToWindow() - updateAnimation() - } - - private func updateAnimation() { - for gradient in gradients { - guard window != nil else { gradient.removeAllAnimations(); continue } - guard gradient.bounds.width > 0, gradient.animation(forKey: "shimmer") == nil else { continue } - let animation = CABasicAnimation(keyPath: "transform.translation.x") - animation.fromValue = -gradient.bounds.width - animation.toValue = gradient.bounds.width - animation.duration = 3 - animation.repeatCount = .infinity - animation.timingFunction = CAMediaTimingFunction(name: .linear) - gradient.add(animation, forKey: "shimmer") - } - } -} - final class NativeListActionOrigin { weak var sourceView: UIView? weak var ownerCell: NativeListRowHost? @@ -483,8 +416,6 @@ final class NativeListCell: NativeListRowHost { private let spinner = UIActivityIndicatorView(style: .medium) private let tableDataStack = UIStackView() private let tableDataColumns = (0..<4).map { _ in NativeListTableColumnView() } - private let skeletonPrimary = UIView() - private let skeletonSecondary = UIView() private var walletGroupCells: [NativeListCell] = [] private var walletGroupMembers: [NativeListItem] = [] private let walletGroupCompactContainer = UIView() @@ -788,23 +719,7 @@ final class NativeListCell: NativeListRowHost { override func layoutSubviews() { super.layoutSubviews() - if let item = currentItem, item.type == "system", item.data.string("presentation") == "market", - ["noMatch", "retry"].contains(item.data.string("variant")), !titleLabel.isHidden { - titleLabel.transform = .identity - // Settle each nested stack before measuring; the content view alone can leave stale descendant frames. - contentView.layoutIfNeeded() - rootStack.layoutIfNeeded() - mainStack.layoutIfNeeded() - titleRowStack.layoutIfNeeded() - // React Native floors text origins to physical pixels after centering the line box. - let scale = max(1, window?.screen.scale ?? traitCollection.displayScale) - let origin = titleLabel.convert(titleLabel.bounds, to: contentView).origin - let x = floor((contentView.bounds.width - titleLabel.bounds.width) / 2 * scale) / scale - let contentHeight: CGFloat = item.data.string("variant") == "retry" ? 56 : 24 - let y = item.data.dictionary("style")?.dictionary("container")?["contentVerticalAlignment"] != nil - ? origin.y : floor(max(32, (contentView.bounds.height - contentHeight) / 2) * scale) / scale - titleLabel.transform = CGAffineTransform(translationX: x - origin.x, y: y - origin.y) - } + // OneKey patch: extend only the background across the section list outer inset. if currentItem?.data.bool("backgroundFullWidth") == true { selectorFullWidthBackground.frame = CGRect(x: -frame.minX, y: 0, width: superview?.bounds.width ?? bounds.width, height: bounds.height) @@ -959,7 +874,6 @@ final class NativeListCell: NativeListRowHost { case "market": bindMarket(item, theme: theme) case "metricCard": bindMetricCard(item, theme: theme) case "sectionHeader": bindSectionHeader(item, theme: theme, layout: layout, checkboxState) - case "system": bindSystem(item, theme: theme) default: break } applyRowStyle(item) @@ -1073,7 +987,7 @@ final class NativeListCell: NativeListRowHost { } private func applySelectorTypography(_ item: NativeListItem) { - guard ["accountSelector", "networkSelector", "walletSidebar"].contains(item.data.string("presentation")) || item.type == "system" && item.data.string("variant") == "warning" else { return } + guard ["accountSelector", "networkSelector", "walletSidebar"].contains(item.data.string("presentation")) else { return } func visit(_ view: UIView) { if let button = view as? UIButton { if let original = button.attributedTitle(for: .normal) { @@ -1392,8 +1306,6 @@ final class NativeListCell: NativeListRowHost { $0.reset() $0.isHidden = true } - skeletonPrimary.removeFromSuperview() - skeletonSecondary.removeFromSuperview() accessoryActions = [] footerActionKeys = [] checkboxAction = nil @@ -2169,15 +2081,6 @@ final class NativeListCell: NativeListRowHost { } case "sectionHeader": return ["title", "subtitle", "value"].contains(field) ? field : nil - case "system": - switch field { - case "title": return variant == "warning" ? "title" : nil - // Only the warning variant renders a separate title; every other variant - // puts its message in the title view. - case "message": return variant == "warning" ? "subtitle" : "title" - case "actionText": return "value" - default: return nil - } default: return nil } @@ -3367,190 +3270,6 @@ final class NativeListCell: NativeListRowHost { } } - private func bindSystem(_ item: NativeListItem, theme: [String: Any]?) { - rootStack.alignment = .center - rootStack.distribution = .fill - let variant = item.data.string("variant") - let isMarket = item.data.string("presentation") == "market" - if isMarket { - rootLeadingConstraint.constant = 20 - rootTrailingConstraint.constant = -20 - rootTopConstraint.constant = 12 - rootBottomConstraint.constant = -12 - } - if isMarket && variant == "retry" { - let message = item.data.string("message") - let text = item.data.string("actionText", default: "Retry") - rootStack.axis = .vertical - // The source tertiary Button has -5 vertical margins around its 30pt frame. - rootStack.spacing = 7 - rootLeadingConstraint.constant = 32 - rootTrailingConstraint.constant = -32 - let height = CGFloat(item.data.double("height", default: message.isEmpty ? 52 : 120)) - let top = message.isEmpty ? 11 : max(32, (height - 56) / 2) - rootTopConstraint.constant = top - rootBottomConstraint.constant = -(height - top - (message.isEmpty ? 30 : 61)) - if !message.isEmpty { - show(titleLabel, message, lines: 2) - titleLabel.font = nativeListTabularFont(ofSize: 16) - titleLabel.textColor = nativeListColor(theme, "secondaryText", "#646464") - titleLabel.textAlignment = .center - setLineHeight(titleLabel, text: message, lineHeight: 24) - rootStack.addArrangedSubview(mainStack) - } - showAccessory(0, text, action: (item.data.string("actionKey"), nil)) - let button = accessoryButtons[0] - button.backgroundColor = .clear - button.layer.cornerRadius = 15 - setButtonLine(button, text: text, font: nativeListTabularFont(ofSize: 14, weight: .medium), - color: nativeListColor(theme, "secondaryText", "#646464"), lineHeight: 20) - let textWidth = button.intrinsicContentSize.width - selectorConstraints.append(contentsOf: [ - button.widthAnchor.constraint(equalToConstant: textWidth + 18), - button.heightAnchor.constraint(equalToConstant: 30), - ]) - NSLayoutConstraint.activate(selectorConstraints) - rootStack.addArrangedSubview(trailingStack) - return - } - if variant == "loading" && item.data.string("loadingStyle") == "skeleton" { - rootLeadingConstraint.constant = 20 - rootTrailingConstraint.constant = -20 - rootTopConstraint.constant = 12 - rootBottomConstraint.constant = -12 - let skeleton = NativeListMarketSkeleton(background: nativeListColor(theme, "background", "#FFFFFF")) - skeleton.translatesAutoresizingMaskIntoConstraints = false - skeleton.heightAnchor.constraint(equalToConstant: 32).isActive = true - rootStack.addArrangedSubview(skeleton) - selectorViews.append(skeleton) - return - } - if variant == "loading" && item.data.string("loadingStyle") == "spinner" { - rootTopConstraint.constant = 16 - rootBottomConstraint.constant = -16 - rootStack.addArrangedSubview(mainStack) - mainStack.alignment = .center - mainStack.setContentHuggingPriority(.defaultLow, for: .horizontal) - let indicator = UIActivityIndicatorView(style: .medium) - indicator.color = nativeListColor(theme, "icon", "#0000009B") - indicator.startAnimating() - mainStack.addArrangedSubview(indicator) - selectorViews.append(indicator) - return - } - if isMarket && variant == "noMatch" { - let padding = max(32, (CGFloat(item.data.double("height", default: 88)) - 24) / 2) - rootTopConstraint.constant = padding - rootBottomConstraint.constant = -padding - rootStack.addArrangedSubview(mainStack) - mainStack.alignment = .center - mainStack.setContentHuggingPriority(.defaultLow, for: .horizontal) - titleLabel.font = nativeListTabularFont(ofSize: 16) - titleLabel.textColor = nativeListColor(theme, "secondaryText", "#646464") - titleLabel.textAlignment = .center - show(titleLabel, item.data.string("message"), lines: 1) - setLineHeight(titleLabel, text: item.data.string("message"), lineHeight: 24) - return - } - if isMarket && variant == "end" { - rootTopConstraint.constant = 16 - rootBottomConstraint.constant = -16 - // OneKey patch: an empty title stack must not consume the dot's line height. - titleRowStack.isHidden = true - rootStack.addArrangedSubview(mainStack) - mainStack.alignment = .center - mainStack.setContentHuggingPriority(.defaultLow, for: .horizontal) - let indicator = UIStackView() - indicator.axis = .horizontal - indicator.alignment = .center - indicator.spacing = 8 - for width in [CGFloat(80), 4, 80] { - let mark = UIView() - mark.translatesAutoresizingMaskIntoConstraints = false - mark.backgroundColor = nativeListColor(theme, "separator", "#0000001F") - mark.layer.cornerRadius = width == 4 ? 2 : 0 - NSLayoutConstraint.activate([ - mark.widthAnchor.constraint(equalToConstant: width), - mark.heightAnchor.constraint(equalToConstant: width == 4 ? 4 : 1), - ]) - indicator.addArrangedSubview(mark) - } - mainStack.addArrangedSubview(indicator) - selectorViews.append(indicator) - return - } - // OneKey patch: deprecated-wallet warnings stay inside the scrolling list. - if variant == "warning" { - rootStack.addArrangedSubview(mainStack) - rootTopConstraint.constant = 14 - rootBottomConstraint.constant = -14 - mainStack.spacing = 4 - titleLabel.font = nativeListFont(ofSize: 14, weight: .medium) - titleLabel.numberOfLines = 0 - subtitleLabel.font = nativeListFont(ofSize: 14) - subtitleLabel.numberOfLines = 0 - show(titleLabel, item.data.string("title"), lines: 0) - show(subtitleLabel, item.data.string("message"), lines: 0) - setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20) - setLineHeight(subtitleLabel, text: item.data.string("message"), lineHeight: 20) - let borderColor = UIColor(nativeListHex: item.data.string("borderColor", default: "#E0E0E0"), fallback: .lightGray) - for top in [true, false] { - let border = UIView() - border.translatesAutoresizingMaskIntoConstraints = false - border.backgroundColor = borderColor - contentView.addSubview(border) - NSLayoutConstraint.activate([ - border.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: -8), - border.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: 8), - border.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale), - top ? border.topAnchor.constraint(equalTo: contentView.topAnchor) : border.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - ]) - selectorViews.append(border) - } - return - } - if variant == "loading" { - leadingWidth.constant = isMarket ? 32 : 40 - leadingHeight.constant = isMarket ? 32 : 40 - leadingContainer.backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0") - leadingContainer.layer.cornerRadius = isMarket ? 16 : 20 - rootStack.addArrangedSubview(leadingContainer) - configureSkeleton(skeletonPrimary, width: 120, height: 12, theme: theme) - configureSkeleton(skeletonSecondary, width: 80, height: 12, theme: theme) - mainStack.spacing = 8 - mainStack.addArrangedSubview(skeletonPrimary) - mainStack.addArrangedSubview(skeletonSecondary) - } - show( - titleLabel, - item.data.string("message", default: variant == "end" ? "End" : ""), - lines: 2 - ) - let alignsToStart = variant == "retry" || variant == "noMatch" || variant == "end" - titleLabel.textAlignment = alignsToStart ? .natural : .center - mainStack.alignment = variant == "loading" || alignsToStart ? .leading : .center - if alignsToStart { - mainStack.setContentHuggingPriority(.defaultLow, for: .horizontal) - titleLabel.font = nativeListFont(ofSize: 14) - titleLabel.textColor = nativeListColor(theme, "secondaryText", "#646464") - setLineHeight(titleLabel, text: titleLabel.text ?? "", lineHeight: 20) - } - rootStack.addArrangedSubview(mainStack) - if variant == "retry" { - showAccessory(0, "Retry", action: (item.data.string("actionKey"), nil)) - accessoryButtons[0].backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0") - accessoryButtons[0].layer.cornerRadius = 14 - accessoryButtons[0].contentEdgeInsets = UIEdgeInsets(top: 4, left: 10, bottom: 4, right: 10) - setButtonLine( - accessoryButtons[0], - text: "Retry", - font: nativeListFont(ofSize: 14, weight: .medium), - color: nativeListColor(theme, "primaryText", "#202020"), - lineHeight: 20 - ) - rootStack.addArrangedSubview(trailingStack) - } - } private func addLeading( _ visual: [String: Any]?, @@ -4023,7 +3742,7 @@ final class NativeListCell: NativeListRowHost { label === fallbackLabel && currentItem?.type == "identity" && currentItem?.data.string("presentation") == "networkSelector" - if currentItem?.type == "market" || currentItem?.type == "system" && currentItem?.data.string("presentation") == "market" && currentItem?.data.string("variant") == "noMatch" || (currentItem?.data["height"] != nil && (["accountSelector", "walletSidebar"].contains(currentItem?.data.string("presentation") ?? "") || currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector")) || currentItem?.type == "system" && currentItem?.data.string("variant") == "warning" || isNetworkFallback { + if currentItem?.type == "market" || (currentItem?.data["height"] != nil && (["accountSelector", "walletSidebar"].contains(currentItem?.data.string("presentation") ?? "") || currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector")) || isNetworkFallback { // OneKey patch: React Native centers font metrics inside explicit line heights. let baselineOffset = max(0, (lineHeight - label.font.lineHeight) / 2) // OneKey patch: TextKit's 14/20 headings align their baseline to the upper physical pixel. @@ -4046,7 +3765,7 @@ final class NativeListCell: NativeListRowHost { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.minimumLineHeight = lineHeight paragraphStyle.maximumLineHeight = lineHeight - let isMarketText = currentItem?.type == "market" || currentItem?.type == "system" && currentItem?.data.string("presentation") == "market" && currentItem?.data.string("variant") == "retry" + let isMarketText = currentItem?.type == "market" let isSelectorValue = currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil && currentItem?.data.string("variant") != "summary" let isSelectorSummary = currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil && currentItem?.data.string("variant") == "summary" (button as? NativeListAccessoryButton)?.selectorSummaryLineHeight = isSelectorSummary ? lineHeight : nil @@ -4324,22 +4043,6 @@ final class NativeListCell: NativeListRowHost { layer.masksToBounds = true } - private func configureSkeleton( - _ view: UIView, - width: CGFloat, - height: CGFloat, - theme: [String: Any]? - ) { - view.backgroundColor = nativeListColor(theme, "rowPressedBackground", "#E8E8E8") - view.layer.cornerRadius = 4 - view.translatesAutoresizingMaskIntoConstraints = false - if view.constraints.isEmpty { - NSLayoutConstraint.activate([ - view.widthAnchor.constraint(equalToConstant: width), - view.heightAnchor.constraint(equalToConstant: height), - ]) - } - } @objc private func accessoryPressed(_ sender: UIButton) { guard let item = currentItem, accessoryActions.indices.contains(sender.tag) else { return } diff --git a/native-views/react-native-native-list/ios/NativeListModels.swift b/native-views/react-native-native-list/ios/NativeListModels.swift index 38d764516..284bd9c7b 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -4,7 +4,7 @@ import UIKit // Message is the first migrated renderer. Other templates still use the // compatible legacy tree; style, content and row keys do not affect reuse. enum NativeListRendererKey: String, CaseIterable { - case legacy, message, rail, mediaTile, action + case legacy, message, rail, mediaTile, action, system } struct NativeListItem { diff --git a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift index c1d10b605..70f09e265 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift @@ -9,6 +9,7 @@ enum NativeListRendererRegistry { .message: (NativeListMessageCell.self, { NativeListMessageCell(frame: .zero) }), .rail: (NativeListRailCell.self, { NativeListRailCell(frame: .zero) }), .mediaTile: (NativeListMediaTileCell.self, { NativeListMediaTileCell(frame: .zero) }), + .system: (NativeListSystemCell.self, { NativeListSystemCell(frame: .zero) }), .action: (NativeListActionCell.self, { NativeListActionCell(frame: .zero) }), ] static func create(_ key: NativeListRendererKey) -> NativeListRowHost { hosts[key]!.create() } @@ -23,9 +24,13 @@ enum NativeListRendererRegistry { collectionView.register(host.type, forCellWithReuseIdentifier: reuseIdentifier(for: key)) } } + static func appliesSizePreset(_ item: NativeListItem) -> Bool { + item.rendererKey != .system || NativeListSystemRenderer.appliesSizePreset(item) + } static func measure(_ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String) -> CGFloat? { + if item.rendererKey == .system { return NativeListSystemRenderer.measure(item, width: width) } if item.rendererKey == .action { return item.data.string("presentation") == "accountSelector" ? 48 : item.data.dictionary("icon") == nil ? 44 : 60 diff --git a/native-views/react-native-native-list/ios/NativeListSystemRenderer.swift b/native-views/react-native-native-list/ios/NativeListSystemRenderer.swift new file mode 100644 index 000000000..0cbcc7d33 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListSystemRenderer.swift @@ -0,0 +1,553 @@ +import UIKit + +final class NativeListSystemCell: NativeListRendererCell { + private let mainStack = UIStackView() + private let titleRowStack = UIStackView() + private let titleLabel = NativeListTextLabel() + private let subtitleLabel = NativeListTextLabel() + private let trailingStack = UIStackView() + private let accessoryButtons = [NativeListAccessoryButton(type: .system)] + private let leadingContainer = UIView() + private let skeletonPrimary = UIView() + private let skeletonSecondary = UIView() + private var leadingWidth: NSLayoutConstraint! + private var leadingHeight: NSLayoutConstraint! + private var selectorViews: [UIView] = [] + private var selectorConstraints: [NSLayoutConstraint] = [] + private var currentItem: NativeListItem? + private var actionKey = "" + private var leftInset: CGFloat = 12 + private var rightInset: CGFloat = -12 + private var topInset: CGFloat = 8 + private var bottomInset: CGFloat = -8 + + override init(frame: CGRect) { + super.init(frame: frame) + mainStack.axis = .vertical + mainStack.alignment = .fill + mainStack.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal) + titleRowStack.axis = .horizontal + titleRowStack.alignment = .center + titleRowStack.addArrangedSubview(titleLabel) + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + mainStack.addArrangedSubview(titleRowStack) + mainStack.addArrangedSubview(subtitleLabel) + trailingStack.axis = .vertical + trailingStack.alignment = .trailing + trailingStack.addArrangedSubview(accessoryButtons[0]) + trailingStack.setContentHuggingPriority(.required, for: .horizontal) + trailingStack.setContentCompressionResistancePriority(.required, for: .horizontal) + accessoryButtons[0].addTarget(self, action: #selector(actionPressed), for: .touchUpInside) + leadingContainer.translatesAutoresizingMaskIntoConstraints = false + leadingWidth = leadingContainer.widthAnchor.constraint(equalToConstant: 40) + leadingHeight = leadingContainer.heightAnchor.constraint(equalToConstant: 40) + NSLayoutConstraint.activate([leadingWidth, leadingHeight]) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func bindContent( + _ item: NativeListItem, theme: [String: Any]?, layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + recycleContent() + currentItem = item + root.axis = .horizontal + root.alignment = .center + root.spacing = 12 + leftInset = layout == "table" ? 16 : 12 + rightInset = -leftInset + topInset = 8 + bottomInset = -8 + mainStack.alignment = .fill + mainStack.spacing = 2 + titleRowStack.isHidden = false + for label in [titleLabel, subtitleLabel] { + label.attributedText = nil + label.text = nil + label.isHidden = true + label.font = nativeListFont( + ofSize: label === titleLabel ? 16 : 14, weight: label === titleLabel ? .medium : .regular) + label.textColor = nativeListColor( + theme, label === titleLabel ? "primaryText" : "secondaryText", + label === titleLabel ? "#202020" : "#646464") + label.numberOfLines = 1 + label.textAlignment = .natural + label.lineBreakMode = .byTruncatingTail + label.rowVerticalAlignment = nil + label.rowOffsetY = 0 + label.transform = .identity + } + let button = accessoryButtons[0] + button.setAttributedTitle(nil, for: .normal) + button.setTitle(nil, for: .normal) + button.isHidden = true + button.titleLabel?.font = nativeListFont(ofSize: 16, weight: .medium) + button.titleLabel?.numberOfLines = 1 + button.contentEdgeInsets = .zero + button.contentHorizontalAlignment = .center + button.contentVerticalAlignment = .center + button.rowTextOffsetY = 0 + button.marketLineHeight = nil + button.layer.cornerRadius = 0 + button.backgroundColor = .clear + bindSystem(item, theme: theme) + let style = item.data.dictionary("style") ?? [:] + if style["horizontalPadding"] != nil { + leftInset = CGFloat(style.double("horizontalPadding")) + rightInset = -leftInset + } + if style["verticalPadding"] != nil { + topInset = CGFloat(style.double("verticalPadding")) + bottomInset = -topInset + } + if style["lineGap"] != nil { mainStack.spacing = CGFloat(style.double("lineGap")) } + if style["leadingGap"] != nil && root.arrangedSubviews.contains(leadingContainer) { + root.setCustomSpacing(CGFloat(style.double("leadingGap")), after: leadingContainer) + } + if style["trailingGap"] != nil { trailingStack.spacing = CGFloat(style.double("trailingGap")) } + let warning = item.data.string("variant") == "warning" + if warning, let text = style.dictionary("title") { + NativeListTextStyles.applyStyledText(titleLabel, text) + } + if let text = style.dictionary("message") { + NativeListTextStyles.applyStyledText(warning ? subtitleLabel : titleLabel, text) + } + if let text = style.dictionary("actionText") { + NativeListTextStyles.applyStyledButton(button, text) + } + contentInsets = UIEdgeInsets( + top: topInset, left: leftInset, bottom: -bottomInset, right: -rightInset) + if root.axis == .horizontal, + let alignment = style.dictionary("container")?["contentVerticalAlignment"] as? String + { + root.alignment = alignment == "top" ? .top : alignment == "bottom" ? .bottom : .center + } + } + override func recycleContent() { + NSLayoutConstraint.deactivate(selectorConstraints) + selectorConstraints.removeAll() + selectorViews.forEach { view in + if let stack = view.superview as? UIStackView { stack.removeArrangedSubview(view) } + view.removeFromSuperview() + } + selectorViews.removeAll() + for view in root.arrangedSubviews { + root.removeArrangedSubview(view) + view.removeFromSuperview() + } + for view in [skeletonPrimary, skeletonSecondary] { + mainStack.removeArrangedSubview(view) + view.removeFromSuperview() + } + currentItem = nil + actionKey = "" + } + @objc private func actionPressed() { + emitAction(actionKey, from: accessoryButtons[0], source: "trailingAccessory", slot: 0) + } + private func showAccessory( + _ index: Int, _ text: String, action: (String, NativeSelectionTarget?)? + ) { + accessoryButtons[0].setTitle(text, for: .normal) + accessoryButtons[0].isHidden = text.isEmpty + actionKey = action?.0 ?? "" + accessoryButtons[0].setContentHuggingPriority(.required, for: .horizontal) + accessoryButtons[0].setContentCompressionResistancePriority(.required, for: .horizontal) + } + private func show(_ label: UILabel, _ text: String, lines: Int) { + label.text = text + label.numberOfLines = lines + label.isHidden = text.isEmpty + } + private func setLineHeight(_ label: UILabel, text: String, lineHeight: CGFloat) { + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = lineHeight + paragraph.maximumLineHeight = lineHeight + paragraph.alignment = label.textAlignment + var attrs: [NSAttributedString.Key: Any] = [ + .font: label.font as Any, .foregroundColor: label.textColor as Any, + .paragraphStyle: paragraph, + ] + if currentItem?.data.string("variant") == "warning" + || currentItem?.data.string("presentation") == "market" + && currentItem?.data.string("variant") == "noMatch" + { + attrs[.baselineOffset] = max(0, (lineHeight - label.font.lineHeight) / 2) + } + if currentItem?.data.string("presentation") == "market" { attrs[.kern] = 0 } + label.attributedText = NSAttributedString(string: text, attributes: attrs) + } + private func setButtonLine( + _ button: UIButton, text: String, font: UIFont, color: UIColor, lineHeight: CGFloat + ) { + let market = currentItem?.data.string("presentation") == "market" + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = lineHeight + paragraph.maximumLineHeight = lineHeight + paragraph.alignment = market ? .natural : .center + let scale = max(1, traitCollection.displayScale) + let offset = market ? ceil(max(0, (lineHeight - font.lineHeight) / 2) * scale) / scale : 0 + (button as? NativeListAccessoryButton)?.marketLineHeight = market ? lineHeight : nil + button.setAttributedTitle( + NSAttributedString( + string: text, + attributes: [ + .font: font, .foregroundColor: color, .paragraphStyle: paragraph, .baselineOffset: offset, + ]), for: .normal) + } + private func bindSystem(_ item: NativeListItem, theme: [String: Any]?) { + root.alignment = .center + root.distribution = .fill + let variant = item.data.string("variant") + let isMarket = item.data.string("presentation") == "market" + if isMarket { + leftInset = 20 + rightInset = -20 + topInset = 12 + bottomInset = -12 + } + if isMarket && variant == "retry" { + let message = item.data.string("message") + let text = item.data.string("actionText", default: "Retry") + root.axis = .vertical + // The source tertiary Button has -5 vertical margins around its 30pt frame. + root.spacing = 7 + leftInset = 32 + rightInset = -32 + let height = CGFloat(item.data.double("height", default: message.isEmpty ? 52 : 120)) + let top = message.isEmpty ? 11 : max(32, (height - 56) / 2) + topInset = top + bottomInset = -(height - top - (message.isEmpty ? 30 : 61)) + if !message.isEmpty { + show(titleLabel, message, lines: 2) + titleLabel.font = nativeListTabularFont(ofSize: 16) + titleLabel.textColor = nativeListColor(theme, "secondaryText", "#646464") + titleLabel.textAlignment = .center + setLineHeight(titleLabel, text: message, lineHeight: 24) + root.addArrangedSubview(mainStack) + } + showAccessory(0, text, action: (item.data.string("actionKey"), nil)) + let button = accessoryButtons[0] + button.backgroundColor = .clear + button.layer.cornerRadius = 15 + setButtonLine( + button, text: text, font: nativeListTabularFont(ofSize: 14, weight: .medium), + color: nativeListColor(theme, "secondaryText", "#646464"), lineHeight: 20) + let textWidth = button.intrinsicContentSize.width + selectorConstraints.append(contentsOf: [ + button.widthAnchor.constraint(equalToConstant: textWidth + 18), + button.heightAnchor.constraint(equalToConstant: 30), + ]) + NSLayoutConstraint.activate(selectorConstraints) + root.addArrangedSubview(trailingStack) + return + } + if variant == "loading" && item.data.string("loadingStyle") == "skeleton" { + leftInset = 20 + rightInset = -20 + topInset = 12 + bottomInset = -12 + let skeleton = NativeListMarketSkeleton( + background: nativeListColor(theme, "background", "#FFFFFF")) + skeleton.translatesAutoresizingMaskIntoConstraints = false + skeleton.heightAnchor.constraint(equalToConstant: 32).isActive = true + root.addArrangedSubview(skeleton) + selectorViews.append(skeleton) + return + } + if variant == "loading" && item.data.string("loadingStyle") == "spinner" { + topInset = 16 + bottomInset = -16 + root.addArrangedSubview(mainStack) + mainStack.alignment = .center + mainStack.setContentHuggingPriority(.defaultLow, for: .horizontal) + let indicator = UIActivityIndicatorView(style: .medium) + indicator.color = nativeListColor(theme, "icon", "#0000009B") + indicator.startAnimating() + mainStack.addArrangedSubview(indicator) + selectorViews.append(indicator) + return + } + if isMarket && variant == "noMatch" { + let padding = max(32, (CGFloat(item.data.double("height", default: 88)) - 24) / 2) + topInset = padding + bottomInset = -padding + root.addArrangedSubview(mainStack) + mainStack.alignment = .center + mainStack.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleLabel.font = nativeListTabularFont(ofSize: 16) + titleLabel.textColor = nativeListColor(theme, "secondaryText", "#646464") + titleLabel.textAlignment = .center + show(titleLabel, item.data.string("message"), lines: 1) + setLineHeight(titleLabel, text: item.data.string("message"), lineHeight: 24) + return + } + if isMarket && variant == "end" { + topInset = 16 + bottomInset = -16 + // OneKey patch: an empty title stack must not consume the dot's line height. + titleRowStack.isHidden = true + root.addArrangedSubview(mainStack) + mainStack.alignment = .center + mainStack.setContentHuggingPriority(.defaultLow, for: .horizontal) + let indicator = UIStackView() + indicator.axis = .horizontal + indicator.alignment = .center + indicator.spacing = 8 + for width in [CGFloat(80), 4, 80] { + let mark = UIView() + mark.translatesAutoresizingMaskIntoConstraints = false + mark.backgroundColor = nativeListColor(theme, "separator", "#0000001F") + mark.layer.cornerRadius = width == 4 ? 2 : 0 + NSLayoutConstraint.activate([ + mark.widthAnchor.constraint(equalToConstant: width), + mark.heightAnchor.constraint(equalToConstant: width == 4 ? 4 : 1), + ]) + indicator.addArrangedSubview(mark) + } + mainStack.addArrangedSubview(indicator) + selectorViews.append(indicator) + return + } + // OneKey patch: deprecated-wallet warnings stay inside the scrolling list. + if variant == "warning" { + root.addArrangedSubview(mainStack) + topInset = 14 + bottomInset = -14 + mainStack.spacing = 4 + titleLabel.font = nativeListFont(ofSize: 14, weight: .medium) + titleLabel.numberOfLines = 0 + subtitleLabel.font = nativeListFont(ofSize: 14) + subtitleLabel.numberOfLines = 0 + show(titleLabel, item.data.string("title"), lines: 0) + show(subtitleLabel, item.data.string("message"), lines: 0) + setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20) + setLineHeight(subtitleLabel, text: item.data.string("message"), lineHeight: 20) + let borderColor = UIColor( + nativeListHex: item.data.string("borderColor", default: "#E0E0E0"), fallback: .lightGray) + for top in [true, false] { + let border = UIView() + border.translatesAutoresizingMaskIntoConstraints = false + border.backgroundColor = borderColor + contentView.addSubview(border) + NSLayoutConstraint.activate([ + border.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: -8), + border.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: 8), + border.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale), + top + ? border.topAnchor.constraint(equalTo: contentView.topAnchor) + : border.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + selectorViews.append(border) + } + return + } + if variant == "loading" { + leadingWidth.constant = isMarket ? 32 : 40 + leadingHeight.constant = isMarket ? 32 : 40 + leadingContainer.backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0") + leadingContainer.layer.cornerRadius = isMarket ? 16 : 20 + root.addArrangedSubview(leadingContainer) + configureSkeleton(skeletonPrimary, width: 120, height: 12, theme: theme) + configureSkeleton(skeletonSecondary, width: 80, height: 12, theme: theme) + mainStack.spacing = 8 + mainStack.addArrangedSubview(skeletonPrimary) + mainStack.addArrangedSubview(skeletonSecondary) + } + show( + titleLabel, + item.data.string("message", default: variant == "end" ? "End" : ""), + lines: 2 + ) + let alignsToStart = variant == "retry" || variant == "noMatch" || variant == "end" + titleLabel.textAlignment = alignsToStart ? .natural : .center + mainStack.alignment = variant == "loading" || alignsToStart ? .leading : .center + if alignsToStart { + mainStack.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleLabel.font = nativeListFont(ofSize: 14) + titleLabel.textColor = nativeListColor(theme, "secondaryText", "#646464") + setLineHeight(titleLabel, text: titleLabel.text ?? "", lineHeight: 20) + } + root.addArrangedSubview(mainStack) + if variant == "retry" { + showAccessory(0, "Retry", action: (item.data.string("actionKey"), nil)) + accessoryButtons[0].backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0") + accessoryButtons[0].layer.cornerRadius = 14 + accessoryButtons[0].contentEdgeInsets = UIEdgeInsets(top: 4, left: 10, bottom: 4, right: 10) + setButtonLine( + accessoryButtons[0], + text: "Retry", + font: nativeListFont(ofSize: 14, weight: .medium), + color: nativeListColor(theme, "primaryText", "#202020"), + lineHeight: 20 + ) + root.addArrangedSubview(trailingStack) + } + } + private func configureSkeleton( + _ view: UIView, + width: CGFloat, + height: CGFloat, + theme: [String: Any]? + ) { + view.backgroundColor = nativeListColor(theme, "rowPressedBackground", "#E8E8E8") + view.layer.cornerRadius = 4 + view.translatesAutoresizingMaskIntoConstraints = false + if view.constraints.isEmpty { + NSLayoutConstraint.activate([ + view.widthAnchor.constraint(equalToConstant: width), + view.heightAnchor.constraint(equalToConstant: height), + ]) + } + } + override func layoutSubviews() { + super.layoutSubviews() + if let item = currentItem, item.type == "system", item.data.string("presentation") == "market", + ["noMatch", "retry"].contains(item.data.string("variant")), !titleLabel.isHidden + { + titleLabel.transform = .identity + // Settle each nested stack before measuring; the content view alone can leave stale descendant frames. + contentView.layoutIfNeeded() + root.layoutIfNeeded() + mainStack.layoutIfNeeded() + titleRowStack.layoutIfNeeded() + // React Native floors text origins to physical pixels after centering the line box. + let scale = max(1, window?.screen.scale ?? traitCollection.displayScale) + let origin = titleLabel.convert(titleLabel.bounds, to: contentView).origin + let x = floor((contentView.bounds.width - titleLabel.bounds.width) / 2 * scale) / scale + let contentHeight: CGFloat = item.data.string("variant") == "retry" ? 56 : 24 + let y = + item.data.dictionary("style")?.dictionary("container")?["contentVerticalAlignment"] != nil + ? origin.y : floor(max(32, (contentView.bounds.height - contentHeight) / 2) * scale) / scale + titleLabel.transform = CGAffineTransform(translationX: x - origin.x, y: y - origin.y) + } + } +} + +// Market/TokenListSkeleton: source geometry and the native Skeleton's 3s shimmer. +private final class NativeListMarketSkeleton: UIView { + private let marks = (0..<5).map { _ in UIView() } + private let gradients = (0..<5).map { _ in CAGradientLayer() } + + init(background: UIColor) { + super.init(frame: .zero) + var white: CGFloat = 1 + background.getWhite(&white, alpha: nil) + let base = UIColor(nativeListHex: white < 0.5 ? "#111111" : "#FAFAFA", fallback: .white) + let highlight = UIColor( + nativeListHex: white < 0.5 ? "#333333" : "#CDCDCD", fallback: .lightGray) + for (index, mark) in marks.enumerated() { + mark.backgroundColor = base + mark.clipsToBounds = true + mark.layer.cornerRadius = index == 0 ? 16 : 8 + let gradient = gradients[index] + gradient.cornerRadius = mark.layer.cornerRadius + gradient.colors = [base.cgColor, highlight.cgColor, base.cgColor] + gradient.locations = [0, 0.5, 1] + gradient.startPoint = CGPoint(x: 0, y: 0.5) + gradient.endPoint = CGPoint(x: 1, y: 0.5) + mark.layer.addSublayer(gradient) + addSubview(mark) + } + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func layoutSubviews() { + super.layoutSubviews() + let frames = [ + CGRect(x: 0, y: 0, width: 32, height: 32), + CGRect(x: 44, y: 0, width: 80, height: 16), + CGRect(x: 44, y: 20, width: 60, height: 12), + CGRect(x: bounds.width - 168, y: 7, width: 80, height: 18), + CGRect(x: bounds.width - 80, y: 7, width: 80, height: 18), + ] + CATransaction.begin() + CATransaction.setDisableActions(true) + for (index, frame) in frames.enumerated() { + marks[index].frame = frame + gradients[index].frame = marks[index].bounds + } + CATransaction.commit() + updateAnimation() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + updateAnimation() + } + + private func updateAnimation() { + for gradient in gradients { + guard window != nil else { + gradient.removeAllAnimations() + continue + } + guard gradient.bounds.width > 0, gradient.animation(forKey: "shimmer") == nil else { + continue + } + let animation = CABasicAnimation(keyPath: "transform.translation.x") + animation.fromValue = -gradient.bounds.width + animation.toValue = gradient.bounds.width + animation.duration = 3 + animation.repeatCount = .infinity + animation.timingFunction = CAMediaTimingFunction(name: .linear) + gradient.add(animation, forKey: "shimmer") + } + } +} + +enum NativeListSystemRenderer { + static func appliesSizePreset(_ item: NativeListItem) -> Bool { !["warning", "spacer"].contains(item.data.string("variant")) } + static func measure(_ item: NativeListItem, width: CGFloat) -> CGFloat { + if item.type == "system", item.data.string("variant") == "spacer" { + return CGFloat(item.data.int("height")) + } + // OneKey patch: warning height follows the current native font and available width. + if item.type == "system", item.data.string("variant") == "warning" { + let style = item.data.dictionary("style") + let textWidth = max( + 1, width - CGFloat(style?.double("horizontalPadding", default: 12) ?? 12) * 2) + func textHeight(_ key: String, weight: NativeListFontWeight) -> CGFloat { + let textStyle = style?.dictionary(key) + let lineHeight = CGFloat(textStyle?.double("lineHeight", default: 20) ?? 20) + let fontSize = CGFloat(textStyle?.double("fontSize", default: 14) ?? 14) + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = lineHeight + paragraph.maximumLineHeight = lineHeight + let measured = ceil( + (item.data.string(key) as NSString).boundingRect( + with: CGSize(width: textWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [ + .font: nativeListFont( + ofSize: fontSize, + weight: NativeListTextStyles.marketFontWeight( + textStyle?.string("fontWeight") ?? "", fallback: weight)), + .paragraphStyle: paragraph, + ], context: nil + ).height / lineHeight) + return min(CGFloat(textStyle?.int("lines", default: Int.max) ?? Int.max), max(1, measured)) + * lineHeight + } + return CGFloat(style?.double("verticalPadding", default: 14) ?? 14) * 2 + + CGFloat(style?.double("lineGap", default: 4) ?? 4) + textHeight("title", weight: .medium) + + textHeight("message", weight: .regular) + } + if item.data.string("variant") == "loading" && item.data.string("loadingStyle") == "skeleton" { + return 56 + } else if item.data.string("variant") == "loading" + && item.data.string("loadingStyle") == "spinner" + { + return 52 + } else if item.data.string("presentation") == "market" { + return item.data.string("variant") == "loading" ? 68 : 44 + } else { + switch item.data.string("variant") { + case "noMatch", "end": return 36 + case "retry": return 44 + default: return 56 + } + } + } +} diff --git a/native-views/react-native-native-list/ios/NativeListTextStyles.swift b/native-views/react-native-native-list/ios/NativeListTextStyles.swift new file mode 100644 index 000000000..4c8688ad6 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListTextStyles.swift @@ -0,0 +1,208 @@ +import UIKit + +// Apply semantic overrides after a renderer has restored its resolved defaults. +enum NativeListTextStyles { + static func applyStyledText(_ label: UILabel, _ style: [String: Any]) { + let text = label.attributedText?.string ?? label.text ?? "" + guard !text.isEmpty, !style.isEmpty else { return } + let originalText = label.attributedText + let textLabel = label as? NativeListTextLabel + let baseFont = + (originalText?.attribute(.font, at: 0, effectiveRange: nil) as? UIFont) ?? label.font + ?? nativeListFont(ofSize: 14) + let size = CGFloat(style.double("fontSize", default: Double(baseFont.pointSize))) + let font: UIFont + if let weightName = style["fontWeight"] as? String { + font = nativeListFont( + ofSize: size, + weight: marketFontWeight(weightName, fallback: .regular) + ) + } else { + font = baseFont.withSize(size) + } + let color = + (style["color"] as? String) + .map { UIColor(nativeListHex: $0, fallback: label.textColor ?? .black) } + ?? label.textColor ?? .black + if style["lines"] != nil { + label.numberOfLines = min(3, max(1, style.int("lines", default: 1))) + label.lineBreakMode = .byTruncatingTail + } + if let alignmentName = style["alignment"] as? String { + label.textAlignment = marketTextAlignment( + alignmentName, direction: label.effectiveUserInterfaceLayoutDirection) + } + if style["truncate"] != nil || style["lines"] != nil { + label.lineBreakMode = styledLineBreakMode(style, lines: label.numberOfLines) + } + if let alignment = style["verticalAlignment"] as? String { + textLabel?.rowVerticalAlignment = alignment + } + if style["offsetY"] != nil { textLabel?.rowOffsetY = CGFloat(style.double("offsetY")) } + label.font = font + label.textColor = color + let attributed = + originalText.map { NSMutableAttributedString(attributedString: $0) } + ?? NSMutableAttributedString(string: text) + let paragraph = + (attributed.attribute(.paragraphStyle, at: 0, effectiveRange: nil) + as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() + if style["alignment"] != nil { paragraph.alignment = label.textAlignment } + if style["lines"] != nil || style["truncate"] != nil { + paragraph.lineBreakMode = label.lineBreakMode + } + var attributes: [NSAttributedString.Key: Any] = [:] + applyStyledFont(attributed, style, fallback: baseFont) + if style["color"] != nil { attributes[.foregroundColor] = color } + if style["alignment"] != nil || style["lines"] != nil || style["truncate"] != nil + || style["lineHeight"] != nil + { + attributes[.paragraphStyle] = paragraph + } + if style["lineHeight"] != nil { + let box = CGFloat(style.double("lineHeight")) + paragraph.minimumLineHeight = box + paragraph.maximumLineHeight = box + // React Native centers font metrics inside an explicit line height. + attributes[.baselineOffset] = max(0, (box - font.lineHeight) / 2) + } + attributed.addAttributes(attributes, range: NSRange(location: 0, length: attributed.length)) + if label.numberOfLines == 1 && (style["lines"] != nil || style["truncate"] != nil) { + normalizeSingleLine(attributed) + } + label.attributedText = attributed + } + + static func applyStyledButton(_ button: UIButton, _ style: [String: Any]) { + let text = + button.attributedTitle(for: .normal)?.string + ?? button.title(for: .normal) + ?? "" + guard !text.isEmpty, !style.isEmpty else { return } + let originalText = button.attributedTitle(for: .normal) + let accessory = button as? NativeListAccessoryButton + let baseFont = + (originalText?.attribute(.font, at: 0, effectiveRange: nil) as? UIFont) ?? button.titleLabel? + .font ?? nativeListFont(ofSize: 14) + let size = CGFloat(style.double("fontSize", default: Double(baseFont.pointSize))) + let font: UIFont + if let weightName = style["fontWeight"] as? String { + font = nativeListFont( + ofSize: size, + weight: marketFontWeight(weightName, fallback: .regular) + ) + } else { + font = baseFont.withSize(size) + } + let fallbackColor = button.titleColor(for: .normal) ?? .black + let color = + (style["color"] as? String) + .map { UIColor(nativeListHex: $0, fallback: fallbackColor) } + ?? fallbackColor + let attributed = + button.attributedTitle(for: .normal) + .map { NSMutableAttributedString(attributedString: $0) } + ?? NSMutableAttributedString(string: text) + let paragraph = + (attributed.attribute(.paragraphStyle, at: 0, effectiveRange: nil) + as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() + if let alignmentName = style["alignment"] as? String { + paragraph.alignment = marketTextAlignment( + alignmentName, direction: button.effectiveUserInterfaceLayoutDirection) + button.contentHorizontalAlignment = + alignmentName == "start" + ? .leading + : alignmentName == "end" ? .trailing : .center + } + var attributes: [NSAttributedString.Key: Any] = [:] + applyStyledFont(attributed, style, fallback: baseFont) + if style["color"] != nil { attributes[.foregroundColor] = color } + if style["alignment"] != nil || style["lineHeight"] != nil || style["lines"] != nil + || style["truncate"] != nil + { + attributes[.paragraphStyle] = paragraph + } + if style["lineHeight"] != nil { + let box = CGFloat(style.double("lineHeight")) + paragraph.minimumLineHeight = box + paragraph.maximumLineHeight = box + attributes[.baselineOffset] = max(0, (box - font.lineHeight) / 2) + if accessory?.marketLineHeight != nil { accessory?.marketLineHeight = box } + if accessory?.selectorSummaryLineHeight != nil { accessory?.selectorSummaryLineHeight = box } + } + if style["lines"] != nil { + button.titleLabel?.numberOfLines = min(3, max(1, style.int("lines", default: 1))) + } + if style["lines"] != nil || style["truncate"] != nil { + let mode = styledLineBreakMode(style, lines: button.titleLabel?.numberOfLines ?? 1) + button.titleLabel?.lineBreakMode = mode + paragraph.lineBreakMode = mode + if button.titleLabel?.numberOfLines == 1 { normalizeSingleLine(attributed) } + } + if let alignment = style["verticalAlignment"] as? String { + button.contentVerticalAlignment = + alignment == "top" ? .top : alignment == "bottom" ? .bottom : .center + } + if style["offsetY"] != nil { + (button as? NativeListAccessoryButton)?.rowTextOffsetY = CGFloat(style.double("offsetY")) + } + button.titleLabel?.font = font + attributed.addAttributes(attributes, range: NSRange(location: 0, length: attributed.length)) + button.setAttributedTitle(attributed, for: .normal) + } + + static func applyStyledFont( + _ text: NSMutableAttributedString, _ style: [String: Any], fallback: UIFont + ) { + guard style["fontSize"] != nil || style["fontWeight"] != nil else { return } + text.enumerateAttribute(.font, in: NSRange(location: 0, length: text.length)) { + value, range, _ in + let original = (value as? UIFont) ?? fallback + let size = CGFloat(style.double("fontSize", default: Double(original.pointSize))) + let font = + style["fontWeight"] == nil + ? original.withSize(size) + : nativeListFont( + ofSize: size, weight: marketFontWeight(style.string("fontWeight"), fallback: .regular)) + text.addAttribute(.font, value: font, range: range) + } + } + + static func normalizeSingleLine(_ text: NSMutableAttributedString) { + let pattern = try! NSRegularExpression(pattern: "\\r\\n|[\\r\\n]") + for match in pattern.matches(in: text.string, range: NSRange(location: 0, length: text.length)) + .reversed() + { + text.replaceCharacters(in: match.range, with: " ") + } + } + + static func styledLineBreakMode(_ style: [String: Any], lines: Int) -> NSLineBreakMode { + if style.string("truncate", default: "tail") != "clip" { return .byTruncatingTail } + // Clipping disables wrapping; multi-line clip must wrap without adding an ellipsis. + return lines == 1 ? .byClipping : .byWordWrapping + } + + static func marketFontWeight(_ value: String, fallback: NativeListFontWeight) + -> NativeListFontWeight + { + switch value { + case "regular": return .regular + case "semibold": return .semibold + case "bold": return .bold + case "medium": return .medium + default: return fallback + } + } + + static func marketTextAlignment(_ value: String, direction: UIUserInterfaceLayoutDirection) + -> NSTextAlignment + { + switch value { + case "center": return .center + case "start": return direction == .rightToLeft ? .right : .left + case "end": return direction == .rightToLeft ? .left : .right + default: return .natural + } + } +} diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index b8a8a3535..b78dfdaa9 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -1703,25 +1703,6 @@ final class NativeListView: UIView { if item.type == "walletGroup", usingCompactReorderHeight, item.key == interactiveReorderCompactKey { return 68 } if let height = item.styledHeight { return height } if item.data["height"] != nil { return CGFloat(item.data.double("height")) } - if item.type == "system", item.data.string("variant") == "spacer" { - return CGFloat(item.data.int("height")) - } - // OneKey patch: warning height follows the current native font and available width. - if item.type == "system", item.data.string("variant") == "warning" { - let style = item.data.dictionary("style") - let textWidth = max(1, collectionView.bounds.width - (config?.contentPaddingHorizontal ?? 0) * 2 - CGFloat(style?.double("horizontalPadding", default: 12) ?? 12) * 2) - func textHeight(_ key: String, weight: NativeListFontWeight) -> CGFloat { - let textStyle = style?.dictionary(key) - let lineHeight = CGFloat(textStyle?.double("lineHeight", default: 20) ?? 20) - let fontSize = CGFloat(textStyle?.double("fontSize", default: 14) ?? 14) - let paragraph = NSMutableParagraphStyle() - paragraph.minimumLineHeight = lineHeight - paragraph.maximumLineHeight = lineHeight - let measured = ceil((item.data.string(key) as NSString).boundingRect(with: CGSize(width: textWidth, height: .greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: nativeListFont(ofSize: fontSize, weight: measuredTextWeight(textStyle, fallback: weight)), .paragraphStyle: paragraph], context: nil).height / lineHeight) - return min(CGFloat(textStyle?.int("lines", default: Int.max) ?? Int.max), max(1, measured)) * lineHeight - } - return CGFloat(style?.double("verticalPadding", default: 14) ?? 14) * 2 + CGFloat(style?.double("lineGap", default: 4) ?? 4) + textHeight("title", weight: .medium) + textHeight("message", weight: .regular) - } if item.type == "walletGroup" { if usingCompactReorderHeight, item.key == interactiveReorderCompactKey { return 68 } let childCount = item.data.dictionaries("children").count @@ -1746,7 +1727,7 @@ final class NativeListView: UIView { : availableWidth let rowWidth = allocatedWidth ?? (config?.orientation == "horizontal" ? 280 : columnWidth) if let height = NativeListRendererRegistry.measure(item, width: rowWidth, theme: config?.theme, layout: config?.layout ?? "linear") { - return max(0, height + (item.data.string("size") == "small" ? -8 : item.data.string("size") == "large" ? 12 : 0)) + return max(0, height + (NativeListRendererRegistry.appliesSizePreset(item) ? (item.data.string("size") == "small" ? -8 : item.data.string("size") == "large" ? 12 : 0) : 0)) } let base: CGFloat switch item.type { @@ -1774,20 +1755,6 @@ final class NativeListView: UIView { : item.data.dictionary("checkbox") != nil ? 56 : config?.layout == "linear" ? 30 : 36 - case "system": - if item.data.string("variant") == "loading" && item.data.string("loadingStyle") == "skeleton" { - base = 56 - } else if item.data.string("variant") == "loading" && item.data.string("loadingStyle") == "spinner" { - base = 52 - } else if item.data.string("presentation") == "market" { - base = item.data.string("variant") == "loading" ? 68 : 44 - } else { - switch item.data.string("variant") { - case "noMatch", "end": base = 36 - case "retry": base = 44 - default: base = 56 - } - } case "dataRow": base = item.data.dictionaries("columns").contains { !$0.string("secondaryText").isEmpty @@ -1825,15 +1792,6 @@ final class NativeListView: UIView { return max(0, base + modifier + sectionSpacing + tableAdjustment) } - private func measuredTextWeight(_ style: [String: Any]?, fallback: NativeListFontWeight) -> NativeListFontWeight { - switch style?.string("fontWeight") { - case "regular": return .regular - case "medium": return .medium - case "semibold": return .semibold - case "bold": return .bold - default: return fallback - } - } private func emit(_ block: ((String) -> Void)?, _ value: [String: Any]) { guard JSONSerialization.isValidJSONObject(value), diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index 09099d3f1..154b785e3 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -1197,6 +1197,80 @@ describe('web row style', () => { } }); + it('restores System warning styles and replaces variant content in its own host', () => { + const { document } = new JSDOM('').window; + const host = document.createElement('div'); + document.body.appendChild(host); + const warning = { + type: 'system', + key: 'status', + variant: 'warning', + title: 'Warning', + message: 'Multiple lines of warning content', + } as const; + const engine = new NativeListWebEngine( + host, + snapshot({ kind: 'sectioned' }, [warning]), + {}, + false + ); + const body = host.querySelector( + '[data-nl-renderer="system"]' + )!; + const title = body.querySelector('[data-nl-slot="title"]')!; + try { + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [ + { + ...warning, + style: { + container: { height: 160 }, + title: { fontSize: 18, lines: 2 }, + message: { lines: 1, truncate: 'clip' }, + lineGap: 8, + }, + }, + ]) + ); + expect(host.querySelector('[data-nl-renderer="system"]')).toBe(body); + expect(body.querySelector('[data-nl-slot="title"]')).toBe(title); + expect(title.style.fontSize).toBe('18px'); + engine.applySnapshot(snapshot({ kind: 'sectioned' }, [warning])); + expect(title.style.fontSize).toBe(''); + expect(body.style.rowGap).toBe(''); + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [ + { + type: 'system', + key: 'status', + variant: 'loading', + loadingStyle: 'spinner', + }, + ]) + ); + expect(host.querySelector('[data-nl-renderer="system"]')).toBe(body); + expect(body.querySelector('[data-nl-slot="title"]')).toBeNull(); + expect(body.querySelector('[role="progressbar"]')).not.toBeNull(); + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [ + { + type: 'system', + key: 'status', + variant: 'retry', + message: 'Retry connection', + actionKey: 'retry', + }, + ]) + ); + expect(body.querySelector('[role="progressbar"]')).toBeNull(); + expect( + body.querySelector('[data-native-list-action="retry"]') + ).not.toBeNull(); + } finally { + engine.destroy(); + } + }); + it('preserves Action views through style clearing and selection echoes', () => { const { document } = new JSDOM('').window; const host = document.createElement('div'); diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index 08e3f9b92..2560970b0 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -483,41 +483,6 @@ export function estimateWebRowHeight( // if (row.type === 'system' && row.variant === 'spacer') return row.height; const explicitHeight = row.style?.container?.height ?? row.height; if (explicitHeight !== undefined) return explicitHeight; - if (row.type === 'system' && row.variant === 'warning') { - const width = Math.max( - 1, - availableWidth - (row.style?.horizontalPadding ?? 12) * 2 - ); - const height = (text: string, style: NativeListTextStyle | undefined) => { - const size = style?.fontSize ?? 14; - const lines = text - .split(/\r\n|[\r\n]/) - .reduce( - (total, line) => - total + - Math.max( - 1, - Math.ceil( - Array.from(line).reduce( - (length, char) => - length + (char.charCodeAt(0) > 255 ? size : size / 2), - 0 - ) / width - ) - ), - 0 - ); - return ( - Math.min(style?.lines ?? Infinity, lines) * (style?.lineHeight ?? 20) - ); - }; - return ( - (row.style?.verticalPadding ?? 14) * 2 + - (row.style?.lineGap ?? 4) + - height(row.title, row.style?.title) + - height(row.message, row.style?.message) - ); - } if (row.type === 'walletGroup') // OneKey patch: wallet badges participate in the outer group height. // return (row.children.length + 1) * 68 + row.children.length * 12; @@ -542,7 +507,7 @@ export function estimateWebRowHeight( const registered = rowRenderer(row); if (registered) - return Math.max(0, registered.measure(availableWidth) + sizeModifier(row)); + return Math.max(0, registered.measure(availableWidth) + (registered.appliesSizePreset ? sizeModifier(row) : 0)); let base: number; switch (row.type) { case 'activity': @@ -579,22 +544,6 @@ export function estimateWebRowHeight( : 36; break; } - case 'system': - base = - row.variant === 'loading' && row.loadingStyle === 'skeleton' - ? 56 - : row.variant === 'loading' && row.loadingStyle === 'spinner' - ? 52 - : 'presentation' in row && row.presentation === 'market' - ? row.variant === 'loading' - ? 68 - : 44 - : row.variant === 'noMatch' || row.variant === 'end' - ? 36 - : row.variant === 'retry' - ? 44 - : 56; - break; case 'dataRow': base = row.columns.some((column) => column.secondaryText) ? 60 : 56; break; @@ -2390,176 +2339,6 @@ function createSectionHeader( return body; } -function createSystemRow( - context: RenderContext, - row: Extract -): HTMLElement { - const body = createElement( - context.document, - 'div', - 'ok-native-list-row ok-native-list-system' - ); - setData(body, 'variant', row.variant); - if ('presentation' in row) - setData(body, 'nativeListPresentation', row.presentation); - if (row.variant === 'loading' && row.loadingStyle === 'skeleton') { - body.classList.add('ok-native-list-market-skeleton'); - const background = resolvedTheme(context.snapshot).background; - const rgb = Number.parseInt(background.slice(1, 7), 16); - const dark = - ((rgb >> 16) & 255) * 0.299 + - ((rgb >> 8) & 255) * 0.587 + - (rgb & 255) * 0.114 < - 128; - body.style.setProperty('--nl-skeleton-base', dark ? '#111111' : '#fafafa'); - body.style.setProperty( - '--nl-skeleton-highlight', - dark ? '#333333' : '#cdcdcd' - ); - const left = createElement( - context.document, - 'div', - 'ok-native-list-skeleton-left' - ); - const mark = (width: number, height: number, circle = false) => { - const element = createElement( - context.document, - 'span', - 'ok-native-list-skeleton-mark' - ); - element.style.width = `${width}px`; - element.style.height = `${height}px`; - if (circle) element.style.borderRadius = '50%'; - return element; - }; - left.appendChild(mark(32, 32, true)); - const text = createElement( - context.document, - 'div', - 'ok-native-list-skeleton-text' - ); - text.appendChild(mark(80, 16)); - text.appendChild(mark(60, 12)); - left.appendChild(text); - body.appendChild(left); - const right = createElement( - context.document, - 'div', - 'ok-native-list-skeleton-right' - ); - right.appendChild(mark(80, 18)); - right.appendChild(mark(80, 18)); - body.appendChild(right); - return body; - } - if (row.variant === 'loading' && row.loadingStyle === 'spinner') { - body.style.justifyContent = 'center'; - body.style.padding = '16px'; - const spinner = createElement( - context.document, - 'span', - 'ok-native-list-market-spinner' - ); - spinner.setAttribute('role', 'progressbar'); - // Same 20px SVG and 750ms rotation as react-native-web ActivityIndicator. - spinner.innerHTML = - ''; - body.appendChild(spinner); - return body; - } - if ('presentation' in row && row.presentation === 'market') { - if (row.variant === 'noMatch') { - body.style.justifyContent = 'center'; - body.style.padding = '32px'; - const message = tagSlot( - createElement(context.document, 'span', '', row.message), - 'message' - ); - message.style.fontSize = '16px'; - message.style.lineHeight = '24px'; - body.appendChild(message); - return body; - } - if (row.variant === 'end') { - body.style.justifyContent = 'center'; - body.style.padding = '16px'; - body.style.gap = '8px'; - for (const width of [80, 4, 80]) { - const mark = createElement(context.document, 'span', ''); - mark.style.width = String(width) + 'px'; - mark.style.height = width === 4 ? '4px' : '1px'; - mark.style.borderRadius = width === 4 ? '2px' : '0'; - mark.style.background = 'var(--nl-separator)'; - body.appendChild(mark); - } - return body; - } - } - if (row.variant === 'warning') { - body.classList.add('ok-native-list-warning'); - body.style.borderColor = row.borderColor ?? 'var(--nl-separator)'; - body.appendChild( - tagSlot( - createElement( - context.document, - 'span', - 'ok-native-list-warning-title', - row.title - ), - 'title' - ) - ); - body.appendChild( - tagSlot( - createElement( - context.document, - 'span', - 'ok-native-list-warning-message', - row.message - ), - 'message' - ) - ); - return body; - } - if (row.variant === 'loading') - body.appendChild( - createElement(context.document, 'span', 'ok-native-list-spinner') - ); - const message = - row.variant === 'spacer' - ? '' - : row.message ?? (row.variant === 'end' ? 'End' : ''); - if (message) - body.appendChild( - tagSlot( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - message - ), - 'message' - ) - ); - if (row.variant === 'retry') { - const action = tagSlot( - createElement( - context.document, - 'button', - 'ok-native-list-action-button', - row.actionText ?? 'Retry' - ), - 'actionText' - ); - action.setAttribute('type', 'button'); - setData(action, 'nativeListAction', row.actionKey); - markActionAnchorSource(action, 'trailingAccessory', 0); - body.appendChild(action); - } - return body; -} - function createMetricCell( context: RenderContext, metric: NonNullable< @@ -3865,6 +3644,7 @@ export function applyRowStyle(body: HTMLElement, row: RowModel): void { function rendererPrimitives(context: RenderContext) { return { + background: resolvedTheme(context.snapshot).background, accessory: (key: string, descriptor: TrailingAccessory, slot: number) => createAccessory(context, key, descriptor, slot), visual: (source: LeadingVisual | undefined) => @@ -3888,8 +3668,6 @@ export function createRowBody( return createWalletGroupRow(context, row); case 'sectionHeader': return createSectionHeader(context, row); - case 'system': - return createSystemRow(context, row); case 'metricCard': return createMetricRow(context, row); case 'dataRow': @@ -3933,6 +3711,7 @@ export class NativeListWebEngine { rail: [], mediaTile: [], action: [], + system: [], }; private readonly rendererKeys = new WeakMap(); private frameHandle: number | undefined; diff --git a/native-views/react-native-native-list/src/web/templates/RowElements.ts b/native-views/react-native-native-list/src/web/templates/RowElements.ts new file mode 100644 index 000000000..d744e6134 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/RowElements.ts @@ -0,0 +1,35 @@ +import type { NativeListActionSource } from '../../models'; +export function createElement( + document: Document, + tag: string, + className?: string, + text?: string +): HTMLElement { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; +} + +export function setData( + element: HTMLElement, + name: string, + value: string | number | boolean | undefined +) { + if (value === undefined) delete element.dataset[name]; + else element.dataset[name] = String(value); +} + +export function tagSlot(element: T, slot: string): T { + element.dataset.nlSlot = slot; + return element; +} + +export function markActionAnchorSource( + element: HTMLElement, + source: NativeListActionSource, + slot?: number +) { + setData(element, 'nativeListAnchorSource', source); + if (slot !== undefined) setData(element, 'nativeListAnchorSlot', slot); +} diff --git a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts index f3937d2dc..e76eacb3d 100644 --- a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts +++ b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts @@ -1,3 +1,5 @@ +import type { SystemRow } from '../../models'; +import { systemRowRenderer } from './SystemRowRenderer'; import type { ActionRow } from '../../models'; import { actionRowRenderer } from './ActionRowRenderer'; import { mediaTileRowRenderer } from './MediaTileRowRenderer'; @@ -8,6 +10,7 @@ import { railRowRenderer } from './RailRowRenderer'; type Renderer = { key: string; + appliesSizePreset?: (row: R) => boolean; create: ( document: Document, row: R, @@ -21,6 +24,7 @@ type Renderer = { function registration(renderer: Renderer, row: R) { return { key: renderer.key, + appliesSizePreset: renderer.appliesSizePreset?.(row) ?? true, create: (document: Document, primitives: RowPrimitives) => renderer.create(document, row, primitives), bind: (body: HTMLElement, primitives: RowPrimitives) => @@ -30,6 +34,7 @@ function registration(renderer: Renderer, row: R) { }; } const factories = { + system: (row: RowModel) => registration(systemRowRenderer, row as SystemRow), action: (row: RowModel) => registration(actionRowRenderer, row as ActionRow), message: (row: RowModel) => registration(messageRowRenderer, row as MessageRow), @@ -38,6 +43,7 @@ const factories = { registration(mediaTileRowRenderer, row as MediaTileRow), }; const renderers = { + system: systemRowRenderer, action: actionRowRenderer, message: messageRowRenderer, rail: railRowRenderer, diff --git a/native-views/react-native-native-list/src/web/templates/RowVisual.ts b/native-views/react-native-native-list/src/web/templates/RowVisual.ts index a4c6a145f..427f8d0c5 100644 --- a/native-views/react-native-native-list/src/web/templates/RowVisual.ts +++ b/native-views/react-native-native-list/src/web/templates/RowVisual.ts @@ -7,6 +7,7 @@ import type { } from '../../models'; export type RowPrimitives = Readonly<{ + background: string; accessory: ( rowKey: string, descriptor: TrailingAccessory, @@ -14,7 +15,11 @@ export type RowPrimitives = Readonly<{ ) => HTMLElement; visual: (source: LeadingVisual | undefined) => HTMLElement | undefined; thumbnail: (source: ImageSource) => HTMLElement | undefined; - textStyle: (element: HTMLElement, style: NativeListTextStyle) => void; + textStyle: ( + element: HTMLElement, + style: NativeListTextStyle, + defaultLines?: number + ) => void; dispose: (element: HTMLElement) => void; }>; diff --git a/native-views/react-native-native-list/src/web/templates/SystemRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/SystemRowRenderer.ts new file mode 100644 index 000000000..d45609081 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/SystemRowRenderer.ts @@ -0,0 +1,287 @@ +import type { NativeListTextStyle, SystemRow } from '../../models'; +import type { RowPrimitives } from './RowVisual'; +import { + createElement, + setData, + tagSlot, + markActionAnchorSource, +} from './RowElements'; +function createSystemRow( + document: Document, + primitives: RowPrimitives, + row: SystemRow +): HTMLElement { + const body = createElement( + document, + 'div', + 'ok-native-list-row ok-native-list-system' + ); + setData(body, 'variant', row.variant); + if ('presentation' in row) + setData(body, 'nativeListPresentation', row.presentation); + if (row.variant === 'loading' && row.loadingStyle === 'skeleton') { + body.classList.add('ok-native-list-market-skeleton'); + const background = primitives.background; + const rgb = Number.parseInt(background.slice(1, 7), 16); + const dark = + ((rgb >> 16) & 255) * 0.299 + + ((rgb >> 8) & 255) * 0.587 + + (rgb & 255) * 0.114 < + 128; + body.style.setProperty('--nl-skeleton-base', dark ? '#111111' : '#fafafa'); + body.style.setProperty( + '--nl-skeleton-highlight', + dark ? '#333333' : '#cdcdcd' + ); + const left = createElement(document, 'div', 'ok-native-list-skeleton-left'); + const mark = (width: number, height: number, circle = false) => { + const element = createElement( + document, + 'span', + 'ok-native-list-skeleton-mark' + ); + element.style.width = `${width}px`; + element.style.height = `${height}px`; + if (circle) element.style.borderRadius = '50%'; + return element; + }; + left.appendChild(mark(32, 32, true)); + const text = createElement(document, 'div', 'ok-native-list-skeleton-text'); + text.appendChild(mark(80, 16)); + text.appendChild(mark(60, 12)); + left.appendChild(text); + body.appendChild(left); + const right = createElement( + document, + 'div', + 'ok-native-list-skeleton-right' + ); + right.appendChild(mark(80, 18)); + right.appendChild(mark(80, 18)); + body.appendChild(right); + return body; + } + if (row.variant === 'loading' && row.loadingStyle === 'spinner') { + body.style.justifyContent = 'center'; + body.style.padding = '16px'; + const spinner = createElement( + document, + 'span', + 'ok-native-list-market-spinner' + ); + spinner.setAttribute('role', 'progressbar'); + // Same 20px SVG and 750ms rotation as react-native-web ActivityIndicator. + spinner.innerHTML = + ''; + body.appendChild(spinner); + return body; + } + if ('presentation' in row && row.presentation === 'market') { + if (row.variant === 'noMatch') { + body.style.justifyContent = 'center'; + body.style.padding = '32px'; + const message = tagSlot( + createElement(document, 'span', '', row.message), + 'message' + ); + message.style.fontSize = '16px'; + message.style.lineHeight = '24px'; + body.appendChild(message); + return body; + } + if (row.variant === 'end') { + body.style.justifyContent = 'center'; + body.style.padding = '16px'; + body.style.gap = '8px'; + for (const width of [80, 4, 80]) { + const mark = createElement(document, 'span', ''); + mark.style.width = String(width) + 'px'; + mark.style.height = width === 4 ? '4px' : '1px'; + mark.style.borderRadius = width === 4 ? '2px' : '0'; + mark.style.background = 'var(--nl-separator)'; + body.appendChild(mark); + } + return body; + } + } + if (row.variant === 'warning') { + body.classList.add('ok-native-list-warning'); + body.style.borderColor = row.borderColor ?? 'var(--nl-separator)'; + body.appendChild( + tagSlot( + createElement( + document, + 'span', + 'ok-native-list-warning-title', + row.title + ), + 'title' + ) + ); + body.appendChild( + tagSlot( + createElement( + document, + 'span', + 'ok-native-list-warning-message', + row.message + ), + 'message' + ) + ); + return body; + } + if (row.variant === 'loading') + body.appendChild(createElement(document, 'span', 'ok-native-list-spinner')); + const message = + row.variant === 'spacer' + ? '' + : row.message ?? (row.variant === 'end' ? 'End' : ''); + if (message) + body.appendChild( + tagSlot( + createElement(document, 'span', 'ok-native-list-secondary', message), + 'message' + ) + ); + if (row.variant === 'retry') { + const action = tagSlot( + createElement( + document, + 'button', + 'ok-native-list-action-button', + row.actionText ?? 'Retry' + ), + 'actionText' + ); + action.setAttribute('type', 'button'); + setData(action, 'nativeListAction', row.actionKey); + markActionAnchorSource(action, 'trailingAccessory', 0); + body.appendChild(action); + } + return body; +} + +type Binding = { key: string; defaults: Map }; +const bindings = new WeakMap(); +function bind(body: HTMLElement, row: SystemRow, primitives: RowPrimitives) { + const content = { ...row, style: undefined }; + const key = JSON.stringify([content, primitives.background]); + let binding = bindings.get(body); + if (!binding || binding.key !== key) { + const next = createSystemRow(body.ownerDocument, primitives, row); + body.replaceChildren(...Array.from(next.childNodes)); + body.className = next.className; + body.style.cssText = next.style.cssText; + body.dataset.variant = row.variant; + body.dataset.nativeListPresentation = + 'presentation' in row ? row.presentation ?? '' : ''; + binding = { + key, + defaults: new Map( + [body, ...body.querySelectorAll('*')].map((node) => [ + node, + node.style.cssText, + ]) + ), + }; + bindings.set(body, binding); + } + binding.defaults.forEach((css, node) => { + node.style.cssText = css; + }); + body.removeAttribute('data-nl-container-background'); + body.removeAttribute('data-nl-container-border'); + const style = row.style; + if (style?.horizontalPadding !== undefined) + body.style.paddingInline = `${style.horizontalPadding}px`; + if (style?.verticalPadding !== undefined) + body.style.paddingBlock = `${style.verticalPadding}px`; + if (style?.lineGap !== undefined && row.variant === 'warning') + body.style.rowGap = `${style.lineGap}px`; + for (const slot of ['title', 'message', 'actionText'] as const) { + const text = style?.[slot]; + if (!text) continue; + body + .querySelectorAll(`[data-nl-slot="${slot}"]`) + .forEach((node) => + primitives.textStyle( + node, + text, + row.variant === 'warning' ? 0 : undefined + ) + ); + } +} +function create(document: Document, row: SystemRow, primitives: RowPrimitives) { + const body = document.createElement('div'); + body.dataset.nlRenderer = 'system'; + bind(body, row, primitives); + return body; +} +function measure(row: SystemRow, width: number) { + if (row.variant === 'warning') { + const textWidth = Math.max( + 1, + width - (row.style?.horizontalPadding ?? 12) * 2 + ); + const height = (text: string, style: NativeListTextStyle | undefined) => { + const size = style?.fontSize ?? 14; + const lines = text + .split(/\r\n|[\r\n]/) + .reduce( + (total, line) => + total + + Math.max( + 1, + Math.ceil( + Array.from(line).reduce( + (length, char) => + length + (char.charCodeAt(0) > 255 ? size : size / 2), + 0 + ) / textWidth + ) + ), + 0 + ); + return ( + Math.min(style?.lines ?? Infinity, lines) * (style?.lineHeight ?? 20) + ); + }; + return ( + (row.style?.verticalPadding ?? 14) * 2 + + (row.style?.lineGap ?? 4) + + height(row.title, row.style?.title) + + height(row.message, row.style?.message) + ); + } + return row.variant === 'spacer' + ? row.height + : row.variant === 'loading' && row.loadingStyle === 'skeleton' + ? 56 + : row.variant === 'loading' && row.loadingStyle === 'spinner' + ? 52 + : 'presentation' in row && row.presentation === 'market' + ? row.variant === 'loading' + ? 68 + : 44 + : row.variant === 'noMatch' || row.variant === 'end' + ? 36 + : row.variant === 'retry' + ? 44 + : 56; +} +export const systemRowRenderer = { + key: 'system' as const, + appliesSizePreset: (row: SystemRow) => + !['warning', 'spacer'].includes(row.variant), + create, + bind, + measure, + measureRendered: (_body: HTMLElement, _row: SystemRow): number | undefined => + undefined, + recycle: (body: HTMLElement) => { + bindings.delete(body); + body.replaceChildren(); + }, +}; From f2a957afdeb2a8a44c78ab67c7609ce591fbc550 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 20:00:51 +0800 Subject: [PATCH 42/71] refactor(native-list): migrate Activity views and footer actions --- .../nativelist/NativeListActivityRenderer.kt | 237 ++++++++++++++++++ .../nativelist/NativeListLeadingVisual.kt | 29 ++- .../com/onekey/nativelist/NativeListModels.kt | 9 +- .../nativelist/NativeListRendererRegistry.kt | 2 + .../onekey/nativelist/NativeListRowView.kt | 108 +------- .../nativelist/NativeListStyleSlotTest.kt | 3 - .../react-native-native-list/docs/DESIGN.md | 17 ++ .../react-native-native-list/docs/SPEC.md | 2 +- .../ios/NativeListActivityRenderer.swift | 160 ++++++++++++ .../ios/NativeListCell.swift | 108 +------- .../ios/NativeListLeadingVisual.swift | 19 +- .../ios/NativeListModels.swift | 2 +- .../ios/NativeListRendererRegistry.swift | 2 + .../ios/RNCNativeListView.swift | 1 - .../src/__tests__/NativeList.web.test.ts | 66 +++++ .../src/web/NativeListWebEngine.ts | 128 +++------- .../src/web/templates/ActionRowRenderer.ts | 4 +- .../src/web/templates/ActivityRowRenderer.ts | 159 ++++++++++++ .../src/web/templates/RowRendererRegistry.ts | 5 + 19 files changed, 733 insertions(+), 328 deletions(-) create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt create mode 100644 native-views/react-native-native-list/ios/NativeListActivityRenderer.swift create mode 100644 native-views/react-native-native-list/src/web/templates/ActivityRowRenderer.ts diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt new file mode 100644 index 000000000..45afce88c --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt @@ -0,0 +1,237 @@ +package com.margelo.nitro.nativelist + +import android.graphics.drawable.GradientDrawable +import android.text.TextUtils +import android.view.Gravity +import android.widget.LinearLayout +import com.facebook.react.uimanager.ThemedReactContext +import org.json.JSONObject + +internal class NativeListActivityRowView(context: ThemedReactContext) : + NativeListRendererRowView(context) { + private val content = + LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + private val visual = NativeListLeadingVisual(context) + private val column = LinearLayout(context).apply { orientation = VERTICAL } + private val titleLine = + LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + private val title = NativeListTextView(context) + private val description = NativeListTextView(context) + private val status = NativeListTextView(context) + private val failure = NativeListTextView(context) + private val amounts = + LinearLayout(context).apply { + orientation = VERTICAL + gravity = Gravity.END + } + private val values = List(2) { NativeListTextView(context) } + private val actions = LinearLayout(context).apply { orientation = HORIZONTAL } + private val buttons = List(3) { NativeListTextView(context) } + override val assetFields = listOf("leading", "secondaryLeading") + + init { + titleLine.addView(title, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + titleLine.addView(failure) + column.addView(titleLine) + column.addView(description) + column.addView(status) + values.forEach(amounts::addView) + buttons.forEach(actions::addView) + content.addView(visual) + content.addView(column, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + content.addView(amounts) + addView(content, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) + addView(actions) + } + + override fun defaultHeight(item: NativeListItem, layout: String) = + if ((item.json.optJSONArray("footerActions")?.length() ?: 0) > 0) 104 else 60 + + override fun bindContent( + item: NativeListItem, + theme: JSONObject?, + layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + val data = item.json + val style = data.optJSONObject("style") ?: JSONObject() + val image = style.optJSONObject("image") ?: JSONObject() + fun color(name: String, fallback: String) = + parseNativeListColor(theme?.optString(name, fallback) ?: fallback) + fun fill(value: Int, radius: Int) = + GradientDrawable().apply { + setColor(value) + cornerRadius = dp(radius).toFloat() + } + fun bind( + view: NativeListTextView, + value: String, + slot: String, + size: Float, + weight: String = "regular", + ink: Int = color("secondaryText", "#0000009B"), + line: Int = 0, + lines: Int = 1, + align: String = "start", + ) { + val textStyle = + JSONObject(style.optJSONObject(slot)?.toString() ?: "{}").apply { + if (!has("alignment")) put("alignment", align) + } + NativeListResolvedText.resolve( + context, + value, + textStyle, + size, + weight, + ink, + line, + lines, + sourceScale, + ) + .bind(view) + view.ellipsize = + if (textStyle.optString("truncate") == "clip") null else TextUtils.TruncateAt.END + } + orientation = VERTICAL + gravity = + when (style.optJSONObject("container")?.optString("contentVerticalAlignment")) { + "bottom" -> Gravity.BOTTOM + "top" -> Gravity.TOP + else -> Gravity.CENTER_VERTICAL + } + content.gravity = + when (style.optJSONObject("container")?.optString("contentVerticalAlignment")) { + "bottom" -> Gravity.BOTTOM + "top" -> Gravity.TOP + else -> Gravity.CENTER_VERTICAL + } + val hp = + if (style.has("horizontalPadding")) stylePx(style.optDouble("horizontalPadding")) + else dp(if (layout == "table") 16 else 12) + val vp = + if (style.has("verticalPadding")) stylePx(style.optDouble("verticalPadding")) else dp(8) + setPadding(hp, vp, hp, vp) + val iw = if (image.has("width")) stylePx(image.optDouble("width")) else dp(40) + val ih = if (image.has("height")) stylePx(image.optDouble("height")) else dp(40) + val gap = if (style.has("leadingGap")) stylePx(style.optDouble("leadingGap")) else dp(12) + visual.layoutParams = LayoutParams(iw, ih).apply { marginEnd = gap } + visual.bind( + data.optJSONObject("leading") ?: JSONObject(), + image, + item.key, + theme, + false, + sourceScale, + data.optJSONObject("secondaryLeading"), + ) + bind( + title, + data.optString("title"), + "title", + 16f, + "medium", + color("primaryText", "#000000DF"), + 24, + ) + bind(description, data.optString("description"), "description", 14f, line = 20, lines = 2) + val failed = data.optString("status") == "Failed" + bind(status, if (failed) "" else data.optString("status"), "status", 12f) + bind( + failure, + if (failed) "Failed" else "", + "status", + 12f, + "medium", + color("negative", "#C40006D3"), + ) + failure.background = fill(color("criticalBackground", "#F3000D14"), 4) + failure.setPadding(dp(8), dp(2), dp(8), dp(2)) + (failure.layoutParams as LayoutParams).apply { + marginStart = + if (style.has("titleBadgeGap")) stylePx(style.optDouble("titleBadgeGap")) else dp(8) + } + for (view in listOf(description, status)) (view.layoutParams as LayoutParams).apply { + topMargin = if (style.has("lineGap")) stylePx(style.optDouble("lineGap")) else 0 + } + for ((index, slot) in listOf("primaryAmount", "secondaryAmount").withIndex()) { + val value = data.optString(slot) + bind( + values[index], + value, + slot, + if (index == 0) 16f else 14f, + if (index == 0) "medium" else "regular", + if (index == 0) + color( + if (value.startsWith("+")) "positive" else "primaryText", + if (value.startsWith("+")) "#00713FDE" else "#000000DF", + ) + else color("secondaryText", "#0000009B"), + line = if (index == 0) 24 else 20, + align = "end", + ) + values[index].layoutParams = + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + if (index > 0) + topMargin = if (style.has("trailingGap")) stylePx(style.optDouble("trailingGap")) else 0 + } + } + val descriptors = data.optJSONArray("footerActions") + val count = minOf(3, descriptors?.length() ?: 0) + actions.visibility = if (count == 0) GONE else VISIBLE + actions.layoutParams = + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + marginStart = if (style.has("leadingGap") || image.has("width")) iw + gap else dp(52) + topMargin = if (style.has("lineGap")) stylePx(style.optDouble("lineGap")) else dp(8) + bottomMargin = dp(4) + } + for ((index, button) in buttons.withIndex()) { + button.visibility = if (index < count) VISIBLE else GONE + button.setOnClickListener(null) + if (index >= count) continue + val action = descriptors!!.getJSONObject(index) + NativeListResolvedText.resolve( + context, + action.optString("label"), + null, + 12f, + "medium", + color( + if (action.optString("tone") == "danger") "negative" else "primaryText", + if (action.optString("tone") == "danger") "#C40006D3" else "#000000DF", + ), + 0, + 1, + sourceScale, + ) + .bind(button) + button.background = fill(color("strongBackground", "#0000000F"), 8) + button.setPadding(dp(8), dp(4), dp(8), dp(4)) + button.isEnabled = !data.optBoolean("disabled") && !action.optBoolean("disabled") + button.layoutParams = + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + marginEnd = dp(8) + } + button.setOnClickListener { + emitAction(action.optString("key"), button, "footerAction", index) + } + } + } + + override fun recycleContent() { + visual.recycle() + buttons.forEach { it.setOnClickListener(null) } + } + + override fun disposeContent() { + visual.dispose() + buttons.forEach { it.setOnClickListener(null) } + } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt index 705438960..475f348c3 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt @@ -62,6 +62,7 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont theme: JSONObject?, isUnread: Boolean, sourceScale: Boolean, + secondaryVisual: JSONObject? = null, ) { this.visual = visual this.style = style @@ -91,6 +92,26 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont else visual.optJSONObject("image")?.let { listOf(it to variant) }.orEmpty() if (kind == "token" && sources.isNotEmpty()) visual.optJSONObject("networkImage")?.let { sources = sources + (it to "network") } + secondaryVisual?.let { second -> + val kind = second.optString("kind") + val source = + when (kind) { + "stackedImages" -> second.optJSONArray("images")?.optJSONObject(0) + "icon" -> null + else -> second.optJSONObject("image") + } + if (source != null) + sources = + sources + + (source to + when (kind) { + "token" -> "token" + "network" -> "network" + "account", + "wallet" -> "avatar" + else -> "generic" + }) + } while (slots.size < sources.size) { val slot = NativeListImageSlot(reactContext) slots.add(slot) @@ -303,7 +324,13 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont else 1f) .roundToInt() else dp(glyphSize) - place(icon, (w - iconSize + if(roundedGlyphOrigin) 1 else 0) / 2, (h - iconSize + if(roundedGlyphOrigin) 1 else 0) / 2, iconSize, iconSize) + place( + icon, + (w - iconSize + if (roundedGlyphOrigin) 1 else 0) / 2, + (h - iconSize + if (roundedGlyphOrigin) 1 else 0) / 2, + iconSize, + iconSize, + ) val tokenPair = kind == "token" && sources.size > 1 slots.take(sources.size).forEachIndexed { index, slot -> val size = diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index 5e087130b..76a72eed0 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -41,13 +41,6 @@ internal fun nativeListStyleSlot(type: String, variant: String, field: String): "title", "subtitle", "tertiary", "badge", "value", "valueSecondary" -> field else -> null } - "activity" -> when (field) { - "title", "status" -> field - "description" -> "subtitle" - "primaryAmount" -> "value" - "secondaryAmount" -> "valueSecondary" - else -> null - } "dataRow" -> when (field) { "columns" -> "dataPrimary" "columnSecondary" -> "dataSecondary" @@ -94,7 +87,7 @@ private fun updateLengthPrefixed(digest: MessageDigest, value: String) { // Message and Rail own dedicated renderers. Remaining templates still share // a compatible legacy tree; keys and style values never partition the pool. -internal enum class NativeListRendererKey { LEGACY, MESSAGE, RAIL, MEDIA_TILE, ACTION, SYSTEM } +internal enum class NativeListRendererKey { LEGACY, MESSAGE, RAIL, MEDIA_TILE, ACTION, SYSTEM, ACTIVITY } internal data class NativeListItem( val key: String, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt index f25a7c0cd..d77c8936a 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt @@ -9,6 +9,7 @@ internal object NativeListRendererRegistry { NativeListRendererKey.MESSAGE to ::NativeListMessageRowView, NativeListRendererKey.RAIL to ::NativeListRailRowView, NativeListRendererKey.MEDIA_TILE to ::NativeListMediaTileRowView, + NativeListRendererKey.ACTIVITY to ::NativeListActivityRowView, NativeListRendererKey.SYSTEM to ::NativeListSystemRowView, NativeListRendererKey.ACTION to ::NativeListActionRowView, ) @@ -18,6 +19,7 @@ internal object NativeListRendererRegistry { "message" to NativeListRendererKey.MESSAGE, "rail" to NativeListRendererKey.RAIL, "mediaTile" to NativeListRendererKey.MEDIA_TILE, + "activity" to NativeListRendererKey.ACTIVITY, "system" to NativeListRendererKey.SYSTEM, "action" to NativeListRendererKey.ACTION, ) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index 98091936e..7864b8829 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -400,9 +400,6 @@ internal class NativeListRowView( private val marketBadgeLabels = List(3) { TextView(context) } private val marketBadgeImages = List(3) { OneKeyImageReusableView(reactContext) } private val marketBadgeGlyphs = List(3) { OneKeyIconView(context) } - private val activityContentRow = LinearLayout(context) - private val actionLine = LinearLayout(context) - private val actionViews = List(3) { TextView(context) } private val trailingColumn = LinearLayout(context) private val trailingViews = List(2) { NativeListTextView(context) } private val trailingIcons = List(2) { OneKeyIconView(context) } @@ -531,16 +528,7 @@ internal class NativeListRowView( mainColumn.addView(status) mainColumn.addView(metricSubtitle) - actionLine.orientation = HORIZONTAL - actionViews.forEach { action -> - action.typeface = NativeListFonts.semibold(context) - action.includeFontPadding = false - action.textSize = sp(14f) - TextViewCompat.setLineHeight(action, dp(20)) - action.setPadding(dp(8), dp(4), dp(8), dp(4)) - actionLine.addView(action) - } - mainColumn.addView(actionLine) + trailingColumn.orientation = VERTICAL trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL @@ -908,7 +896,6 @@ internal class NativeListRowView( when (item.type) { "walletGroup" -> bindWalletGroup(item, theme, layout, listOrientation, checkboxState) "identity" -> bindIdentity(item, theme, selected, checkboxState) - "activity" -> bindActivity(item, theme) "dataRow" -> bindDataRow(item, theme, checkboxState) "market" -> bindMarket(item, theme) "metricCard" -> bindMetricCard(item, theme) @@ -1053,13 +1040,7 @@ internal class NativeListRowView( } } } - if (item.type == "activity" && actionLine.parent === this) { - if (style.has("lineGap")) styleMargins(actionLine, top = styleDp(style.optDouble("lineGap"))) - if (style.has("leadingGap") || style.optJSONObject("image")?.has("width") == true) { - val leading = leadingFrame.layoutParams as MarginLayoutParams - styleMargins(actionLine, start = leading.width + leading.marginEnd) - } - } + if (item.type == "dataRow") tableDataColumns.forEach { it.applyStyle(style, ::applyStyledText) } val variant = item.json.optString("variant") @@ -1080,7 +1061,7 @@ internal class NativeListRowView( val view = if (item.type == "identity") semanticValueViews.getOrNull(if (slot == "value") 0 else 1) else styledSlotView(slot) view?.let { applyStyledText(it, slotStyle) } } - "status" -> applyStyledText(if (item.type == "activity" && item.json.optString("status") == "Failed") badgeLine else status, slotStyle) + "status" -> applyStyledText(if (false) badgeLine else status, slotStyle) "badge" -> (semanticBadgeLabels.ifEmpty { listOf(badgeLine) }).forEach { applyStyledText(it, slotStyle) } "subtitle" -> (semanticSubtitleLabels.ifEmpty { listOf(subtitle) }).forEach { applyStyledText(it, slotStyle) } else -> styledSlotView(slot)?.let { applyStyledText(it, slotStyle) } @@ -1475,8 +1456,6 @@ internal class NativeListRowView( row.alpha = 1f row.recycle() } - activityContentRow.removeAllViews() - (actionLine.parent as? ViewGroup)?.removeView(actionLine) removeAllViews() // OneKey patch: the old animator is cancelled and old children are detached. // Do not trim currently needed members when re-binding an expanded group. @@ -1501,7 +1480,6 @@ internal class NativeListRowView( mainColumn.addView(titleLine, 0) mainColumn.addView(subtitle, 1) mainColumn.addView(tertiary, 2) - mainColumn.addView(actionLine) titleLine.packsChildrenAtStart = false titleLine.removeView(headerTitleIcon) trailingColumn.removeView(headerValueIcon) @@ -1540,12 +1518,6 @@ internal class NativeListRowView( badgeLine.visibility = GONE badgeLine.background = null badgeLine.setPadding(0, 0, 0, 0) - actionLine.visibility = GONE - actionViews.forEach { - it.visibility = GONE - it.background = null - it.setOnClickListener(null) - } trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL trailingColumn.orientation = VERTICAL trailingColumn.layoutParams = wrap() @@ -2044,79 +2016,6 @@ internal class NativeListRowView( bindAccessories(item, accessories, theme, checkboxState) } - private fun bindActivity(item: NativeListItem, theme: JSONObject?) { - addLeading( - item.json.optJSONObject("leading"), - secondaryVisual = item.json.optJSONObject("secondaryLeading"), - ) - addView(mainColumn, weighted()) - showText(title, item.json.optString("title"), 1) - showText(subtitle, item.json.optString("description"), 2) - TextViewCompat.setLineHeight(title, dp(24)) - TextViewCompat.setLineHeight(subtitle, dp(20)) - if (item.json.optString("status") == "Failed") { - showText(badgeLine, "Failed", 1) - badgeLine.setTextColor(color(theme, "negative", "#C40006D3")) - badgeLine.background = roundedFill( - color(theme, "criticalBackground", "#F3000D14"), - 4f, - ) - badgeLine.setPadding(dp(8), dp(2), dp(8), dp(2)) - } else { - showText(status, item.json.optString("status"), 1) - } - addView(trailingColumn, wrap()) - showTrailing(0, item.json.optString("primaryAmount"), true) - showTrailing(1, item.json.optString("secondaryAmount"), false) - if (item.json.optString("primaryAmount").startsWith("+")) { - trailingViews[0].setTextColor(color(theme, "positive", "#00713FDE")) - } - val actions = item.json.optJSONArray("footerActions") - if (actions != null && actions.length() > 0) { - actionLine.visibility = VISIBLE - for (index in 0 until minOf(3, actions.length())) { - val action = actions.getJSONObject(index) - val actionView = actionViews[index] - actionView.text = action.optString("label") - actionView.visibility = VISIBLE - actionView.isEnabled = isEnabled && !action.optBoolean("disabled", false) - actionView.background = roundedFill(color(theme, "strongBackground", "#0000000F"), 8f) - actionView.setTextColor( - if (action.optString("tone") == "danger") { - color(theme, "negative", "#C40006D3") - } else { - color(theme, "primaryText", "#000000DF") - }, - ) - actionView.layoutParams = LayoutParams( - LayoutParams.WRAP_CONTENT, - LayoutParams.WRAP_CONTENT, - ).apply { marginEnd = dp(8) } - actionView.setOnClickListener { - emitAction(item, action.optString("key"), null, actionView, "footerAction", index) - } - } - // TxActionCommonListView is one column ListItem with an 8dp gap between - // its content XStack and the pending action footer. - removeView(leadingFrame) - removeView(mainColumn) - removeView(trailingColumn) - mainColumn.removeView(actionLine) - activityContentRow.orientation = HORIZONTAL - activityContentRow.gravity = Gravity.CENTER_VERTICAL - activityContentRow.addView(leadingFrame) - activityContentRow.addView(mainColumn) - activityContentRow.addView(trailingColumn) - orientation = VERTICAL - gravity = Gravity.START - addView(activityContentRow, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) - addView(actionLine, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { - marginStart = if (leadingFrame.visibility == VISIBLE) dp(52) else 0 - topMargin = dp(8) - bottomMargin = dp(4) - }) - } - } private fun bindDataRow( item: NativeListItem, @@ -3848,7 +3747,6 @@ internal class NativeListRowView( } isNetworkSelectorIdentity -> 47 else -> when (item.type) { - "activity" -> if ((item.json.optJSONArray("footerActions")?.length() ?: 0) > 0) 104 else 60 "metricCard" -> when (item.json.optString("variant")) { "activity" -> 0 "performance" -> 0 diff --git a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt index ff9a7bccd..a0730a348 100644 --- a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt +++ b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt @@ -25,11 +25,8 @@ class NativeListStyleSlotTest { Case(type = "identity", field = "valueSecondary", expected = "valueSecondary"), // Legacy status views only serve unmigrated templates. Case(type = "rail", field = "status", expected = null), - Case(type = "activity", field = "status", expected = "status"), Case(type = "message", field = "time", expected = null), // Amounts, indices and values share the two trailing views. - Case(type = "activity", field = "primaryAmount", expected = "value"), - Case(type = "activity", field = "secondaryAmount", expected = "valueSecondary"), Case(type = "dataRow", field = "index", expected = "value"), Case(type = "dataRow", field = "columns", expected = "dataPrimary"), Case(type = "dataRow", field = "columnSecondary", expected = "dataSecondary"), diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index ed3afa4ac..842b2fb66 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -355,3 +355,20 @@ regression tests cover style clearing, retained warning nodes and variant reuse. Evidence: external runtime `ios-system-*`, `android-system-*`, `web-system-*`. iOS/Android Debug builds, Android unit tests and 156 package tests pass. Remaining stage 3 templates: Activity, DataRow and MetricCard. + +### Stage 3: Activity acceptance (2026-09-23) + +Activity owns its leading visuals, text, amounts and three bounded footer-action +buttons. Its legacy dispatch and exclusive native allocations are removed; Web +Identity no longer dispatches Activity internally. Secondary image descriptors +use the shared bounded image primitive. Action's Web visual now also applies +explicit image width/height and clears them back to its defaults. + +The dedicated native runtimes and headed Chrome exercised default captures, +style set/clear, secondary images, row footer actions, end/top scrolling and +empty/repopulate with incompatible-family reuse. iOS preserves its pre-existing +text compression for content exceeding the default 60-point height. Native +builds and Android unit tests pass; 157 package tests cover image retention, +amount/style clearing, action replacement and Action image dimensions. +Evidence is retained as `ios-activity-*`, `android-activity-*`, `web-activity-*` +in the external runtime. Remaining stage 3 templates: DataRow and MetricCard. diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index e8abf317f..4792fd8ca 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -78,7 +78,7 @@ separate workload and measurements. | --- | --- | --- | | 1. Baseline | Complete: [implicit keys, defaults, special updates and acceptance baseline](MIGRATION_BASELINE.md) inventoried | Preserve each existing rule or explicitly migrate its caller | | 2. Message pilot | Complete: registered lightweight native hosts, persistent Web body, resolved styling/measurement and lifecycle acceptance | Lightweight native hosts, resolved styling/measurement, update classification and lifecycle acceptance | -| 3. Simple templates | In progress (4/7): Rail, MediaTile, Action and System migrated on all three platforms; three templates remain | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | +| 3. Simple templates | In progress (5/7): Rail, MediaTile, Action, System and Activity migrated on all three platforms; DataRow and MetricCard remain | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | | 4. Complex templates | Not started | Migrate Market, Identity and SectionHeader while preserving quote, selection and header behavior | | 5. WalletGroup | Not started | Independent member renderers; verify member events/styles, compact dragging and height restoration | | 6. Cleanup | Not started | Remove migrated key inference and global style maps; container no longer manipulates template internals | diff --git a/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift b/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift new file mode 100644 index 000000000..be123ade2 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift @@ -0,0 +1,160 @@ +import UIKit + +final class NativeListActivityCell: NativeListRendererCell { + private let visual = NativeListLeadingVisual() + private let column = UIStackView() + private let titleLine = UIStackView() + private let title = NativeListTextLabel() + private let descriptionLabel = NativeListTextLabel() + private let status = NativeListTextLabel() + private let failure = NativeListTextLabel() + private let amounts = UIStackView() + private let amountLabels = [ + NativeListAccessoryButton(type: .system), NativeListAccessoryButton(type: .system), + ] + private let actions = UIStackView() + private let buttons = (0..<3).map { _ in UIButton(type: .system) } + private var actionKeys: [String] = [] + private var dimensions: [NSLayoutConstraint] = [] + override var assetFields: [String] { ["leading", "secondaryLeading"] } + + override init(frame: CGRect) { + super.init(frame: frame) + root.axis = .horizontal + column.axis = .vertical + column.alignment = .fill + column.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal) + column.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + titleLine.axis = .horizontal + titleLine.alignment = .center + titleLine.spacing = 8 + title.setContentCompressionResistancePriority(.required, for: .horizontal) + failure.setContentHuggingPriority(.required, for: .horizontal) + titleLine.addArrangedSubview(title) + titleLine.addArrangedSubview(failure) + [titleLine, descriptionLabel, status, actions].forEach(column.addArrangedSubview) + amounts.axis = .vertical + amounts.alignment = .trailing + amounts.setContentHuggingPriority(.required, for: .horizontal) + amounts.setContentCompressionResistancePriority(.required, for: .horizontal) + amountLabels.forEach(amounts.addArrangedSubview) + actions.axis = .horizontal + actions.alignment = .center + actions.spacing = 8 + for (index, button) in buttons.enumerated() { + button.tag = index + button.titleLabel?.font = nativeListFont(ofSize: 12, weight: .medium) + button.layer.cornerRadius = 8 + button.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) + button.addTarget(self, action: #selector(pressed(_:)), for: .touchUpInside) + actions.addArrangedSubview(button) + } + [visual, column, amounts].forEach(root.addArrangedSubview) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func bindContent( + _ item: NativeListItem, theme: [String: Any]?, layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + let data = item.data + let style = data.dictionary("style") ?? [:] + let image = style.dictionary("image") ?? [:] + root.spacing = 12 + root.setCustomSpacing(CGFloat(style.double("leadingGap", default: 12)), after: visual) + switch style.dictionary("container")?.string("contentVerticalAlignment") { + case "top": root.alignment = .top + case "bottom": root.alignment = .bottom + default: root.alignment = .center + } + column.spacing = CGFloat(style.double("lineGap")) + titleLine.spacing = CGFloat(style.double("titleBadgeGap", default: 8)) + amounts.spacing = CGFloat(style.double("trailingGap", default: 2)) + contentInsets = UIEdgeInsets( + top: CGFloat(style.double("verticalPadding", default: 8)), + left: CGFloat(style.double("horizontalPadding", default: layout == "table" ? 16 : 12)), + bottom: CGFloat(style.double("verticalPadding", default: 8)), + right: CGFloat(style.double("horizontalPadding", default: layout == "table" ? 16 : 12))) + NSLayoutConstraint.deactivate(dimensions) + dimensions = [ + visual.widthAnchor.constraint(equalToConstant: CGFloat(image.double("width", default: 40))), + visual.heightAnchor.constraint(equalToConstant: CGFloat(image.double("height", default: 40))), + ] + NSLayoutConstraint.activate(dimensions) + visual.bind( + data.dictionary("leading") ?? [:], style: image, key: item.key, theme: theme, isUnread: false, + secondaryVisual: data.dictionary("secondaryLeading")) + func bind( + _ view: NativeListTextLabel, _ value: String, _ slot: String, size: CGFloat, + weight: NativeListFontWeight = .regular, color: UIColor, line: CGFloat, lines: Int = 1 + ) { + NativeListResolvedText( + value, style: style.dictionary(slot), size: size, weight: weight, color: color, + lineHeight: line, lines: lines + ).bind(view) + } + bind( + title, data.string("title"), "title", size: 16, weight: .medium, + color: nativeListColor(theme, "primaryText", "#202020"), line: 24) + bind( + descriptionLabel, data.string("description"), "description", size: 14, + color: nativeListColor(theme, "secondaryText", "#646464"), line: 20, lines: 2) + let failed = data.string("status") == "Failed" + bind( + status, failed ? "" : data.string("status"), "status", size: 12, + color: nativeListColor(theme, "secondaryText", "#646464"), + line: nativeListFont(ofSize: 12).lineHeight) + bind( + failure, failed ? " Failed " : "", "status", size: 12, weight: .medium, + color: nativeListColor(theme, "negative", "#CE2C31"), + line: nativeListFont(ofSize: 12, weight: .medium).lineHeight) + failure.backgroundColor = nativeListColor(theme, "criticalBackground", "#F3000D14") + failure.layer.cornerRadius = 4 + failure.clipsToBounds = true + for (index, slot) in ["primaryAmount", "secondaryAmount"].enumerated() { + let button = amountLabels[index] + let value = data.string(slot) + let color = + index == 0 + ? nativeListColor( + theme, value.hasPrefix("+") ? "positive" : "primaryText", + value.hasPrefix("+") ? "#218358" : "#202020") + : nativeListColor(theme, "secondaryText", "#646464") + button.titleLabel?.numberOfLines = 1 + button.titleLabel?.lineBreakMode = .byTruncatingTail + button.contentHorizontalAlignment = .center + button.contentVerticalAlignment = .center + button.rowTextOffsetY = 0 + let resolved = NativeListResolvedText( + value, style: nil, size: index == 0 ? 16 : 14, weight: index == 0 ? .medium : .regular, + color: color, lineHeight: index == 0 ? 24 : 20, lines: 1, tabular: true) + button.setAttributedTitle(resolved.attributed, for: .normal) + button.titleLabel?.font = resolved.font + button.isHidden = value.isEmpty + if let text = style.dictionary(slot) { NativeListTextStyles.applyStyledButton(button, text) } + } + let descriptors = Array(data.dictionaries("footerActions").prefix(3)) + actions.isHidden = descriptors.isEmpty + actionKeys = descriptors.map { $0.string("key") } + for (index, button) in buttons.enumerated() { + button.isHidden = index >= descriptors.count + guard index < descriptors.count else { continue } + let action = descriptors[index] + button.setTitle(action.string("label"), for: .normal) + button.isEnabled = !data.bool("disabled") && !action.bool("disabled") + button.backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0") + button.setTitleColor( + nativeListColor( + theme, action.string("tone") == "danger" ? "negative" : "primaryText", + action.string("tone") == "danger" ? "#CE2C31" : "#202020"), for: .normal) + } + } + @objc private func pressed(_ button: UIButton) { + guard button.tag < actionKeys.count else { return } + emitAction(actionKeys[button.tag], from: button, source: "footerAction", slot: button.tag) + } + override func recycleContent() { + visual.recycle() + actionKeys = [] + buttons.forEach { $0.isHidden = true } + } +} diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index f9c1c392d..795f54845 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -406,8 +406,6 @@ final class NativeListCell: NativeListRowHost { // private let marketBadgeButtons = (0..<3).map { _ in UIButton(type: .system) } private let marketBadgeButtons = (0..<3).map { _ in NativeListAccessoryButton(type: .system) } private let marketBadgeImages = (0..<3).map { _ in OneKeyImageReusableView(frame: .zero) } - private let actionStack = UIStackView() - private let actionButtons = (0..<3).map { _ in UIButton(type: .system) } private let trailingStack = UIStackView() // OneKey patch: summary actions opt into source typography while other buttons keep UIKit layout. // private let accessoryButtons = (0..<2).map { _ in UIButton(type: .system) } @@ -445,7 +443,6 @@ final class NativeListCell: NativeListRowHost { private var selectorTypographyRestorers: [() -> Void] = [] private var currentItem: NativeListItem? private var accessoryActions: [(String, NativeSelectionTarget?)] = [] - private var footerActionKeys: [String] = [] private var checkboxAction: (String, NativeSelectionTarget?)? private var boundCheckboxData: [String: Any]? private var boundCheckboxTarget: NativeSelectionTarget? @@ -665,19 +662,6 @@ final class NativeListCell: NativeListRowHost { [titleRowStack, subtitleLabel, tertiaryLabel, statusLabel, metricSubtitleLabel] .forEach(mainStack.addArrangedSubview) - actionStack.axis = .horizontal - actionStack.alignment = .center - actionStack.spacing = 8 - actionButtons.enumerated().forEach { index, button in - button.titleLabel?.font = nativeListFont(ofSize: 12, weight: .medium) - button.layer.cornerRadius = 8 - button.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) - button.tag = index - button.addTarget(self, action: #selector(footerActionPressed(_:)), for: .touchUpInside) - actionStack.addArrangedSubview(button) - } - mainStack.addArrangedSubview(actionStack) - trailingStack.axis = .vertical trailingStack.alignment = .trailing trailingStack.spacing = 2 @@ -869,7 +853,6 @@ final class NativeListCell: NativeListRowHost { switch item.type { case "walletGroup": bindWalletGroup(item, theme: theme, layout: layout, checkboxState) case "identity": bindIdentity(item, theme: theme, selected: selected, checkboxState) - case "activity": bindActivity(item, theme: theme) case "dataRow": bindDataRow(item, theme: theme, checkboxState) case "market": bindMarket(item, theme: theme) case "metricCard": bindMetricCard(item, theme: theme) @@ -1262,15 +1245,10 @@ final class NativeListCell: NativeListRowHost { $0.imageEdgeInsets = .zero $0.titleEdgeInsets = .zero } - [titleLabel, subtitleLabel, tertiaryLabel, statusLabel, metricSubtitleLabel, badgeLabel, actionStack] + [titleLabel, subtitleLabel, tertiaryLabel, statusLabel, metricSubtitleLabel, badgeLabel] .forEach { $0.isHidden = true } separatorView.isHidden = true separatorLeadingConstraint.constant = 0 - actionButtons.forEach { - $0.isHidden = true - $0.setTitle(nil, for: .normal) - $0.backgroundColor = .clear - } accessoryButtons.enumerated().forEach { index, button in button.isUserInteractionEnabled = true button.selectorSummaryLineHeight = nil @@ -1307,7 +1285,6 @@ final class NativeListCell: NativeListRowHost { $0.isHidden = true } accessoryActions = [] - footerActionKeys = [] checkboxAction = nil boundCheckboxData = nil boundCheckboxTarget = nil @@ -1898,70 +1875,6 @@ final class NativeListCell: NativeListRowHost { } } - private func bindActivity(_ item: NativeListItem, theme: [String: Any]?) { - addLeading( - item.data.dictionary("leading"), - secondaryVisual: item.data.dictionary("secondaryLeading"), - key: item.key - ) - rootStack.addArrangedSubview(mainStack) - show(titleLabel, item.data.string("title"), lines: 1) - show(subtitleLabel, item.data.string("description"), lines: 2) - mainStack.spacing = 0 - setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) - setLineHeight(subtitleLabel, text: item.data.string("description"), lineHeight: 20) - if item.data.string("status") == "Failed" { - show(badgeLabel, " Failed ", lines: 1) - badgeLabel.textColor = nativeListColor(theme, "negative", "#CE2C31") - badgeLabel.backgroundColor = nativeListColor( - theme, - "criticalBackground", - "#F3000D14" - ) - badgeLabel.layer.cornerRadius = 4 - badgeLabel.clipsToBounds = true - } else { - show(statusLabel, item.data.string("status"), lines: 1) - } - rootStack.addArrangedSubview(trailingStack) - showAccessory(0, item.data.string("primaryAmount")) - showAccessory(1, item.data.string("secondaryAmount")) - let primaryAmountColor = item.data.string("primaryAmount").hasPrefix("+") - ? nativeListColor(theme, "positive", "#218358") - : nativeListColor(theme, "primaryText", "#202020") - setButtonLine( - accessoryButtons[0], - text: item.data.string("primaryAmount"), - font: nativeListTabularFont(ofSize: 16, weight: .medium), - color: primaryAmountColor, - lineHeight: 24 - ) - setButtonLine( - accessoryButtons[1], - text: item.data.string("secondaryAmount"), - font: nativeListTabularFont(ofSize: 14), - color: nativeListColor(theme, "secondaryText", "#646464"), - lineHeight: 20 - ) - let actions = item.data.dictionaries("footerActions").prefix(3) - if !actions.isEmpty { - actionStack.isHidden = false - for (index, action) in actions.enumerated() { - let button = actionButtons[index] - button.isHidden = false - button.isEnabled = !action.bool("disabled") - button.setTitle(action.string("label"), for: .normal) - button.backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0") - button.setTitleColor( - action.string("tone") == "danger" - ? nativeListColor(theme, "negative", "#CE2C31") - : nativeListColor(theme, "primaryText", "#202020"), - for: .normal - ) - footerActionKeys.append(action.string("key")) - } - } - } private func bindDataRow( _ item: NativeListItem, @@ -2055,14 +1968,6 @@ final class NativeListCell: NativeListRowHost { case "identity": return ["title", "subtitle", "tertiary", "badge", "value", "valueSecondary"] .contains(field) ? field : nil - case "activity": - switch field { - case "title", "status": return field - case "description": return "subtitle" - case "primaryAmount": return "value" - case "secondaryAmount": return "valueSecondary" - default: return nil - } case "dataRow": switch field { case "columns": return "dataPrimary" @@ -2202,7 +2107,7 @@ final class NativeListCell: NativeListRowHost { case "title": applyStyledText(titleLabel, slotStyle) case "subtitle": (semanticSubtitleLabels.isEmpty ? [subtitleLabel] : semanticSubtitleLabels).forEach { applyStyledText($0, slotStyle) } case "tertiary": applyStyledText(tertiaryLabel, slotStyle) - case "status": applyStyledText(item.type == "activity" && item.data.string("status") == "Failed" ? badgeLabel : statusLabel, slotStyle) + case "status": applyStyledText(statusLabel, slotStyle) case "metricSubtitle": applyStyledText(metricSubtitleLabel, slotStyle) case "badge": (semanticBadgeLabels.isEmpty ? [badgeLabel] : semanticBadgeLabels).forEach { applyStyledText($0, slotStyle) } case "dataPrimary", "dataSecondary": break // Applied to independent column labels above. @@ -4070,15 +3975,6 @@ final class NativeListCell: NativeListRowHost { ) } - @objc private func footerActionPressed(_ sender: UIButton) { - guard let item = currentItem, footerActionKeys.indices.contains(sender.tag) else { return } - onAction?( - item, - footerActionKeys[sender.tag], - nil, - actionOrigin(sourceView: sender, source: "footerAction", slot: sender.tag) - ) - } @objc private func checkboxPressed() { guard let item = currentItem, let action = checkboxAction else { return } diff --git a/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift b/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift index d4e929bff..e3a099d32 100644 --- a/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift +++ b/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift @@ -35,7 +35,7 @@ final class NativeListLeadingVisual: UIView { func bind( _ visual: [String: Any], style: [String: Any], key: String, theme: [String: Any]?, - isUnread: Bool + isUnread: Bool, secondaryVisual: [String: Any]? = nil ) { self.visual = visual imageStyle = style @@ -51,6 +51,23 @@ final class NativeListLeadingVisual: UIView { if kind == "token", !sources.isEmpty, let network = visual.dictionary("networkImage") { sources.append((network, "network")) } + if let secondaryVisual { + let secondaryKind = secondaryVisual.string("kind") + let source = + secondaryKind == "stackedImages" + ? secondaryVisual.dictionaries("images").first + : secondaryKind == "icon" ? nil : secondaryVisual.dictionary("image") + if let source { + sources.append( + ( + source, + secondaryKind == "token" + ? "token" + : secondaryKind == "network" + ? "network" : ["account", "wallet"].contains(secondaryKind) ? "avatar" : "generic" + )) + } + } while slots.count < sources.count { let slot = NativeListImageSlot() slots.append(slot) diff --git a/native-views/react-native-native-list/ios/NativeListModels.swift b/native-views/react-native-native-list/ios/NativeListModels.swift index 284bd9c7b..da1937225 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -4,7 +4,7 @@ import UIKit // Message is the first migrated renderer. Other templates still use the // compatible legacy tree; style, content and row keys do not affect reuse. enum NativeListRendererKey: String, CaseIterable { - case legacy, message, rail, mediaTile, action, system + case legacy, message, rail, mediaTile, action, system, activity } struct NativeListItem { diff --git a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift index 70f09e265..edb7dbee3 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift @@ -9,6 +9,7 @@ enum NativeListRendererRegistry { .message: (NativeListMessageCell.self, { NativeListMessageCell(frame: .zero) }), .rail: (NativeListRailCell.self, { NativeListRailCell(frame: .zero) }), .mediaTile: (NativeListMediaTileCell.self, { NativeListMediaTileCell(frame: .zero) }), + .activity: (NativeListActivityCell.self, { NativeListActivityCell(frame: .zero) }), .system: (NativeListSystemCell.self, { NativeListSystemCell(frame: .zero) }), .action: (NativeListActionCell.self, { NativeListActionCell(frame: .zero) }), ] @@ -30,6 +31,7 @@ enum NativeListRendererRegistry { static func measure(_ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String) -> CGFloat? { + if item.rendererKey == .activity { return item.data.dictionaries("footerActions").isEmpty ? 60 : 100 } if item.rendererKey == .system { return NativeListSystemRenderer.measure(item, width: width) } if item.rendererKey == .action { return item.data.string("presentation") == "accountSelector" diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index b78dfdaa9..10e70f80f 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -1731,7 +1731,6 @@ final class NativeListView: UIView { } let base: CGFloat switch item.type { - case "activity": base = item.data.dictionaries("footerActions").isEmpty ? 60 : 100 case "metricCard": base = item.data.string("variant") == "activity" ? 160 + 1 / UIScreen.main.scale diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index 154b785e3..486b56f61 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -1197,6 +1197,69 @@ describe('web row style', () => { } }); + it('retains Activity images while resetting amount styles and footer actions', () => { + const { document } = new JSDOM('').window; + const host = document.createElement('div'); + document.body.appendChild(host); + const row = { + type: 'activity', + key: 'tx', + leading: { kind: 'image', image: { uri: 'https://example.com/a.png', width: 40, height: 40 } }, + title: 'Received', + description: 'From Alice', + status: 'Failed', + primaryAmount: '+1 BTC', + secondaryAmount: '$42', + footerActions: [{ key: 'retry', label: 'Retry' }], + } as const; + const engine = new NativeListWebEngine( + host, + snapshot({ kind: 'sectioned' }, [row]), + {}, + false + ); + const body = host.querySelector( + '[data-nl-renderer="activity"]' + )!; + const image = body.querySelector('img'); + try { + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [ + { + ...row, + style: { + container: { height: 120 }, + primaryAmount: { fontSize: 12 }, + status: { color: '#ff0000' }, + image: { width: 28 }, + }, + }, + ]) + ); + expect(body.querySelector('img')).toBe(image); + expect( + body.querySelector('[data-nl-slot="primaryAmount"]')!.style + .fontSize + ).toBe('12px'); + engine.applySnapshot( + snapshot({ kind: 'sectioned' }, [ + { ...row, status: 'Confirmed', footerActions: undefined }, + ]) + ); + expect(body.querySelector('img')).toBe(image); + expect( + body.querySelector('[data-nl-slot="primaryAmount"]')!.style + .fontSize + ).toBe(''); + expect( + body.querySelector('[data-native-list-action="retry"]') + ).toBeNull(); + expect(body.textContent).toContain('Confirmed'); + } finally { + engine.destroy(); + } + }); + it('restores System warning styles and replaces variant content in its own host', () => { const { document } = new JSDOM('').window; const host = document.createElement('div'); @@ -1329,6 +1392,7 @@ describe('web row style', () => { title: { fontSize: 20, lines: 2 }, value: { fontSize: 12, lines: 1 }, leadingGap: 18, + image: { width: 28, height: 30 }, }, }, ]) @@ -1337,6 +1401,8 @@ describe('web row style', () => { expect(body.querySelector('[data-nl-slot="title"]')).toBe(title); expect(body.querySelector('.ok-native-list-visual')).toBe(icon); expect(current().style.height).toBe('100px'); + expect((icon as HTMLElement).style.width).toBe('28px'); + expect((icon as HTMLElement).style.height).toBe('30px'); const selected = snapshot({ kind: 'sectioned' }, [action, selectable]); engine.applySnapshot({ ...selected, diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index 2560970b0..275306c57 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -507,12 +507,13 @@ export function estimateWebRowHeight( const registered = rowRenderer(row); if (registered) - return Math.max(0, registered.measure(availableWidth) + (registered.appliesSizePreset ? sizeModifier(row) : 0)); + return Math.max( + 0, + registered.measure(availableWidth) + + (registered.appliesSizePreset ? sizeModifier(row) : 0) + ); let base: number; switch (row.type) { - case 'activity': - base = row.footerActions?.length ? 100 : 60; - break; case 'metricCard': base = row.variant === 'activity' @@ -2611,20 +2612,23 @@ function createDataRow( return body; } -function createIdentityOrActivityRow( +function createIdentityRow( context: RenderContext, - row: Extract + row: Extract< + RowModel, + { + type: 'identity'; + } + > ): HTMLElement { - const presentation = row.type === 'identity' ? row.presentation : undefined; + const presentation = row.presentation; const body = createElement( context.document, 'div', [ 'ok-native-list-row', 'ok-native-list-standard', - row.type === 'identity' && !presentation - ? 'ok-native-list-identity-row' - : '', + !presentation ? 'ok-native-list-identity-row' : '', presentation === 'networkSelector' ? 'ok-native-list-network-row' : '', presentation === 'walletSidebar' ? 'ok-native-list-wallet-row' : '', presentation === 'accountSelector' ? 'ok-native-list-account-row' : '', @@ -2637,11 +2641,11 @@ function createIdentityOrActivityRow( 'nativeListSelector', row.height !== undefined ? presentation : undefined ); - if (row.type === 'identity' && row.titleActionKey && row.titleActionOnHover) { + if (row.titleActionKey && row.titleActionOnHover) { setData(body, 'nativeListHoverAction', row.titleActionKey); markActionAnchorSource(body, 'leadingAction'); } - if (row.type === 'identity' && row.leadingAction) { + if (row.leadingAction) { const action = createIconAction( context, row.leadingAction.name, @@ -2659,7 +2663,7 @@ function createIdentityOrActivityRow( ); if ( visual && - row.type === 'identity' && + true && row.height !== undefined && row.presentation === 'walletSidebar' && 'fallbackIcon' in row.leading && @@ -2683,7 +2687,7 @@ function createIdentityOrActivityRow( } if ( visual && - row.type === 'identity' && + true && row.height !== undefined && row.presentation === 'walletSidebar' && 'borderStyle' in row.leading && @@ -2691,29 +2695,22 @@ function createIdentityOrActivityRow( ) visual.style.borderWidth = '1px'; if (visual) body.appendChild(visual); - if (row.type === 'activity' && row.secondaryLeading) { - const secondVisual = createVisual(context, row.secondaryLeading); - if (secondVisual) body.appendChild(secondVisual); - } const title = row.title; - const subtitle = row.type === 'identity' ? row.subtitle : row.description; + const subtitle = row.subtitle; const column = createTextColumn( context, title, subtitle, - row.type === 'identity' ? row.tertiary : undefined, - row.type === 'identity' ? row.tertiaryTone : undefined, - row.type === 'identity' && presentation !== 'walletSidebar' - ? row.badges - : undefined, + row.tertiary, + row.tertiaryTone, + presentation !== 'walletSidebar' ? row.badges : undefined, { title: 'title', - subtitle: row.type === 'activity' ? 'description' : 'subtitle', + subtitle: 'subtitle', tertiary: 'tertiary', } ); - // OneKey patch: match existing search, subtitle fragments, and sidebar badges. - if (row.type === 'identity') { + { const titleElement = column.firstElementChild as HTMLElement; if (row.titleMatch?.length) { const textTarget = @@ -2795,75 +2792,8 @@ function createIdentityOrActivityRow( .forEach((text) => applyTextLayout(text, { lines })); } } - if (row.type === 'activity' && row.status) - column.appendChild( - tagSlot( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - row.status - ), - 'status' - ) - ); - if (row.type === 'activity' && row.footerActions?.length) { - const actions = createElement( - context.document, - 'span', - 'ok-native-list-actions' - ); - row.footerActions.forEach((action, slot) => { - const button = createElement( - context.document, - 'button', - 'ok-native-list-action-button', - action.label - ); - button.setAttribute('type', 'button'); - button.toggleAttribute('disabled', Boolean(action.disabled)); - setData(button, 'tone', action.tone); - setData(button, 'nativeListAction', action.key); - markActionAnchorSource(button, 'footerAction', slot); - actions.appendChild(button); - }); - column.appendChild(actions); - } body.appendChild(column); - if (row.type === 'activity') { - const amounts = createElement( - context.document, - 'span', - 'ok-native-list-amounts' - ); - if (row.primaryAmount) - amounts.appendChild( - tagSlot( - createElement( - context.document, - 'span', - 'ok-native-list-value', - row.primaryAmount - ), - 'primaryAmount' - ) - ); - if (row.secondaryAmount) - amounts.appendChild( - tagSlot( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - row.secondaryAmount - ), - 'secondaryAmount' - ) - ); - body.appendChild(amounts); - } else { - appendAccessories(body, context, row.key, row.trailing); - } + appendAccessories(body, context, row.key, row.trailing); return body; } @@ -2902,8 +2832,8 @@ function createWalletGroupRow( setData(memberElement, 'nativeListGroupParent', memberIndex === 0); setData(memberElement, 'nativeListSelected', member.selected); // OneKey patch: grouped members have the same selector typography as standalone wallets. - // memberElement.appendChild(createIdentityOrActivityRow(context, member)); - const memberBody = createIdentityOrActivityRow(context, member); + // memberElement.appendChild(createIdentityRow(context, member)); + const memberBody = createIdentityRow(context, member); applySelectorTabularNumbers(memberBody, member); applyRowStyle(memberBody, member); memberElement.appendChild(memberBody); @@ -3675,8 +3605,7 @@ export function createRowBody( case 'market': return createMarketRow(context, row); case 'identity': - case 'activity': - return createIdentityOrActivityRow(context, row); + return createIdentityRow(context, row); } throw new Error('No renderer registered for ' + row.type); } @@ -3712,6 +3641,7 @@ export class NativeListWebEngine { mediaTile: [], action: [], system: [], + activity: [], }; private readonly rendererKeys = new WeakMap(); private frameHandle: number | undefined; diff --git a/native-views/react-native-native-list/src/web/templates/ActionRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/ActionRowRenderer.ts index 767f6abe4..c4cb0bd07 100644 --- a/native-views/react-native-native-list/src/web/templates/ActionRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/ActionRowRenderer.ts @@ -47,8 +47,8 @@ function bind(body: HTMLElement, row: ActionRow, primitives: RowPrimitives) { const size = row.presentation === 'accountSelector' ? 32 : 40; v.visualStyle.apply( style?.image, - size, - size, + style?.image?.width ?? size, + style?.image?.height ?? size, (style?.leadingGap ?? 12) - 12 ); } diff --git a/native-views/react-native-native-list/src/web/templates/ActivityRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/ActivityRowRenderer.ts new file mode 100644 index 000000000..2d547c76b --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/ActivityRowRenderer.ts @@ -0,0 +1,159 @@ +import type { ActivityRow } from '../../models'; +import { createElement, tagSlot, markActionAnchorSource } from './RowElements'; +import { RowVisualStyle, type RowPrimitives } from './RowVisual'; +type Views = { + column: HTMLElement; + fields: Record; + amounts: HTMLElement; + actions: HTMLElement; + visuals: HTMLElement[]; + visualStyles: RowVisualStyle[]; + visualKey: string; + primitives: RowPrimitives; +}; +const views = new WeakMap(); +function bind(body: HTMLElement, row: ActivityRow, primitives: RowPrimitives) { + const v = views.get(body)!; + v.primitives = primitives; + body.style.cssText = ''; + body.removeAttribute('data-nl-container-background'); + body.removeAttribute('data-nl-container-border'); + const style = row.style; + if (style?.horizontalPadding !== undefined) + body.style.paddingInline = `${style.horizontalPadding}px`; + if (style?.verticalPadding !== undefined) + body.style.paddingBlock = `${style.verticalPadding}px`; + v.column.style.rowGap = + style?.lineGap !== undefined ? `${style.lineGap}px` : ''; + v.amounts.style.gap = + style?.trailingGap !== undefined ? `${style.trailingGap}px` : ''; + for (const key of [ + 'title', + 'description', + 'status', + 'primaryAmount', + 'secondaryAmount', + ] as const) { + const node = v.fields[key]!; + node.style.cssText = ''; + node.textContent = row[key] ?? ''; + node.hidden = !row[key]; + if (style?.[key]) primitives.textStyle(node, style[key]!); + } + const visualKey = JSON.stringify([ + row.key, + row.leading, + row.secondaryLeading, + ]); + if (v.visualKey !== visualKey) { + v.visuals.forEach((node) => { + primitives.dispose(node); + node.remove(); + }); + v.visuals = [row.leading, row.secondaryLeading] + .map((source) => primitives.visual(source)) + .filter((node): node is HTMLElement => !!node); + v.visuals.forEach((node) => body.insertBefore(node, v.column)); + v.visualStyles = v.visuals.map((node) => new RowVisualStyle(node)); + v.visualKey = visualKey; + } + if (v.visualStyles[0]) + v.visualStyles[0].apply( + style?.image, + style?.image?.width ?? 40, + style?.image?.height ?? 40, + (style?.leadingGap ?? 12) - 12 + ); + v.actions.replaceChildren(); + v.actions.hidden = !row.footerActions?.length; + row.footerActions?.forEach((action, index) => { + const button = createElement( + body.ownerDocument, + 'button', + 'ok-native-list-action-button', + action.label + ); + button.setAttribute('type', 'button'); + button.toggleAttribute('disabled', Boolean(action.disabled)); + button.dataset.tone = action.tone ?? ''; + button.dataset.nativeListAction = action.key; + markActionAnchorSource(button, 'footerAction', index); + v.actions.appendChild(button); + }); +} +function create( + document: Document, + row: ActivityRow, + primitives: RowPrimitives +) { + const body = createElement( + document, + 'div', + 'ok-native-list-row ok-native-list-standard' + ); + body.dataset.nlRenderer = 'activity'; + const column = createElement(document, 'span', 'ok-native-list-flex'); + const amounts = createElement(document, 'span', 'ok-native-list-amounts'); + const actions = createElement(document, 'span', 'ok-native-list-actions'); + const fields: Record = {}; + for (const key of [ + 'title', + 'description', + 'status', + 'primaryAmount', + 'secondaryAmount', + ]) { + const node = tagSlot( + createElement( + document, + 'span', + key === 'title' + ? 'ok-native-list-title' + : key === 'primaryAmount' + ? 'ok-native-list-value' + : 'ok-native-list-secondary' + ), + key + ); + fields[key] = node; + (key.endsWith('Amount') ? amounts : column).appendChild(node); + } + column.appendChild(actions); + body.append(column, amounts); + views.set(body, { + column, + amounts, + actions, + fields, + visuals: [], + visualStyles: [], + visualKey: '', + primitives, + }); + bind(body, row, primitives); + return body; +} +function recycle(body: HTMLElement) { + const v = views.get(body); + if (!v) return; + v.visuals.forEach((node) => { + v.primitives.dispose(node); + node.remove(); + }); + v.visuals = []; + v.visualStyles = []; + v.visualKey = ''; + v.actions.replaceChildren(); +} +export const activityRowRenderer = { + key: 'activity' as const, + create, + bind, + recycle, + measure: (row: ActivityRow, _width: number) => + row.footerActions?.length ? 100 : 60, + measureRendered: ( + _body: HTMLElement, + _row: ActivityRow + ): number | undefined => undefined, +}; diff --git a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts index e76eacb3d..4c4c95371 100644 --- a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts +++ b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts @@ -1,3 +1,5 @@ +import type { ActivityRow } from '../../models'; +import { activityRowRenderer } from './ActivityRowRenderer'; import type { SystemRow } from '../../models'; import { systemRowRenderer } from './SystemRowRenderer'; import type { ActionRow } from '../../models'; @@ -34,6 +36,8 @@ function registration(renderer: Renderer, row: R) { }; } const factories = { + activity: (row: RowModel) => + registration(activityRowRenderer, row as ActivityRow), system: (row: RowModel) => registration(systemRowRenderer, row as SystemRow), action: (row: RowModel) => registration(actionRowRenderer, row as ActionRow), message: (row: RowModel) => @@ -43,6 +47,7 @@ const factories = { registration(mediaTileRowRenderer, row as MediaTileRow), }; const renderers = { + activity: activityRowRenderer, system: systemRowRenderer, action: actionRowRenderer, message: messageRowRenderer, From a22a9dcaf128f42d7fb9f976d3f9f395e52c38ac Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 20:17:25 +0800 Subject: [PATCH 43/71] refactor(native-list): migrate DataRow columns and selection --- .../nativelist/NativeListDataRowRenderer.kt | 383 ++++++++++++++++++ .../com/onekey/nativelist/NativeListModels.kt | 8 +- .../nativelist/NativeListRendererRegistry.kt | 2 + .../nativelist/NativeListRendererRowView.kt | 24 +- .../onekey/nativelist/NativeListRowView.kt | 236 +---------- .../nativelist/NativeListStyleSlotTest.kt | 3 - .../react-native-native-list/docs/DESIGN.md | 16 + .../react-native-native-list/docs/SPEC.md | 2 +- .../ios/NativeListCell.swift | 279 +------------ .../ios/NativeListDataRowRenderer.swift | 300 ++++++++++++++ .../ios/NativeListModels.swift | 2 +- .../ios/NativeListRendererCell.swift | 7 +- .../ios/NativeListRendererRegistry.swift | 5 + .../ios/RNCNativeListView.swift | 11 +- .../src/__tests__/NativeList.web.test.ts | 87 +++- .../src/web/NativeListWebEngine.ts | 98 +---- .../src/web/templates/DataRowRenderer.ts | 206 ++++++++++ .../src/web/templates/RowRendererRegistry.ts | 4 + 18 files changed, 1040 insertions(+), 633 deletions(-) create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt create mode 100644 native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift create mode 100644 native-views/react-native-native-list/src/web/templates/DataRowRenderer.ts diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt new file mode 100644 index 000000000..70c3b1dfb --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt @@ -0,0 +1,383 @@ +package com.margelo.nitro.nativelist + +import android.graphics.drawable.GradientDrawable +import android.text.TextUtils +import android.view.Gravity +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.widget.TextViewCompat +import com.facebook.react.uimanager.ThemedReactContext +import kotlin.math.roundToInt +import org.json.JSONArray +import org.json.JSONObject + +internal class NativeListDataRowView(context: ThemedReactContext) : + NativeListRendererRowView(context) { + private val visual = NativeListLeadingVisual(context) + private val favorite = OneKeyIconView(context) + private val accessories = NativeListAccessoryStack(context) + private val columns = + LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + private val cells = List(4) { NativeListTableColumnView(context) } + + override fun unselectedBackground( + item: NativeListItem, + theme: JSONObject?, + layout: String, + itemIndex: Int?, + ): Int { + val index = if (item.json.has("index")) item.json.optInt("index") else itemIndex + return if (layout == "table" && index?.rem(2) == 0) + parseNativeListColor(theme?.optString("subduedBackground", "#F9F9F9") ?: "#F9F9F9") + else super.unselectedBackground(item, theme, layout, itemIndex) + } + + override fun backgroundGroupPosition( + item: NativeListItem, + layout: String, + itemIndex: Int?, + selected: Boolean, + ): String { + val index = if (item.json.has("index")) item.json.optInt("index") else itemIndex + return if (layout == "table" && index?.rem(2) == 0 && !selected) "" + else super.backgroundGroupPosition(item, layout, itemIndex, selected) + } + + override val assetFields = listOf("leading") + + init { + cells.forEach(columns::addView) + addView(favorite) + addView(visual) + addView(accessories) + addView(columns, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + accessories.onAction = { key, view, slot, target -> + emitAction(key, view, "trailingAccessory", slot, target) + } + } + + override fun defaultHeight(item: NativeListItem, layout: String): Int { + val values = item.json.optJSONArray("columns") ?: JSONArray() + val secondary = + (0 until values.length()).any { + values.getJSONObject(it).optString("secondaryText").isNotEmpty() + } + return if (layout == "table") if (secondary) 60 else 52 else if (secondary) 64 else 56 + } + + override fun bindContent( + item: NativeListItem, + theme: JSONObject?, + layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + val data = item.json + val style = data.optJSONObject("style") ?: JSONObject() + val image = style.optJSONObject("image") ?: JSONObject() + orientation = HORIZONTAL + gravity = + when (style.optJSONObject("container")?.optString("contentVerticalAlignment")) { + "top" -> Gravity.TOP + "bottom" -> Gravity.BOTTOM + else -> Gravity.CENTER_VERTICAL + } + val hp = + if (style.has("horizontalPadding")) stylePx(style.optDouble("horizontalPadding")) + else dp(if (layout == "table") 16 else 12) + val vp = + if (style.has("verticalPadding")) stylePx(style.optDouble("verticalPadding")) else dp(8) + setPadding(hp, vp, hp, vp) + val active = data.optBoolean("favoriteActive") + favorite.visibility = if (active || data.optBoolean("favorite")) VISIBLE else GONE + favorite.iconName = if (active) "StarSolid" else "StarOutline" + favorite.useSourceScale = sourceScale + favorite.tintColor = + parseNativeListColor( + theme?.optString( + if (active) "icon" else "iconSubdued", + if (active) "#0000009B" else "#00000072", + ) ?: if (active) "#0000009B" else "#00000072" + ) + favorite.layoutParams = + LayoutParams(dp(20), dp(20)).apply { marginEnd = dp(if (layout == "table") 8 else 12) } + val leading = data.optJSONObject("leading") + visual.visibility = if (leading == null) GONE else VISIBLE + visual.layoutParams = + LayoutParams( + if (image.has("width")) stylePx(image.optDouble("width")) else dp(40), + if (image.has("height")) stylePx(image.optDouble("height")) else dp(40), + ) + .apply { + marginEnd = + if (style.has("leadingGap")) stylePx(style.optDouble("leadingGap")) + else dp(if (layout == "table") 10 else 12) + } + if (leading != null) visual.bind(leading, image, item.key, theme, false, sourceScale) + else visual.recycle() + val descriptors = JSONArray() + if (data.has("index")) + descriptors.put( + JSONObject().put("kind", "value").put("text", data.optInt("index").toString()) + ) + data.optJSONObject("checkbox")?.let(descriptors::put) + val accessoryStyle = JSONObject().put("value", style.optJSONObject("index")) + accessories.bind(item, descriptors, theme, accessoryStyle, sourceScale, checkboxState) + accessories.visibility = if (descriptors.length() == 0) GONE else VISIBLE + accessories.layoutParams = + LayoutParams(dp(32), LayoutParams.WRAP_CONTENT).apply { marginEnd = dp(10) } + val values = data.getJSONArray("columns") + cells.forEachIndexed { index, cell -> + cell.visibility = if (index < values.length()) VISIBLE else GONE + if (index < values.length()) { + val value = values.getJSONObject(index) + cell.bind( + value, + if (index == 0) data.optJSONArray("badges") else null, + dataColor(value.optString("tone"), theme), + dataColor(value.optString("secondaryTone", "secondary"), theme), + parseNativeListColor(theme?.optString("info", "#006DCBF2") ?: "#006DCBF2"), + if (layout == "table") 14f else 16f, + style, + ) + cell.layoutParams = + LayoutParams(0, LayoutParams.WRAP_CONTENT, value.optInt("weight", 1).toFloat()) + } else cell.reset() + } + } + + private fun dataColor(tone: String, theme: JSONObject?): Int { + val token = + when (tone) { + "positive" -> "positive" + "negative" -> "negative" + "secondary" -> "secondaryText" + "disabled" -> "disabledText" + "caution" -> "caution" + else -> "primaryText" + } + val fallback = + when (token) { + "positive" -> "#00713FDE" + "negative" -> "#C40006D3" + "secondaryText" -> "#0000009B" + "disabledText" -> "#00000072" + "caution" -> "#AB6400" + else -> "#000000DF" + } + return parseNativeListColor(theme?.optString(token, fallback) ?: fallback) + } + + override fun bindSelectionContent( + item: NativeListItem, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + accessories.updateSelection(item, checkboxState) + } + + override fun recycleContent() { + visual.recycle() + accessories.reset() + cells.forEach { it.reset() } + } + + override fun disposeContent() { + visual.dispose() + accessories.reset() + } +} + +private class NativeListTableColumnView(context: android.content.Context) : LinearLayout(context) { + private val primaryLine = LinearLayout(context) + private val primary = NativeListTextView(context) + private val badges = LinearLayout(context) + private val secondaryLine = LinearLayout(context) + private val secondaryLeading = NativeListTextView(context) + private val secondary = NativeListTextView(context) + + init { + orientation = VERTICAL + primaryLine.orientation = HORIZONTAL + primaryLine.gravity = Gravity.CENTER_VERTICAL + badges.orientation = HORIZONTAL + badges.gravity = Gravity.CENTER_VERTICAL + primary.includeFontPadding = false + primary.fontFeatureSettings = "tnum" + primary.maxLines = 1 + primary.ellipsize = TextUtils.TruncateAt.END + primary.textSize = sp(14f) + primary.typeface = NativeListFonts.medium(context) + TextViewCompat.setLineHeight(primary, dp(20)) + secondaryLine.orientation = HORIZONTAL + secondaryLine.gravity = Gravity.CENTER_VERTICAL + listOf(secondaryLeading, secondary).forEach { label -> + label.includeFontPadding = false + label.fontFeatureSettings = "tnum" + label.maxLines = 1 + label.ellipsize = TextUtils.TruncateAt.END + label.textSize = sp(12f) + label.typeface = NativeListFonts.regular(context) + TextViewCompat.setLineHeight(label, dp(16)) + } + secondaryLeading.maxWidth = dp(120) + secondaryLine.addView( + secondaryLeading, + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT), + ) + secondaryLine.addView( + secondary, + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + marginStart = dp(4) + }, + ) + primaryLine.addView(primary, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + primaryLine.addView( + badges, + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + marginStart = dp(6) + }, + ) + addView(primaryLine, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) + addView( + secondaryLine, + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { topMargin = dp(4) }, + ) + } + + fun reset() { + primaryLine.layoutParams.width = LayoutParams.WRAP_CONTENT + primary.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + secondary.layoutParams = + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + marginStart = dp(4) + } + (secondaryLine.layoutParams as MarginLayoutParams).topMargin = dp(4) + (badges.layoutParams as MarginLayoutParams).marginStart = dp(6) + primary.text = "" + secondaryLeading.text = "" + secondary.text = "" + secondaryLeading.visibility = GONE + secondary.visibility = GONE + secondaryLine.visibility = GONE + badges.removeAllViews() + badges.visibility = GONE + } + + fun bind( + column: JSONObject, + rowBadges: JSONArray?, + primaryColor: Int, + secondaryColor: Int, + infoColor: Int, + primarySize: Float, + style: JSONObject, + ) { + reset() + gravity = + when (column.optString("alignment", "start")) { + "center" -> Gravity.CENTER_HORIZONTAL + "end" -> Gravity.END + else -> Gravity.START + } + fun bindText( + view: NativeListTextView, + value: String, + slot: String, + size: Float, + weight: String, + color: Int, + line: Int, + ) { + val resolved = + JSONObject(style.optJSONObject(slot)?.toString() ?: "{}").apply { + if (!has("alignment")) put("alignment", column.optString("alignment", "start")) + } + NativeListResolvedText.resolve(context, value, resolved, size, weight, color, line, 1, false) + .bind(view) + if (resolved.optString("truncate") != "clip") view.ellipsize = TextUtils.TruncateAt.END + } + bindText(primary, column.optString("text"), "columns", primarySize, "medium", primaryColor, 20) + bindText( + secondaryLeading, + column.optString("secondaryLeadingText"), + "columnSecondary", + 12f, + "regular", + secondaryColor, + 16, + ) + bindText( + secondary, + column.optString("secondaryText"), + "columnSecondary", + 12f, + "regular", + secondaryColor, + 16, + ) + if (rowBadges != null && rowBadges.length() > 0) { + badges.visibility = VISIBLE + for (index in 0 until minOf(2, rowBadges.length())) { + val badge = + TextView(context).apply { + includeFontPadding = false + fontFeatureSettings = "tnum" + gravity = Gravity.CENTER + text = rowBadges.getJSONObject(index).optString("text") + textSize = sp(10f) + typeface = NativeListFonts.regular(context) + setTextColor(infoColor) + setPadding(dp(6), 0, dp(6), 0) + background = + GradientDrawable().apply { + setColor(parseNativeListColor("#008FF519")) + cornerRadius = dp(4).toFloat() + } + } + badges.addView( + badge, + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + if (index > 0) marginStart = dp(4) + }, + ) + } + } + val secondaryLeadingText = column.optString("secondaryLeadingText") + if (secondaryLeadingText.isNotEmpty()) { + secondaryLeading.visibility = VISIBLE + secondaryLine.visibility = VISIBLE + } + val secondaryText = column.optString("secondaryText") + if (secondaryText.isNotEmpty()) { + secondary.visibility = VISIBLE + secondaryLine.visibility = VISIBLE + } + if (style.length() > 0) applyStyle(style) + } + + private fun applyStyle(style: JSONObject) { + style.optJSONObject("columns")?.let { + if (it.has("alignment")) { + primaryLine.layoutParams.width = LayoutParams.MATCH_PARENT + primary.layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) + } + } + style.optJSONObject("columnSecondary")?.let { + if (it.has("alignment")) + secondary.layoutParams = + LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f).apply { marginStart = dp(4) } + } + (secondaryLine.layoutParams as MarginLayoutParams).topMargin = + style.optDouble("lineGap", 4.0).let { (it * resources.displayMetrics.density).roundToInt() } + (badges.layoutParams as MarginLayoutParams).marginStart = + style.optDouble("titleBadgeGap", 6.0).let { + (it * resources.displayMetrics.density).roundToInt() + } + } + + private fun dp(value: Int): Int = NativeListScale.dp(resources, value) + + private fun sp(value: Float): Float = NativeListScale.font(resources, value) +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index 76a72eed0..63a0816d2 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -41,12 +41,6 @@ internal fun nativeListStyleSlot(type: String, variant: String, field: String): "title", "subtitle", "tertiary", "badge", "value", "valueSecondary" -> field else -> null } - "dataRow" -> when (field) { - "columns" -> "dataPrimary" - "columnSecondary" -> "dataSecondary" - "index" -> "value" - else -> null - } // The large number and the small label sit in swapped views. "metricCard" -> when (field) { "value" -> "title" @@ -87,7 +81,7 @@ private fun updateLengthPrefixed(digest: MessageDigest, value: String) { // Message and Rail own dedicated renderers. Remaining templates still share // a compatible legacy tree; keys and style values never partition the pool. -internal enum class NativeListRendererKey { LEGACY, MESSAGE, RAIL, MEDIA_TILE, ACTION, SYSTEM, ACTIVITY } +internal enum class NativeListRendererKey { LEGACY, MESSAGE, RAIL, MEDIA_TILE, ACTION, SYSTEM, ACTIVITY, DATA_ROW } internal data class NativeListItem( val key: String, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt index d77c8936a..b15d9a98d 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt @@ -9,6 +9,7 @@ internal object NativeListRendererRegistry { NativeListRendererKey.MESSAGE to ::NativeListMessageRowView, NativeListRendererKey.RAIL to ::NativeListRailRowView, NativeListRendererKey.MEDIA_TILE to ::NativeListMediaTileRowView, + NativeListRendererKey.DATA_ROW to ::NativeListDataRowView, NativeListRendererKey.ACTIVITY to ::NativeListActivityRowView, NativeListRendererKey.SYSTEM to ::NativeListSystemRowView, NativeListRendererKey.ACTION to ::NativeListActionRowView, @@ -19,6 +20,7 @@ internal object NativeListRendererRegistry { "message" to NativeListRendererKey.MESSAGE, "rail" to NativeListRendererKey.RAIL, "mediaTile" to NativeListRendererKey.MEDIA_TILE, + "dataRow" to NativeListRendererKey.DATA_ROW, "activity" to NativeListRendererKey.ACTIVITY, "system" to NativeListRendererKey.SYSTEM, "action" to NativeListRendererKey.ACTION, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt index dea18a9e6..0b52d37bc 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt @@ -79,6 +79,22 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th private var current: NativeListItem? = null private var theme: JSONObject? = null private var listLayout = "linear" + private var itemIndex: Int? = null + + protected open fun unselectedBackground( + item: NativeListItem, + theme: JSONObject?, + layout: String, + itemIndex: Int?, + ) = color(theme?.optString("rowBackground", "#FFFFFF") ?: "#FFFFFF") + + protected open fun backgroundGroupPosition( + item: NativeListItem, + layout: String, + itemIndex: Int?, + selected: Boolean, + ) = item.json.optString("groupPosition") + private var selected = false protected var sourceScale = false private var inputSignature = "" @@ -149,6 +165,7 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th tag = item this.theme = theme listLayout = layout + this.itemIndex = itemIndex this.selected = selected explicitHeight = item.styledHeight?.let(::stylePx) ?: modelHeight(item, layout) val sizeDelta = @@ -198,6 +215,7 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th this.theme = theme this.selected = selected listLayout = layout + this.itemIndex = itemIndex bindSelectionContent(item, checkboxState) appearance() } @@ -233,14 +251,16 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th val backgroundFallback = if (pressed && pressChangesBackground) pressedColorFallback else if (showSelected) "#0000000F" else "#FFFFFF" - var fill = color(theme?.optString(backgroundName, backgroundFallback) ?: backgroundFallback) + var fill = + if (!pressed && !showSelected) unselectedBackground(item, theme, listLayout, itemIndex) + else color(theme?.optString(backgroundName, backgroundFallback) ?: backgroundFallback) if (!pressed || !pressChangesBackground) { if (item.json.has("backgroundColor")) fill = color(item.json.optString("backgroundColor"), fill) if (container.has("backgroundColor")) fill = color(container.optString("backgroundColor"), fill) } - val position = item.json.optString("groupPosition") + val position = backgroundGroupPosition(item, listLayout, itemIndex, showSelected) val radius = if (container.has("cornerRadius")) stylePx(container.optDouble("cornerRadius")).toFloat() else if (defaultCornerRadius > 0) dp(defaultCornerRadius).toFloat() diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index 7864b8829..8bd0ed058 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -203,148 +203,6 @@ private class SelectorSubtitleLayout(context: android.content.Context) : LinearL } } -private class NativeListTableColumnView(context: android.content.Context) : LinearLayout(context) { - private val primaryLine = LinearLayout(context) - private val primary = NativeListTextView(context) - private val badges = LinearLayout(context) - private val secondaryLine = LinearLayout(context) - private val secondaryLeading = NativeListTextView(context) - private val secondary = NativeListTextView(context) - - init { - orientation = VERTICAL - primaryLine.orientation = HORIZONTAL - primaryLine.gravity = Gravity.CENTER_VERTICAL - badges.orientation = HORIZONTAL - badges.gravity = Gravity.CENTER_VERTICAL - primary.includeFontPadding = false - primary.fontFeatureSettings = "tnum" - primary.maxLines = 1 - primary.ellipsize = TextUtils.TruncateAt.END - primary.textSize = sp(14f) - primary.typeface = NativeListFonts.medium(context) - TextViewCompat.setLineHeight(primary, dp(20)) - secondaryLine.orientation = HORIZONTAL - secondaryLine.gravity = Gravity.CENTER_VERTICAL - listOf(secondaryLeading, secondary).forEach { label -> - label.includeFontPadding = false - label.fontFeatureSettings = "tnum" - label.maxLines = 1 - label.ellipsize = TextUtils.TruncateAt.END - label.textSize = sp(12f) - label.typeface = NativeListFonts.regular(context) - TextViewCompat.setLineHeight(label, dp(16)) - } - secondaryLeading.maxWidth = dp(120) - secondaryLine.addView(secondaryLeading, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) - secondaryLine.addView(secondary, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { - marginStart = dp(4) - }) - primaryLine.addView(primary, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) - primaryLine.addView(badges, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { - marginStart = dp(6) - }) - addView(primaryLine, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) - addView(secondaryLine, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { - topMargin = dp(4) - }) - } - - fun reset() { - primaryLine.layoutParams.width = LayoutParams.WRAP_CONTENT - primary.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) - secondary.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { marginStart = dp(4) } - (secondaryLine.layoutParams as MarginLayoutParams).topMargin = dp(4) - (badges.layoutParams as MarginLayoutParams).marginStart = dp(6) - primary.text = "" - secondaryLeading.text = "" - secondary.text = "" - secondaryLeading.visibility = GONE - secondary.visibility = GONE - secondaryLine.visibility = GONE - badges.removeAllViews() - badges.visibility = GONE - } - - fun bind( - column: JSONObject, - rowBadges: JSONArray?, - primaryColor: Int, - secondaryColor: Int, - infoColor: Int, - primarySize: Float, - ) { - reset() - primary.textSize = sp(primarySize) - gravity = when (column.optString("alignment", "start")) { - "center" -> Gravity.CENTER_HORIZONTAL - "end" -> Gravity.END - else -> Gravity.START - } - primary.gravity = gravity - secondaryLeading.gravity = gravity - secondary.gravity = gravity - primary.text = column.optString("text") - primary.setTextColor(primaryColor) - if (rowBadges != null && rowBadges.length() > 0) { - badges.visibility = VISIBLE - for (index in 0 until minOf(2, rowBadges.length())) { - val badge = TextView(context).apply { - includeFontPadding = false - fontFeatureSettings = "tnum" - gravity = Gravity.CENTER - text = rowBadges.getJSONObject(index).optString("text") - textSize = sp(10f) - typeface = NativeListFonts.regular(context) - setTextColor(infoColor) - setPadding(dp(6), 0, dp(6), 0) - background = GradientDrawable().apply { - setColor(parseNativeListColor("#008FF519")) - cornerRadius = dp(4).toFloat() - } - } - badges.addView(badge, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { - if (index > 0) marginStart = dp(4) - }) - } - } - val secondaryLeadingText = column.optString("secondaryLeadingText") - if (secondaryLeadingText.isNotEmpty()) { - secondaryLeading.text = secondaryLeadingText - secondaryLeading.setTextColor(secondaryColor) - secondaryLeading.visibility = VISIBLE - secondaryLine.visibility = VISIBLE - } - val secondaryText = column.optString("secondaryText") - if (secondaryText.isNotEmpty()) { - secondary.text = secondaryText - secondary.setTextColor(secondaryColor) - secondary.visibility = VISIBLE - secondaryLine.visibility = VISIBLE - } - } - - fun applyStyle(style: JSONObject, applyText: (TextView, JSONObject) -> Unit) { - style.optJSONObject("columns")?.let { - applyText(primary, it) - if (it.has("alignment")) { - primaryLine.layoutParams.width = LayoutParams.MATCH_PARENT - primary.layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) - } - } - style.optJSONObject("columnSecondary")?.let { - applyText(secondaryLeading, it) - applyText(secondary, it) - if (it.has("alignment")) secondary.layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f).apply { marginStart = dp(4) } - } - (secondaryLine.layoutParams as MarginLayoutParams).topMargin = style.optDouble("lineGap", 4.0).let { (it * resources.displayMetrics.density).roundToInt() } - (badges.layoutParams as MarginLayoutParams).marginStart = style.optDouble("titleBadgeGap", 6.0).let { (it * resources.displayMetrics.density).roundToInt() } - } - - private fun dp(value: Int): Int = NativeListScale.dp(resources, value) - private fun sp(value: Float): Float = NativeListScale.font(resources, value) -} - internal class NativeListRowView( private val reactContext: ThemedReactContext, ) : NativeListRowHost(reactContext) { @@ -380,7 +238,6 @@ internal class NativeListRowView( private val leadingCornerIcon = OneKeyIconView(context) private val leadingFallback = TextView(context) private val leadingIcon = OneKeyIconView(context) - private val favoriteIcon = OneKeyIconView(context) private val headerTitleIcon = OneKeyIconView(context) private val headerValueIcon = OneKeyIconView(context) private val leadingActionIcon = OneKeyIconView(context) @@ -405,8 +262,6 @@ internal class NativeListRowView( private val trailingIcons = List(2) { OneKeyIconView(context) } private val checkbox = OneKeyCheckboxView(context) private val spinner = ProgressBar(context) - private val tableDataContainer = LinearLayout(context) - private val tableDataColumns = List(4) { NativeListTableColumnView(context) } private val unreadDot = View(context) private val walletGroupRows = mutableListOf() private val walletGroupDragBadgeBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG) @@ -481,7 +336,6 @@ internal class NativeListRowView( leadingFrame.clipChildren = false leadingFrame.clipToPadding = false leadingIcon.layoutParams = FrameLayout.LayoutParams(dp(18), dp(18), Gravity.CENTER) - favoriteIcon.layoutParams = LayoutParams(dp(24), dp(24)) leadingActionIcon.layoutParams = LayoutParams(dp(24), dp(24)) leadingCornerIcon.layoutParams = FrameLayout.LayoutParams(dp(18), dp(18), Gravity.CENTER) unreadDot.layoutParams = FrameLayout.LayoutParams(dp(8), dp(8), Gravity.TOP or Gravity.END) @@ -537,9 +391,7 @@ internal class NativeListRowView( trailingColumn.addView(checkbox) trailingColumn.addView(spinner) - tableDataContainer.orientation = HORIZONTAL - tableDataContainer.gravity = Gravity.CENTER_VERTICAL - tableDataColumns.forEach { tableDataContainer.addView(it) } + secondaryImage.outlineProvider = object : ViewOutlineProvider() { override fun getOutline(view: View, outline: Outline) { @@ -896,7 +748,6 @@ internal class NativeListRowView( when (item.type) { "walletGroup" -> bindWalletGroup(item, theme, layout, listOrientation, checkboxState) "identity" -> bindIdentity(item, theme, selected, checkboxState) - "dataRow" -> bindDataRow(item, theme, checkboxState) "market" -> bindMarket(item, theme) "metricCard" -> bindMetricCard(item, theme) "sectionHeader" -> bindSectionHeader(item, theme, checkboxState) @@ -1041,7 +892,7 @@ internal class NativeListRowView( } } - if (item.type == "dataRow") tableDataColumns.forEach { it.applyStyle(style, ::applyStyledText) } + val variant = item.json.optString("variant") if (item.type == "metricCard" && variant in setOf("activity", "performance")) { @@ -1056,7 +907,6 @@ internal class NativeListRowView( val slotStyle = style.optJSONObject(field) ?: continue when (val slot = nativeListStyleSlot(item.type, variant, field)) { null -> continue - "dataPrimary", "dataSecondary" -> Unit // Independent column labels are styled above. "value", "valueSecondary" -> { val view = if (item.type == "identity") semanticValueViews.getOrNull(if (slot == "value") 0 else 1) else styledSlotView(slot) view?.let { applyStyledText(it, slotStyle) } @@ -1568,8 +1418,6 @@ internal class NativeListRowView( leadingIcon.iconName = "" leadingIcon.layoutParams = FrameLayout.LayoutParams(dp(18), dp(18), Gravity.CENTER) leadingIcon.glyphSizeDp = null - favoriteIcon.visibility = GONE - favoriteIcon.iconName = "" headerTitleIcon.visibility = GONE headerTitleIcon.iconName = "" headerValueIcon.visibility = GONE @@ -1581,11 +1429,7 @@ internal class NativeListRowView( leadingActionIcon.setTag(com.facebook.react.R.id.react_test_id, null) leadingActionIcon.contentDescription = null mainColumn.visibility = VISIBLE - tableDataContainer.visibility = GONE - tableDataColumns.forEach { - it.reset() - it.visibility = GONE - } + leadingOverlayBackground.visibility = GONE leadingCornerIconFrame.visibility = GONE leadingCornerIcon.iconName = "" @@ -2017,64 +1861,6 @@ internal class NativeListRowView( } - private fun bindDataRow( - item: NativeListItem, - theme: JSONObject?, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - ) { - if ( - item.json.optBoolean("favorite", false) || - item.json.optBoolean("favoriteActive", false) - ) { - val favoriteActive = item.json.optBoolean("favoriteActive", false) - favoriteIcon.visibility = VISIBLE - favoriteIcon.iconName = if (favoriteActive) "StarSolid" else "StarOutline" - favoriteIcon.tintColor = color( - theme, - if (favoriteActive) "icon" else "iconSubdued", - if (favoriteActive) "#0000009B" else "#00000072", - ) - addView(favoriteIcon, LayoutParams(dp(20), dp(20)).apply { - marginEnd = dp(if (currentLayout == "table") 8 else 12) - }) - } - item.json.optJSONObject("leading")?.let { - addLeading(it, 40, spacingDp = if (currentLayout == "table") 10 else 12) - } - var hasLeadingAccessory = false - item.json.optJSONObject("checkbox")?.let { - bindCheckbox(item, it, checkboxState) - hasLeadingAccessory = true - } - if (item.json.has("index")) { - val indexView = trailingViews[0] - indexView.text = item.json.optInt("index").toString() - indexView.visibility = VISIBLE - hasLeadingAccessory = true - } - if (hasLeadingAccessory) { - addView(trailingColumn, LayoutParams(dp(32), LayoutParams.WRAP_CONTENT).apply { marginEnd = dp(10) }) - } - val columns = item.json.getJSONArray("columns") - val rowBadges = item.json.optJSONArray("badges") - tableDataContainer.visibility = VISIBLE - for (index in 0 until minOf(4, columns.length())) { - val column = columns.getJSONObject(index) - tableDataColumns[index].visibility = VISIBLE - tableDataColumns[index].bind( - column = column, - rowBadges = if (index == 0) rowBadges else null, - primaryColor = dataTextColor(column.optString("tone"), theme), - secondaryColor = dataTextColor(column.optString("secondaryTone", "secondary"), theme), - infoColor = color(theme, "info", "#006DCBF2"), - primarySize = if (currentLayout == "table") 14f else 16f, - ) - tableDataColumns[index].layoutParams = LayoutParams( - 0, LayoutParams.WRAP_CONTENT, column.optInt("weight", 1).toFloat(), - ) - } - addView(tableDataContainer, weighted()) - } private fun marketTypeface(weight: String, fallback: String): Typeface = when ( weight.ifEmpty { fallback } @@ -3762,15 +3548,6 @@ internal class NativeListRowView( item.json.optString("value").isNotEmpty() && item.json.optJSONObject("checkbox") != null -> 40 else -> 36 } - "dataRow" -> if (currentLayout == "table") { - 60 - } else if ((0 until item.json.getJSONArray("columns").length()).any { - item.json.getJSONArray("columns").getJSONObject(it).optString("secondaryText").isNotEmpty() - }) { - 64 - } else { - 56 - } else -> when { item.type == "identity" && item.json.optString("presentation") == "walletSidebar" -> if ((item.json.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68 item.type == "identity" && item.json.optString("tertiary").isNotEmpty() -> 72 @@ -3789,12 +3566,7 @@ internal class NativeListRowView( when (item.json.optString("size", "medium")) { "small" -> -8; "large" -> 12; else -> 0 } } val sectionSpacing = 0 - val hasSecondaryColumn = item.type == "dataRow" && - (0 until item.json.getJSONArray("columns").length()).any { - item.json.getJSONArray("columns").getJSONObject(it).optString("secondaryText").isNotEmpty() - } - val tableAdjustment = if (item.type == "dataRow" && currentLayout == "table" && !hasSecondaryColumn) -8 else 0 - minimumHeight = dp((baseHeight + modifier + sectionSpacing + tableAdjustment).coerceAtLeast(0)) + minimumHeight = dp((baseHeight + modifier + sectionSpacing).coerceAtLeast(0)) } diff --git a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt index a0730a348..4b7a82e2c 100644 --- a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt +++ b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt @@ -27,9 +27,6 @@ class NativeListStyleSlotTest { Case(type = "rail", field = "status", expected = null), Case(type = "message", field = "time", expected = null), // Amounts, indices and values share the two trailing views. - Case(type = "dataRow", field = "index", expected = "value"), - Case(type = "dataRow", field = "columns", expected = "dataPrimary"), - Case(type = "dataRow", field = "columnSecondary", expected = "dataSecondary"), Case(type = "system", field = "title", variant = "retry", expected = null), Case(type = "sectionHeader", field = "value", expected = "value"), // Only the warning variant renders a separate title. diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index 842b2fb66..190205c7f 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -372,3 +372,19 @@ builds and Android unit tests pass; 157 package tests cover image retention, amount/style clearing, action replacement and Action image dimensions. Evidence is retained as `ios-activity-*`, `android-activity-*`, `web-activity-*` in the external runtime. Remaining stage 3 templates: DataRow and MetricCard. + +### Stage 3: DataRow acceptance (2026-09-23) + +DataRow owns four bounded column views, leading visual, favorite, index and +checkbox controls. Its table striping retains the effective row index and +platform defaults. Dedicated native hosts and the persistent Web body replace +the legacy table allocations, dispatch and style paths. + +The dedicated iOS/Android simulators and headed Chrome exercised selection +(callback counter increment), linear/table switching, style set/clear, +end/top scrolling and empty/repopulate with family replacement. Native default +captures preserve column geometry and existing truncation. The Web regression +changes three columns to two and back, clearing secondary labels and styles +while retaining the unchanged image. Native builds and Android unit tests +pass; all 158 package tests and typecheck pass. Evidence: external runtime +`ios-data-*`, `android-data-*`; MetricCard is the last stage 3 template. diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index 4792fd8ca..b4bbf36fb 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -78,7 +78,7 @@ separate workload and measurements. | --- | --- | --- | | 1. Baseline | Complete: [implicit keys, defaults, special updates and acceptance baseline](MIGRATION_BASELINE.md) inventoried | Preserve each existing rule or explicitly migrate its caller | | 2. Message pilot | Complete: registered lightweight native hosts, persistent Web body, resolved styling/measurement and lifecycle acceptance | Lightweight native hosts, resolved styling/measurement, update classification and lifecycle acceptance | -| 3. Simple templates | In progress (5/7): Rail, MediaTile, Action, System and Activity migrated on all three platforms; DataRow and MetricCard remain | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | +| 3. Simple templates | In progress (6/7): Rail, MediaTile, Action, System, Activity and DataRow migrated on all three platforms; MetricCard remains | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | | 4. Complex templates | Not started | Migrate Market, Identity and SectionHeader while preserving quote, selection and header behavior | | 5. WalletGroup | Not started | Independent member renderers; verify member events/styles, compact dragging and height restoration | | 6. Cleanup | Not started | Remove migrated key inference and global style maps; container no longer manipulates template internals | diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index 795f54845..214b85e64 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -187,186 +187,6 @@ private final class NativeListDottedUnderlineLabel: NativeListTextLabel { } } -private final class NativeListTableColumnView: UIStackView { - private let primaryLine = UIStackView() - private let primaryLabel = NativeListTextLabel() - private let badgesStack = UIStackView() - private let secondaryLine = UIStackView() - private let secondaryLeadingLabel = NativeListTextLabel() - private let secondaryLabel = NativeListTextLabel() - - override init(frame: CGRect) { - super.init(frame: frame) - axis = .vertical - alignment = .leading - distribution = .fill - spacing = 4 - - primaryLine.axis = .horizontal - primaryLine.alignment = .center - primaryLine.spacing = 6 - primaryLabel.numberOfLines = 1 - primaryLabel.lineBreakMode = .byTruncatingTail - primaryLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - badgesStack.axis = .horizontal - badgesStack.alignment = .center - badgesStack.spacing = 4 - badgesStack.setContentCompressionResistancePriority(.required, for: .horizontal) - primaryLine.addArrangedSubview(primaryLabel) - primaryLine.addArrangedSubview(badgesStack) - - secondaryLine.axis = .horizontal - secondaryLine.alignment = .center - secondaryLine.spacing = 4 - for label in [secondaryLeadingLabel, secondaryLabel] { - label.numberOfLines = 1 - label.lineBreakMode = .byTruncatingTail - secondaryLine.addArrangedSubview(label) - } - secondaryLeadingLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - secondaryLabel.setContentCompressionResistancePriority(.required, for: .horizontal) - secondaryLeadingLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 120).isActive = true - addArrangedSubview(primaryLine) - addArrangedSubview(secondaryLine) - } - - required init(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func reset() { - spacing = 4 - primaryLine.spacing = 6 - primaryLabel.numberOfLines = 1 - secondaryLeadingLabel.numberOfLines = 1 - secondaryLabel.numberOfLines = 1 - primaryLabel.attributedText = nil - secondaryLeadingLabel.attributedText = nil - secondaryLabel.attributedText = nil - secondaryLeadingLabel.isHidden = true - secondaryLabel.isHidden = true - secondaryLine.isHidden = true - badgesStack.arrangedSubviews.forEach { - badgesStack.removeArrangedSubview($0) - $0.removeFromSuperview() - } - } - - func bind( - column: [String: Any], - badges: [[String: Any]], - theme: [String: Any]? - ) { - reset() - let textAlignment: NSTextAlignment - switch column.string("alignment") { - case "end": - alignment = .trailing - textAlignment = .right - case "center": - alignment = .center - textAlignment = .center - default: - alignment = .leading - textAlignment = .left - } - primaryLabel.textAlignment = textAlignment - secondaryLeadingLabel.textAlignment = textAlignment - secondaryLabel.textAlignment = textAlignment - primaryLabel.font = nativeListFont(ofSize: 14, weight: .medium) - secondaryLeadingLabel.font = nativeListFont(ofSize: 12) - secondaryLabel.font = nativeListFont(ofSize: 12) - primaryLabel.attributedText = line( - column.string("text"), - font: nativeListFont(ofSize: 14, weight: .medium), - color: textColor(column.string("tone"), theme: theme), - height: 20 - ) - - for badge in badges.prefix(2) { - let label = NativeListInsetLabel() - label.horizontalInset = 6 - label.text = badge.string("text") - label.font = nativeListFont(ofSize: 10) - label.textColor = nativeListColor(theme, "info", "#0D74CE") - label.textAlignment = .center - label.backgroundColor = UIColor(nativeListHex: "#008FF519", fallback: .systemBlue) - label.layer.cornerRadius = 4 - label.clipsToBounds = true - label.translatesAutoresizingMaskIntoConstraints = false - label.heightAnchor.constraint(equalToConstant: 16).isActive = true - badgesStack.addArrangedSubview(label) - } - badgesStack.isHidden = badges.isEmpty - - let secondaryLeading = column.string("secondaryLeadingText") - if !secondaryLeading.isEmpty { - secondaryLeadingLabel.attributedText = line( - secondaryLeading, - font: nativeListFont(ofSize: 12), - color: nativeListColor(theme, "secondaryText", "#646464"), - height: 16 - ) - secondaryLeadingLabel.isHidden = false - secondaryLine.isHidden = false - } - let secondary = column.string("secondaryText") - if !secondary.isEmpty { - secondaryLabel.attributedText = line( - secondary, - font: nativeListFont(ofSize: 12), - color: textColor( - column.string("secondaryTone", default: "secondary"), - theme: theme - ), - height: 16 - ) - secondaryLabel.isHidden = false - secondaryLine.isHidden = false - } - } - - func applyStyle(_ style: [String: Any], text: (UILabel, [String: Any]) -> Void) { - if let primary = style.dictionary("columns") { - if primary["alignment"] != nil { alignment = .fill } - text(primaryLabel, primary) - } - if let secondary = style.dictionary("columnSecondary") { - if secondary["alignment"] != nil { alignment = .fill } - text(secondaryLeadingLabel, secondary) - text(secondaryLabel, secondary) - } - if style["lineGap"] != nil { spacing = CGFloat(style.double("lineGap")) } - if style["titleBadgeGap"] != nil { primaryLine.spacing = CGFloat(style.double("titleBadgeGap")) } - } - - private func line(_ text: String, font: UIFont, color: UIColor, height: CGFloat) -> NSAttributedString { - let paragraph = NSMutableParagraphStyle() - paragraph.minimumLineHeight = height - paragraph.maximumLineHeight = height - return NSAttributedString( - string: text, - attributes: [ - .font: font, - .foregroundColor: color, - .paragraphStyle: paragraph, - ] - ) - } - - private func textColor(_ tone: String, theme: [String: Any]?) -> UIColor { - switch tone { - // OneKey patch: account warnings and hidden balances use existing theme tokens. - case "disabled": return nativeListColor(theme, "disabledText", "#8D8D8D") - case "caution": return nativeListColor(theme, "caution", "#AB6400") - case "secondary": return nativeListColor(theme, "secondaryText", "#646464") - case "positive": return nativeListColor(theme, "positive", "#218358") - case "negative": return nativeListColor(theme, "negative", "#CE2C31") - default: return nativeListColor(theme, "primaryText", "#202020") - } - } -} - final class NativeListCell: NativeListRowHost { private let rootStack = UIStackView() private var styledCircleViews: [UIView] = [] @@ -384,7 +204,6 @@ final class NativeListCell: NativeListRowHost { private let secondaryImage = OneKeyImageReusableView(frame: .zero) private let fallbackLabel = UILabel() private let leadingIconImageView = UIImageView() - private let favoriteIconImageView = UIImageView() private let headerTitleIconImageView = UIImageView() private let headerValueIconImageView = UIImageView() private let leadingActionButton = UIButton(type: .system) @@ -412,8 +231,6 @@ final class NativeListCell: NativeListRowHost { private let accessoryButtons = (0..<2).map { _ in NativeListAccessoryButton(type: .system) } private let checkboxButton = UIButton(type: .system) private let spinner = UIActivityIndicatorView(style: .medium) - private let tableDataStack = UIStackView() - private let tableDataColumns = (0..<4).map { _ in NativeListTableColumnView() } private var walletGroupCells: [NativeListCell] = [] private var walletGroupMembers: [NativeListItem] = [] private let walletGroupCompactContainer = UIView() @@ -437,7 +254,6 @@ final class NativeListCell: NativeListRowHost { private var rootTopConstraint: NSLayoutConstraint! private var rootBottomConstraint: NSLayoutConstraint! private var leadingSlotConstraints: [NSLayoutConstraint] = [] - private var dataWeightConstraints: [NSLayoutConstraint] = [] private var accessorySizeConstraints: [NSLayoutConstraint] = [] // OneKey patch: restore selector-only font features before a cell is reused. private var selectorTypographyRestorers: [() -> Void] = [] @@ -508,9 +324,6 @@ final class NativeListCell: NativeListRowHost { ]) walletGroupCompactContainer.isHidden = true walletGroupCompactContainer.isUserInteractionEnabled = false - favoriteIconImageView.translatesAutoresizingMaskIntoConstraints = false - favoriteIconImageView.contentMode = .scaleAspectFit - favoriteIconImageView.image = nativeListIcon(named: "StarOutline") headerTitleIconImageView.translatesAutoresizingMaskIntoConstraints = false headerTitleIconImageView.contentMode = .scaleAspectFit headerValueIconImageView.translatesAutoresizingMaskIntoConstraints = false @@ -520,8 +333,6 @@ final class NativeListCell: NativeListRowHost { leadingActionButton.tintAdjustmentMode = .normal leadingActionButton.addTarget(self, action: #selector(leadingActionPressed), for: .touchUpInside) NSLayoutConstraint.activate([ - favoriteIconImageView.widthAnchor.constraint(equalToConstant: 20), - favoriteIconImageView.heightAnchor.constraint(equalToConstant: 20), headerTitleIconImageView.widthAnchor.constraint(equalToConstant: 12), headerTitleIconImageView.heightAnchor.constraint(equalToConstant: 12), headerValueIconImageView.widthAnchor.constraint(equalToConstant: 12), @@ -690,11 +501,7 @@ final class NativeListCell: NativeListRowHost { trailingStack.addArrangedSubview(checkboxButton) trailingStack.addArrangedSubview(spinner) - tableDataStack.axis = .horizontal - tableDataStack.alignment = .center - tableDataStack.distribution = .fill - tableDataStack.spacing = 8 - tableDataColumns.forEach(tableDataStack.addArrangedSubview) + } required init?(coder: NSCoder) { @@ -853,7 +660,6 @@ final class NativeListCell: NativeListRowHost { switch item.type { case "walletGroup": bindWalletGroup(item, theme: theme, layout: layout, checkboxState) case "identity": bindIdentity(item, theme: theme, selected: selected, checkboxState) - case "dataRow": bindDataRow(item, theme: theme, checkboxState) case "market": bindMarket(item, theme: theme) case "metricCard": bindMetricCard(item, theme: theme) case "sectionHeader": bindSectionHeader(item, theme: theme, layout: layout, checkboxState) @@ -1153,8 +959,6 @@ final class NativeListCell: NativeListRowHost { secondaryHeight.isActive = false NSLayoutConstraint.deactivate(leadingSlotConstraints) leadingSlotConstraints.removeAll() - NSLayoutConstraint.deactivate(dataWeightConstraints) - dataWeightConstraints.removeAll() NSLayoutConstraint.deactivate(accessorySizeConstraints) accessorySizeConstraints.removeAll() leadingImages.forEach { @@ -1171,8 +975,6 @@ final class NativeListCell: NativeListRowHost { leadingIconImageView.image = nil leadingCornerIconBackground.isHidden = true leadingCornerIconImageView.image = nil - favoriteIconImageView.isHidden = true - favoriteIconImageView.image = nativeListIcon(named: "StarOutline") leadingActionButton.isHidden = true leadingActionButton.setImage(nil, for: .normal) leadingActionButton.setImage(nil, for: .disabled) @@ -1280,10 +1082,7 @@ final class NativeListCell: NativeListRowHost { checkboxButton.setImage(nil, for: .normal) spinner.stopAnimating() spinner.alpha = 1 - tableDataColumns.forEach { - $0.reset() - $0.isHidden = true - } + accessoryActions = [] checkboxAction = nil boundCheckboxData = nil @@ -1876,68 +1675,6 @@ final class NativeListCell: NativeListRowHost { } - private func bindDataRow( - _ item: NativeListItem, - theme: [String: Any]?, - _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String - ) { - if item.data.bool("favorite") || item.data.bool("favoriteActive") { - favoriteIconImageView.isHidden = false - let favoriteActive = item.data.bool("favoriteActive") - favoriteIconImageView.image = nativeListIcon( - named: favoriteActive ? "StarSolid" : "StarOutline" - ) - favoriteIconImageView.tintColor = nativeListColor( - theme, - favoriteActive ? "icon" : "iconSubdued", - favoriteActive ? "#646464" : "#8D8D8D" - ) - rootStack.addArrangedSubview(favoriteIconImageView) - if currentLayout == "table" { - rootStack.setCustomSpacing(8, after: favoriteIconImageView) - } - } - rootStack.spacing = currentLayout == "table" ? 10 : 8 - if let leading = item.data.dictionary("leading") { - leadingWidth.constant = 40 - leadingHeight.constant = 40 - addLeading(leading, key: item.key) - } - var hasLeadingAccessory = false - if let checkbox = item.data.dictionary("checkbox") { - bindCheckbox(item, checkbox, checkboxState) - hasLeadingAccessory = true - } - if item.data["index"] != nil { - showAccessory(0, String(item.data.int("index"))) - hasLeadingAccessory = true - } - if hasLeadingAccessory { rootStack.addArrangedSubview(trailingStack) } - let columns = Array(item.data.dictionaries("columns").prefix(4)) - let rowBadges = item.data.dictionaries("badges") - rootStack.addArrangedSubview(tableDataStack) - for (index, column) in columns.enumerated() { - tableDataColumns[index].isHidden = false - tableDataColumns[index].bind( - column: column, - badges: index == 0 ? rowBadges : [], - theme: theme - ) - } - if let firstColumn = columns.first { - let firstWeight = CGFloat(max(1, firstColumn.int("weight", default: 1))) - for index in 1.. NativeListFontWeight { switch value { @@ -1968,13 +1705,6 @@ final class NativeListCell: NativeListRowHost { case "identity": return ["title", "subtitle", "tertiary", "badge", "value", "valueSecondary"] .contains(field) ? field : nil - case "dataRow": - switch field { - case "columns": return "dataPrimary" - case "columnSecondary": return "dataSecondary" - case "index": return "value" - default: return nil - } // The large number and the small label sit in swapped views. case "metricCard": switch field { @@ -2087,9 +1817,7 @@ final class NativeListCell: NativeListRowHost { } } } - if item.type == "dataRow" { - tableDataColumns.forEach { $0.applyStyle(style, text: applyStyledText) } - } + let variant = item.data.string("variant") if item.type == "metricCard" && ["activity", "performance"].contains(variant) { @@ -2110,7 +1838,6 @@ final class NativeListCell: NativeListRowHost { case "status": applyStyledText(statusLabel, slotStyle) case "metricSubtitle": applyStyledText(metricSubtitleLabel, slotStyle) case "badge": (semanticBadgeLabels.isEmpty ? [badgeLabel] : semanticBadgeLabels).forEach { applyStyledText($0, slotStyle) } - case "dataPrimary", "dataSecondary": break // Applied to independent column labels above. case "value", "valueSecondary": let index = slot == "value" ? 0 : 1 let buttons = item.type == "identity" ? semanticValueButtons : accessoryButtons diff --git a/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift b/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift new file mode 100644 index 000000000..b293f31bd --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift @@ -0,0 +1,300 @@ +import UIKit + +final class NativeListDataRowCell: NativeListRendererCell { + private let visual = NativeListLeadingVisual() + private let favorite = UIImageView() + private let accessories = NativeListAccessoryStack() + private let columns = UIStackView() + private let cells = (0..<4).map { _ in NativeListTableColumnView() } + private var rowConstraints: [NSLayoutConstraint] = [] + override func unselectedBackground( + _ item: NativeListItem, theme: [String: Any]?, layout: String, itemIndex: Int? + ) -> UIColor { + let index = item.data["index"] == nil ? itemIndex : item.data.int("index") + return layout == "table" && index.map({ $0 % 2 == 0 }) == true + ? nativeListColor(theme, "subduedBackground", "#F9F9F9") + : super.unselectedBackground(item, theme: theme, layout: layout, itemIndex: itemIndex) + } + override var assetFields: [String] { ["leading"] } + override init(frame: CGRect) { + super.init(frame: frame) + root.axis = .horizontal + favorite.contentMode = .scaleAspectFit + columns.axis = .horizontal + columns.alignment = .center + columns.spacing = 8 + cells.forEach(columns.addArrangedSubview) + [favorite, visual, accessories, columns].forEach(root.addArrangedSubview) + accessories.onAction = { [weak self] key, view, slot, target in + self?.emitAction(key, from: view, source: "trailingAccessory", slot: slot, target: target) + } + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func bindContent( + _ item: NativeListItem, theme: [String: Any]?, layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + let data = item.data + let style = data.dictionary("style") ?? [:] + let image = style.dictionary("image") ?? [:] + root.spacing = layout == "table" ? 10 : 8 + root.alignment = + style.dictionary("container")?.string("contentVerticalAlignment") == "top" + ? .top + : style.dictionary("container")?.string("contentVerticalAlignment") == "bottom" + ? .bottom : .center + let hp = CGFloat(style.double("horizontalPadding", default: layout == "table" ? 16 : 12)) + let vp = CGFloat(style.double("verticalPadding", default: 8)) + contentInsets = UIEdgeInsets(top: vp, left: hp, bottom: vp, right: hp) + favorite.isHidden = !data.bool("favorite") && !data.bool("favoriteActive") + favorite.image = nativeListIcon( + named: data.bool("favoriteActive") ? "StarSolid" : "StarOutline") + favorite.tintColor = nativeListColor( + theme, data.bool("favoriteActive") ? "icon" : "iconSubdued", + data.bool("favoriteActive") ? "#646464" : "#8D8D8D") + root.setCustomSpacing(layout == "table" ? 8 : root.spacing, after: favorite) + visual.isHidden = data.dictionary("leading") == nil + if let leading = data.dictionary("leading") { + visual.bind(leading, style: image, key: item.key, theme: theme, isUnread: false) + } else { + visual.recycle() + } + root.setCustomSpacing( + CGFloat(style.double("leadingGap", default: Double(root.spacing))), after: visual) + var descriptors: [[String: Any]] = [] + if data["index"] != nil { + descriptors.append(["kind": "value", "text": String(data.int("index"))]) + } + if let checkbox = data.dictionary("checkbox") { descriptors.append(checkbox) } + var accessoryStyle = style + accessoryStyle["value"] = style.dictionary("index") + accessories.bind( + item, descriptors: descriptors, theme: theme, style: accessoryStyle, + checkboxState: checkboxState) + accessories.isHidden = descriptors.isEmpty + NSLayoutConstraint.deactivate(rowConstraints) + rowConstraints = [ + favorite.widthAnchor.constraint(equalToConstant: 20), + favorite.heightAnchor.constraint(equalToConstant: 20), + visual.widthAnchor.constraint(equalToConstant: CGFloat(image.double("width", default: 40))), + visual.heightAnchor.constraint(equalToConstant: CGFloat(image.double("height", default: 40))), + ] + let values = Array(data.dictionaries("columns").prefix(4)) + for (index, cell) in cells.enumerated() { + cell.isHidden = index >= values.count + guard index < values.count else { + cell.reset() + continue + } + cell.bind( + column: values[index], badges: index == 0 ? data.dictionaries("badges") : [], theme: theme) + cell.applyStyle(style, text: NativeListTextStyles.applyStyledText) + if index > 0 { + rowConstraints.append( + cell.widthAnchor.constraint( + equalTo: cells[0].widthAnchor, + multiplier: CGFloat(max(1, values[index].int("weight", default: 1))) + / CGFloat(max(1, values[0].int("weight", default: 1))))) + } + } + NSLayoutConstraint.activate(rowConstraints) + } + override func bindSelectionContent( + _ item: NativeListItem, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { accessories.updateSelection(item, checkboxState: checkboxState) } + override func recycleContent() { + visual.recycle() + accessories.reset() + cells.forEach { $0.reset() } + } +} + +private final class NativeListTableColumnView: UIStackView { + private let primaryLine = UIStackView() + private let primaryLabel = NativeListTextLabel() + private let badgesStack = UIStackView() + private let secondaryLine = UIStackView() + private let secondaryLeadingLabel = NativeListTextLabel() + private let secondaryLabel = NativeListTextLabel() + + override init(frame: CGRect) { + super.init(frame: frame) + axis = .vertical + alignment = .leading + distribution = .fill + spacing = 4 + + primaryLine.axis = .horizontal + primaryLine.alignment = .center + primaryLine.spacing = 6 + primaryLabel.numberOfLines = 1 + primaryLabel.lineBreakMode = .byTruncatingTail + primaryLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + badgesStack.axis = .horizontal + badgesStack.alignment = .center + badgesStack.spacing = 4 + badgesStack.setContentCompressionResistancePriority(.required, for: .horizontal) + primaryLine.addArrangedSubview(primaryLabel) + primaryLine.addArrangedSubview(badgesStack) + + secondaryLine.axis = .horizontal + secondaryLine.alignment = .center + secondaryLine.spacing = 4 + for label in [secondaryLeadingLabel, secondaryLabel] { + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + secondaryLine.addArrangedSubview(label) + } + secondaryLeadingLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + secondaryLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + secondaryLeadingLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 120).isActive = true + addArrangedSubview(primaryLine) + addArrangedSubview(secondaryLine) + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func reset() { + for label in [primaryLabel, secondaryLeadingLabel, secondaryLabel] { + label.rowVerticalAlignment = nil + label.rowOffsetY = 0 + label.lineBreakMode = .byTruncatingTail + } + spacing = 4 + primaryLine.spacing = 6 + primaryLabel.numberOfLines = 1 + secondaryLeadingLabel.numberOfLines = 1 + secondaryLabel.numberOfLines = 1 + primaryLabel.attributedText = nil + secondaryLeadingLabel.attributedText = nil + secondaryLabel.attributedText = nil + secondaryLeadingLabel.isHidden = true + secondaryLabel.isHidden = true + secondaryLine.isHidden = true + badgesStack.arrangedSubviews.forEach { + badgesStack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + } + + func bind( + column: [String: Any], + badges: [[String: Any]], + theme: [String: Any]? + ) { + reset() + let textAlignment: NSTextAlignment + switch column.string("alignment") { + case "end": + alignment = .trailing + textAlignment = .right + case "center": + alignment = .center + textAlignment = .center + default: + alignment = .leading + textAlignment = .left + } + primaryLabel.textAlignment = textAlignment + secondaryLeadingLabel.textAlignment = textAlignment + secondaryLabel.textAlignment = textAlignment + primaryLabel.font = nativeListFont(ofSize: 14, weight: .medium) + secondaryLeadingLabel.font = nativeListFont(ofSize: 12) + secondaryLabel.font = nativeListFont(ofSize: 12) + primaryLabel.attributedText = line( + column.string("text"), + font: nativeListFont(ofSize: 14, weight: .medium), + color: textColor(column.string("tone"), theme: theme), + height: 20 + ) + + for badge in badges.prefix(2) { + let label = NativeListInsetLabel() + label.horizontalInset = 6 + label.text = badge.string("text") + label.font = nativeListFont(ofSize: 10) + label.textColor = nativeListColor(theme, "info", "#0D74CE") + label.textAlignment = .center + label.backgroundColor = UIColor(nativeListHex: "#008FF519", fallback: .systemBlue) + label.layer.cornerRadius = 4 + label.clipsToBounds = true + label.translatesAutoresizingMaskIntoConstraints = false + label.heightAnchor.constraint(equalToConstant: 16).isActive = true + badgesStack.addArrangedSubview(label) + } + badgesStack.isHidden = badges.isEmpty + + let secondaryLeading = column.string("secondaryLeadingText") + if !secondaryLeading.isEmpty { + secondaryLeadingLabel.attributedText = line( + secondaryLeading, + font: nativeListFont(ofSize: 12), + color: nativeListColor(theme, "secondaryText", "#646464"), + height: 16 + ) + secondaryLeadingLabel.isHidden = false + secondaryLine.isHidden = false + } + let secondary = column.string("secondaryText") + if !secondary.isEmpty { + secondaryLabel.attributedText = line( + secondary, + font: nativeListFont(ofSize: 12), + color: textColor( + column.string("secondaryTone", default: "secondary"), + theme: theme + ), + height: 16 + ) + secondaryLabel.isHidden = false + secondaryLine.isHidden = false + } + } + + func applyStyle(_ style: [String: Any], text: (UILabel, [String: Any]) -> Void) { + if let primary = style.dictionary("columns") { + if primary["alignment"] != nil { alignment = .fill } + text(primaryLabel, primary) + } + if let secondary = style.dictionary("columnSecondary") { + if secondary["alignment"] != nil { alignment = .fill } + text(secondaryLeadingLabel, secondary) + text(secondaryLabel, secondary) + } + if style["lineGap"] != nil { spacing = CGFloat(style.double("lineGap")) } + if style["titleBadgeGap"] != nil { + primaryLine.spacing = CGFloat(style.double("titleBadgeGap")) + } + } + + private func line(_ text: String, font: UIFont, color: UIColor, height: CGFloat) + -> NSAttributedString + { + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = height + paragraph.maximumLineHeight = height + return NSAttributedString( + string: text, + attributes: [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraph, + ] + ) + } + + private func textColor(_ tone: String, theme: [String: Any]?) -> UIColor { + switch tone { + // OneKey patch: account warnings and hidden balances use existing theme tokens. + case "disabled": return nativeListColor(theme, "disabledText", "#8D8D8D") + case "caution": return nativeListColor(theme, "caution", "#AB6400") + case "secondary": return nativeListColor(theme, "secondaryText", "#646464") + case "positive": return nativeListColor(theme, "positive", "#218358") + case "negative": return nativeListColor(theme, "negative", "#CE2C31") + default: return nativeListColor(theme, "primaryText", "#202020") + } + } +} diff --git a/native-views/react-native-native-list/ios/NativeListModels.swift b/native-views/react-native-native-list/ios/NativeListModels.swift index da1937225..a2d237690 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -4,7 +4,7 @@ import UIKit // Message is the first migrated renderer. Other templates still use the // compatible legacy tree; style, content and row keys do not affect reuse. enum NativeListRendererKey: String, CaseIterable { - case legacy, message, rail, mediaTile, action, system, activity + case legacy, message, rail, mediaTile, action, system, activity, dataRow } struct NativeListItem { diff --git a/native-views/react-native-native-list/ios/NativeListRendererCell.swift b/native-views/react-native-native-list/ios/NativeListRendererCell.swift index e0fd2b935..1ec3503f4 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererCell.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererCell.swift @@ -40,6 +40,8 @@ class NativeListRendererCell: NativeListRowHost { private var item: NativeListItem? private var theme: [String: Any]? private var layout = "linear" + private var itemIndex: Int? + func unselectedBackground(_ item: NativeListItem, theme: [String: Any]?, layout: String, itemIndex: Int?) -> UIColor { nativeListColor(theme, "rowBackground", "#FFFFFF") } private var selectedState = false private var inputSignature = "" @@ -103,6 +105,7 @@ class NativeListRendererCell: NativeListRowHost { self.item = item self.theme = theme self.layout = layout + self.itemIndex = itemIndex self.selectedState = selected bindSelectionContent(item, checkboxState: checkboxState) accessibilityLabel = item.data.string("accessibilityLabel", default: item.data.string("title")) @@ -142,9 +145,7 @@ class NativeListRendererCell: NativeListRowHost { let container = item.data.dictionary("style")?.dictionary("container") ?? [:] let showSelection = showsSelection && selectedState && (layout != "sectioned" || item.data.bool("selected")) - var background = nativeListColor( - theme, showSelection ? "rowSelectedBackground" : "rowBackground", - showSelection ? "#F0F0F0" : "#FFFFFF") + var background = showSelection ? nativeListColor(theme, "rowSelectedBackground", "#F0F0F0") : unselectedBackground(item, theme: theme, layout: layout, itemIndex: itemIndex) if let color = (container["backgroundColor"] ?? item.data["backgroundColor"]) as? String { background = UIColor(nativeListHex: color, fallback: background) } diff --git a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift index edb7dbee3..ac47993b0 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift @@ -9,6 +9,7 @@ enum NativeListRendererRegistry { .message: (NativeListMessageCell.self, { NativeListMessageCell(frame: .zero) }), .rail: (NativeListRailCell.self, { NativeListRailCell(frame: .zero) }), .mediaTile: (NativeListMediaTileCell.self, { NativeListMediaTileCell(frame: .zero) }), + .dataRow: (NativeListDataRowCell.self, { NativeListDataRowCell(frame: .zero) }), .activity: (NativeListActivityCell.self, { NativeListActivityCell(frame: .zero) }), .system: (NativeListSystemCell.self, { NativeListSystemCell(frame: .zero) }), .action: (NativeListActionCell.self, { NativeListActionCell(frame: .zero) }), @@ -31,6 +32,10 @@ enum NativeListRendererRegistry { static func measure(_ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String) -> CGFloat? { + if item.rendererKey == .dataRow { + let secondary = item.data.dictionaries("columns").contains { !$0.string("secondaryText").isEmpty } + return secondary ? 60 : layout == "table" ? 48 : 56 + } if item.rendererKey == .activity { return item.data.dictionaries("footerActions").isEmpty ? 60 : 100 } if item.rendererKey == .system { return NativeListSystemRenderer.measure(item, width: width) } if item.rendererKey == .action { diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index 10e70f80f..de9ef733a 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -1754,10 +1754,6 @@ final class NativeListView: UIView { : item.data.dictionary("checkbox") != nil ? 56 : config?.layout == "linear" ? 30 : 36 - case "dataRow": - base = item.data.dictionaries("columns").contains { - !$0.string("secondaryText").isEmpty - } ? 60 : 56 case "market": let style = item.data.dictionary("style") let imageHeight = CGFloat(style?.dictionary("image")?.double( @@ -1783,12 +1779,7 @@ final class NativeListView: UIView { ? -8 : item.data.string("size") == "large" ? 12 : 0 let sectionSpacing: CGFloat = 0 - let tableAdjustment: CGFloat = config?.layout == "table" && - item.type == "dataRow" && - !item.data.dictionaries("columns").contains(where: { !$0.string("secondaryText").isEmpty }) - ? -8 - : 0 - return max(0, base + modifier + sectionSpacing + tableAdjustment) + return max(0, base + modifier + sectionSpacing) } diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index 486b56f61..5992596d5 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -1204,7 +1204,10 @@ describe('web row style', () => { const row = { type: 'activity', key: 'tx', - leading: { kind: 'image', image: { uri: 'https://example.com/a.png', width: 40, height: 40 } }, + leading: { + kind: 'image', + image: { uri: 'https://example.com/a.png', width: 40, height: 40 }, + }, title: 'Received', description: 'From Alice', status: 'Failed', @@ -2259,3 +2262,85 @@ describe('web row style', () => { } ); }); + +describe('DataRow renderer lifecycle', () => { + const dataSnapshot = ( + layout: NativeListSnapshot['layout'], + items: readonly RowModel[] + ): NativeListSnapshot => ({ schemaVersion: 1, generation: 1, layout, rows }); + const { JSDOM } = require('jsdom') as { + JSDOM: new (html: string) => { window: { document: Document } }; + }; + it('retains the image while removing columns and restoring text styles', () => { + const { document } = new JSDOM('').window; + const host = document.createElement('div'); + document.body.appendChild(host); + const row: Extract = { + type: 'dataRow', + key: 'data', + leading: { kind: 'image', image }, + index: 2, + columns: [ + { key: 'asset', text: 'Bitcoin', secondaryText: 'BTC', weight: 2 }, + { key: 'price', text: '$42,000', alignment: 'end' }, + { key: 'change', text: '+2.4%' }, + ], + }; + const engine = new NativeListWebEngine( + host, + dataSnapshot({ kind: 'table' }, [row]), + {}, + false + ); + try { + const body = host.querySelector( + '[data-nl-renderer="dataRow"]' + )!; + const leading = body.querySelector('img'); + engine.applySnapshot( + dataSnapshot({ kind: 'table' }, [ + { + ...row, + style: { + columns: { fontSize: 22, lines: 2 }, + columnSecondary: { color: '#ff0000' }, + image: { width: 28, height: 30 }, + }, + }, + ]) + ); + expect(body.querySelector('img')).toBe(leading); + expect( + body.querySelector('[data-nl-slot="columns"]')?.style + .fontSize + ).toBe('22px'); + engine.applySnapshot( + dataSnapshot({ kind: 'table' }, [ + { + ...row, + columns: [ + { key: 'asset', text: 'Ether' }, + { key: 'price', text: '$2,000' }, + ], + }, + ]) + ); + expect(host.querySelector('[data-nl-renderer="dataRow"]')).toBe(body); + expect(body.querySelector('img')).toBe(leading); + expect(body.querySelectorAll('[data-nl-slot="columns"]')).toHaveLength(2); + expect( + body.querySelectorAll('[data-nl-slot="columnSecondary"]') + ).toHaveLength(0); + expect( + body.querySelector('[data-nl-slot="columns"]')?.style + .fontSize + ).not.toBe('22px'); + expect(body.textContent).not.toContain('$42,000'); + engine.applySnapshot(dataSnapshot({ kind: 'table' }, [row])); + expect(body.querySelectorAll('[data-nl-slot="columns"]')).toHaveLength(3); + expect(body.textContent).toContain('BTC'); + } finally { + engine.destroy(); + } + }); +}); diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index 275306c57..6765860d1 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -545,9 +545,6 @@ export function estimateWebRowHeight( : 36; break; } - case 'dataRow': - base = row.columns.some((column) => column.secondaryText) ? 60 : 56; - break; case 'market': { const marketStyle = resolveWebMarketLayoutStyle(row); base = Math.max( @@ -2520,98 +2517,6 @@ function createMetricRow( return body; } -function createDataRow( - context: RenderContext, - row: Extract -): HTMLElement { - const body = createElement( - context.document, - 'div', - 'ok-native-list-row ok-native-list-data' - ); - if (row.checkbox) - body.appendChild(createCheckbox(context, row.key, row.checkbox)); - if (row.index !== undefined) - body.appendChild( - tagSlot( - createElement( - context.document, - 'span', - 'ok-native-list-index', - String(row.index) - ), - 'index' - ) - ); - if (row.favorite) { - const favorite = createElement( - context.document, - 'span', - 'ok-native-list-favorite', - row.favoriteActive ? '★' : '☆' - ); - setData(favorite, 'active', row.favoriteActive); - body.appendChild(favorite); - } - if (row.leading) { - const visual = createVisual(context, row.leading); - if (visual) body.appendChild(visual); - } - row.columns.forEach((column) => { - const cell = createElement( - context.document, - 'span', - 'ok-native-list-data-cell' - ); - cell.style.flex = String(column.weight ?? 1); - setData(cell, 'align', column.alignment ?? 'start'); - const primary = createElement( - context.document, - 'span', - 'ok-native-list-data-primary' - ); - primary.style.color = toneColor(column.tone, 'primary'); - primary.appendChild( - tagSlot( - createElement(context.document, 'span', undefined, column.text), - 'columns' - ) - ); - if (column.key === 'asset' && row.badges?.length) { - row.badges.forEach((badge) => - primary.appendChild(createBadge(context, badge)) - ); - } - cell.appendChild(primary); - if (column.secondaryLeadingText || column.secondaryText) { - const secondaryLine = createElement( - context.document, - 'span', - 'ok-native-list-data-secondary-line' - ); - secondaryLine.style.display = 'flex'; - secondaryLine.style.gap = '4px'; - for (const text of [column.secondaryLeadingText, column.secondaryText]) { - if (!text) continue; - const secondary = tagSlot( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - text - ), - 'columnSecondary' - ); - secondary.style.color = toneColor(column.secondaryTone, 'secondary'); - secondaryLine.appendChild(secondary); - } - cell.appendChild(secondaryLine); - } - body.appendChild(cell); - }); - return body; -} - function createIdentityRow( context: RenderContext, row: Extract< @@ -3600,8 +3505,6 @@ export function createRowBody( return createSectionHeader(context, row); case 'metricCard': return createMetricRow(context, row); - case 'dataRow': - return createDataRow(context, row); case 'market': return createMarketRow(context, row); case 'identity': @@ -3642,6 +3545,7 @@ export class NativeListWebEngine { action: [], system: [], activity: [], + dataRow: [], }; private readonly rendererKeys = new WeakMap(); private frameHandle: number | undefined; diff --git a/native-views/react-native-native-list/src/web/templates/DataRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/DataRowRenderer.ts new file mode 100644 index 000000000..5539b7c48 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/DataRowRenderer.ts @@ -0,0 +1,206 @@ +import type { DataRow, TextTone } from '../../models'; +import { createElement, tagSlot } from './RowElements'; +import { RowVisualStyle, type RowPrimitives } from './RowVisual'; +type ColumnViews = { + body: HTMLElement; + primaryLine: HTMLElement; + primary: HTMLElement; + badges: HTMLElement; + secondaryLine: HTMLElement; + secondary: HTMLElement[]; +}; +type Views = { + favorite: HTMLElement; + index: HTMLElement; + controls: HTMLElement; + columns: ColumnViews[]; + visual?: HTMLElement; + visualStyle?: RowVisualStyle; + visualKey: string; + primitives: RowPrimitives; +}; +const views = new WeakMap(); +const tone = (value: TextTone | undefined, fallback: string) => + value === 'positive' + ? 'var(--nl-positive)' + : value === 'negative' + ? 'var(--nl-negative)' + : value === 'secondary' + ? 'var(--nl-secondary)' + : `var(--nl-${fallback})`; +function bind(body: HTMLElement, row: DataRow, primitives: RowPrimitives) { + const v = views.get(body)!; + v.primitives = primitives; + body.style.cssText = ''; + body.removeAttribute('data-nl-container-background'); + body.removeAttribute('data-nl-container-border'); + const style = row.style; + if (style?.horizontalPadding !== undefined) + body.style.paddingInline = `${style.horizontalPadding}px`; + if (style?.verticalPadding !== undefined) + body.style.paddingBlock = `${style.verticalPadding}px`; + v.controls.replaceChildren(); + if (row.checkbox) + v.controls.appendChild(primitives.accessory(row.key, row.checkbox, 0)); + v.controls.style.display = 'contents'; + v.index.style.cssText = ''; + v.index.hidden = row.index === undefined; + v.index.textContent = String(row.index ?? ''); + if (style?.index) primitives.textStyle(v.index, style.index); + v.favorite.hidden = !row.favorite; + v.favorite.textContent = row.favoriteActive ? '★' : '☆'; + v.favorite.dataset.active = String(!!row.favoriteActive); + const visualKey = JSON.stringify([row.key, row.leading]); + if (visualKey !== v.visualKey) { + if (v.visual) { + primitives.dispose(v.visual); + v.visual.remove(); + } + v.visual = primitives.visual(row.leading); + v.visualStyle = v.visual ? new RowVisualStyle(v.visual) : undefined; + if (v.visual) body.insertBefore(v.visual, v.columns[0]!.body); + v.visualKey = visualKey; + } + v.visualStyle?.apply( + style?.image, + style?.image?.width ?? 40, + style?.image?.height ?? 40, + (style?.leadingGap ?? 12) - 12 + ); + v.columns.forEach((cell, index) => { + const column = row.columns[index]; + cell.body.hidden = !column; + if (!column) { + cell.body.remove(); + return; + } + body.appendChild(cell.body); + cell.body.style.cssText = ''; + cell.body.hidden = false; + cell.body.style.flex = String(column.weight ?? 1); + cell.body.style.display = 'flex'; + cell.body.dataset.align = column.alignment ?? 'start'; + if (style?.lineGap !== undefined) + cell.body.style.rowGap = `${style.lineGap}px`; + cell.primaryLine.style.cssText = ''; + cell.primaryLine.style.color = tone(column.tone, 'primary'); + if (style?.titleBadgeGap !== undefined) + cell.primaryLine.style.gap = `${style.titleBadgeGap}px`; + cell.primary.style.cssText = ''; + cell.primary.textContent = column.text; + if (style?.columns) primitives.textStyle(cell.primary, style.columns); + cell.badges.replaceChildren(); + cell.badges.style.display = 'contents'; + if (column.key === 'asset') + row.badges?.forEach((badge) => { + const label = tagSlot( + createElement( + body.ownerDocument, + 'span', + 'ok-native-list-badge', + badge.text + ), + 'badge' + ); + label.dataset.tone = badge.tone ?? ''; + cell.badges.appendChild(label); + }); + cell.secondaryLine.style.display = + !column.secondaryLeadingText && !column.secondaryText ? 'none' : 'flex'; + [column.secondaryLeadingText, column.secondaryText].forEach( + (text, slot) => { + const label = cell.secondary[slot]!; + label.hidden = !text; + if (text) cell.secondaryLine.appendChild(label); + else label.remove(); + label.style.cssText = ''; + label.textContent = text ?? ''; + label.style.color = tone(column.secondaryTone, 'secondary'); + if (style?.columnSecondary) + primitives.textStyle(label, style.columnSecondary); + } + ); + }); +} +function create(document: Document, row: DataRow, primitives: RowPrimitives) { + const body = createElement( + document, + 'div', + 'ok-native-list-row ok-native-list-data' + ); + body.dataset.nlRenderer = 'dataRow'; + const index = tagSlot( + createElement(document, 'span', 'ok-native-list-index'), + 'index' + ); + const favorite = createElement(document, 'span', 'ok-native-list-favorite'); + const controls = createElement(document, 'span'); + body.append(controls, index, favorite); + const columns = Array.from({ length: 4 }, () => { + const cell = createElement(document, 'span', 'ok-native-list-data-cell'); + const primaryLine = createElement( + document, + 'span', + 'ok-native-list-data-primary' + ); + const primary = tagSlot(createElement(document, 'span'), 'columns'); + const badges = createElement(document, 'span'); + primaryLine.append(primary, badges); + const secondaryLine = createElement( + document, + 'span', + 'ok-native-list-data-secondary-line' + ); + secondaryLine.style.display = 'flex'; + secondaryLine.style.gap = '4px'; + const secondary = Array.from({ length: 2 }, () => + tagSlot( + createElement(document, 'span', 'ok-native-list-secondary'), + 'columnSecondary' + ) + ); + secondaryLine.append(...secondary); + cell.append(primaryLine, secondaryLine); + body.appendChild(cell); + return { + body: cell, + primaryLine, + primary, + badges, + secondaryLine, + secondary, + }; + }); + views.set(body, { + favorite, + index, + controls, + columns, + visualKey: '', + primitives, + }); + bind(body, row, primitives); + return body; +} +function recycle(body: HTMLElement) { + const v = views.get(body); + if (!v) return; + if (v.visual) { + v.primitives.dispose(v.visual); + v.visual.remove(); + } + v.visual = undefined; + v.visualStyle = undefined; + v.visualKey = ''; + v.controls.replaceChildren(); +} +export const dataRowRenderer = { + key: 'dataRow' as const, + create, + bind, + recycle, + measure: (row: DataRow, _width: number) => + row.columns.some((column) => column.secondaryText) ? 60 : 56, + measureRendered: (_body: HTMLElement, _row: DataRow): number | undefined => + undefined, +}; diff --git a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts index 4c4c95371..41a708d39 100644 --- a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts +++ b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts @@ -1,3 +1,5 @@ +import type { DataRow } from '../../models'; +import { dataRowRenderer } from './DataRowRenderer'; import type { ActivityRow } from '../../models'; import { activityRowRenderer } from './ActivityRowRenderer'; import type { SystemRow } from '../../models'; @@ -36,6 +38,7 @@ function registration(renderer: Renderer, row: R) { }; } const factories = { + dataRow: (row: RowModel) => registration(dataRowRenderer, row as DataRow), activity: (row: RowModel) => registration(activityRowRenderer, row as ActivityRow), system: (row: RowModel) => registration(systemRowRenderer, row as SystemRow), @@ -47,6 +50,7 @@ const factories = { registration(mediaTileRowRenderer, row as MediaTileRow), }; const renderers = { + dataRow: dataRowRenderer, activity: activityRowRenderer, system: systemRowRenderer, action: actionRowRenderer, From 52e5a180c9057f1ff839c22b4ebe65ffb4544d29 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 20:17:48 +0800 Subject: [PATCH 44/71] test(native-list): use DataRow fixture rows explicitly --- .../src/__tests__/NativeList.web.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index 5992596d5..436faa1f1 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -2267,7 +2267,7 @@ describe('DataRow renderer lifecycle', () => { const dataSnapshot = ( layout: NativeListSnapshot['layout'], items: readonly RowModel[] - ): NativeListSnapshot => ({ schemaVersion: 1, generation: 1, layout, rows }); + ): NativeListSnapshot => ({ schemaVersion: 1, generation: 1, layout, rows: items }); const { JSDOM } = require('jsdom') as { JSDOM: new (html: string) => { window: { document: Document } }; }; From be8a3e7d80e37792c02bf680975dbef293feea3d Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 20:27:38 +0800 Subject: [PATCH 45/71] fix(native-list): preserve DataRow table padding defaults --- .../java/com/onekey/nativelist/NativeListDataRowRenderer.kt | 5 +++-- .../ios/NativeListDataRowRenderer.swift | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt index 70c3b1dfb..cfb84d844 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt @@ -86,9 +86,10 @@ internal class NativeListDataRowView(context: ThemedReactContext) : } val hp = if (style.has("horizontalPadding")) stylePx(style.optDouble("horizontalPadding")) - else dp(if (layout == "table") 16 else 12) + else dp(if (layout == "table") 20 else 12) val vp = - if (style.has("verticalPadding")) stylePx(style.optDouble("verticalPadding")) else dp(8) + if (style.has("verticalPadding")) stylePx(style.optDouble("verticalPadding")) + else dp(if (layout == "table") 10 else 8) setPadding(hp, vp, hp, vp) val active = data.optBoolean("favoriteActive") favorite.visibility = if (active || data.optBoolean("favorite")) VISIBLE else GONE diff --git a/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift b/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift index b293f31bd..a2c8b262b 100644 --- a/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift @@ -43,8 +43,8 @@ final class NativeListDataRowCell: NativeListRendererCell { ? .top : style.dictionary("container")?.string("contentVerticalAlignment") == "bottom" ? .bottom : .center - let hp = CGFloat(style.double("horizontalPadding", default: layout == "table" ? 16 : 12)) - let vp = CGFloat(style.double("verticalPadding", default: 8)) + let hp = CGFloat(style.double("horizontalPadding", default: layout == "table" ? 20 : 12)) + let vp = CGFloat(style.double("verticalPadding", default: layout == "table" ? 10 : 8)) contentInsets = UIEdgeInsets(top: vp, left: hp, bottom: vp, right: hp) favorite.isHidden = !data.bool("favorite") && !data.bool("favoriteActive") favorite.image = nativeListIcon( From 72793e0482f04e58c7e99ace7728e8b7e47a2fba Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 20:30:37 +0800 Subject: [PATCH 46/71] refactor(native-list): complete MetricCard renderer migration --- .../NativeListMetricCardRenderer.kt | 547 ++++++++++++++++++ .../com/onekey/nativelist/NativeListModels.kt | 16 +- .../nativelist/NativeListRendererRegistry.kt | 2 + .../onekey/nativelist/NativeListRowView.kt | 358 +----------- .../nativelist/NativeListStyleSlotTest.kt | 18 +- .../react-native-native-list/docs/DESIGN.md | 33 ++ .../react-native-native-list/docs/SPEC.md | 21 +- .../ios/NativeListCell.swift | 330 +---------- .../ios/NativeListMetricCardRenderer.swift | 400 +++++++++++++ .../ios/NativeListModels.swift | 2 +- .../ios/NativeListRendererCell.swift | 4 +- .../ios/NativeListRendererRegistry.swift | 4 + .../ios/RNCNativeListView.swift | 4 - .../src/__tests__/NativeList.web.test.ts | 129 ++++- .../src/web/NativeListWebEngine.ts | 263 +-------- .../web/templates/MetricCardRowRenderer.ts | 328 +++++++++++ .../src/web/templates/RowRendererRegistry.ts | 5 + 17 files changed, 1508 insertions(+), 956 deletions(-) create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMetricCardRenderer.kt create mode 100644 native-views/react-native-native-list/ios/NativeListMetricCardRenderer.swift create mode 100644 native-views/react-native-native-list/src/web/templates/MetricCardRowRenderer.ts diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMetricCardRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMetricCardRenderer.kt new file mode 100644 index 000000000..e9f5b2be4 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMetricCardRenderer.kt @@ -0,0 +1,547 @@ +package com.margelo.nitro.nativelist + +import android.graphics.Color +import android.graphics.Outline +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.text.TextUtils +import android.view.Gravity +import android.view.View +import android.view.ViewOutlineProvider +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.widget.TextViewCompat +import com.facebook.react.uimanager.ThemedReactContext +import org.json.JSONArray +import org.json.JSONObject + +internal class NativeListMetricCardRowView(context: ThemedReactContext) : + NativeListRendererRowView(context) { + private val visual = NativeListLeadingVisual(context) + private val column = LinearLayout(context).apply { orientation = VERTICAL } + private val titleLine = + LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + private val title = NativeListTextView(context) + private val label = NativeListTextView(context) + private val trend = NativeListTextView(context) + private val detail = NativeListTextView(context) + private val badge = NativeListTextView(context) + private val metricImages = List(5) { NativeListImageSlot(context) } + private val usedImages = mutableSetOf() + private var rowKey = "" + private var compositeMetricTitle: TextView? = null + override val assetFields = listOf("visual", "metrics") + override val defaultCornerRadius = 12 + + override fun unselectedBackground( + item: NativeListItem, + theme: JSONObject?, + layout: String, + itemIndex: Int?, + ) = + if (layout == "sectioned" && !item.json.optBoolean("selected")) + super.unselectedBackground(item, theme, layout, itemIndex) + else color(theme, "subduedBackground", "#F9F9F9") + + override fun defaultHeight(item: NativeListItem, layout: String) = + if (item.json.optString("variant") in setOf("activity", "performance")) 0 else 132 + + init { + titleLine.addView(title, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + titleLine.addView(badge) + column.addView(titleLine) + column.addView(label) + column.addView(trend) + column.addView(detail) + } + + private fun color(theme: JSONObject?, name: String, fallback: String) = + parseNativeListColor(theme?.optString(name, fallback) ?: fallback) + + private fun safeColor(value: String, fallback: Int) = + runCatching { parseNativeListColor(value) }.getOrDefault(fallback) + + private fun roundedFill(color: Int, radius: Float) = + GradientDrawable().apply { + setColor(color) + cornerRadius = dp(radius.toInt()).toFloat() + } + + private fun sp(value: Float) = if (sourceScale) value else NativeListScale.font(resources, value) + + private fun wrap() = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + + private val circleOutlineProvider = + object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setOval(0, 0, view.width, view.height) + } + } + + private fun visualSources(visual: JSONObject): List> { + val kind = visual.optString("kind") + val variant = + when (kind) { + "token" -> "token" + "network" -> "network" + "account", + "wallet" -> "avatar" + else -> "generic" + } + if (kind == "icon") return emptyList() + if (kind == "stackedImages") { + val images = visual.optJSONArray("images") ?: return emptyList() + return (0 until images.length()).map { images.getJSONObject(it) to "generic" } + } + return visual.optJSONObject("image")?.let { listOf(it to variant) } ?: emptyList() + } + + private fun dataTextColor(tone: String, theme: JSONObject?): Int = + when (tone) { + "positive" -> color(theme, "positive", "#00713FDE") + "negative" -> color(theme, "negative", "#C40006D3") + "secondary" -> color(theme, "secondaryText", "#0000009B") + else -> color(theme, "primaryText", "#000000DF") + } + + override fun bindContent( + item: NativeListItem, + theme: JSONObject?, + layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + removeAllViews() + usedImages.clear() + rowKey = item.key + compositeMetricTitle = null + orientation = VERTICAL + gravity = Gravity.START + val data = item.json + val style = data.optJSONObject("style") ?: JSONObject() + val image = style.optJSONObject("image") ?: JSONObject() + val hp = + if (style.has("horizontalPadding")) stylePx(style.optDouble("horizontalPadding")) else dp(14) + val vp = + if (style.has("verticalPadding")) stylePx(style.optDouble("verticalPadding")) else dp(14) + setPadding(hp, vp, hp, vp) + fun text( + view: NativeListTextView, + value: String, + slot: String, + size: Float, + weight: String = "regular", + ink: Int = color(theme, "secondaryText", "#0000009B"), + ) { + NativeListResolvedText.resolve( + context, + value, + style.optJSONObject(slot), + size, + weight, + ink, + 0, + 1, + sourceScale || slot == "subtitle", + ) + .bind(view) + if (style.optJSONObject(slot)?.optString("truncate") != "clip") + view.ellipsize = TextUtils.TruncateAt.END + } + if (data.optString("variant") in setOf("activity", "performance")) { + visual.recycle() + bindCompositeMetricCard(item, theme, data.optString("variant")) + val heading = compositeMetricTitle as? NativeListTextView + if (heading != null) { + val spacing = heading.letterSpacing + NativeListResolvedText.resolve( + context, + data.optString("title").uppercase(), + style.optJSONObject("title"), + 11f, + "regular", + color(theme, "disabledText", "#00000072"), + 14, + 1, + sourceScale, + ) + .bind(heading) + heading.letterSpacing = spacing + } + if (style.has("lineGap")) { + val gap = stylePx(style.optDouble("lineGap")) + for (index in 1 until childCount) { + val params = getChildAt(index).layoutParams as LayoutParams + params.topMargin = gap + params.bottomMargin = 0 + getChildAt(index).layoutParams = params + } + } + } else { + data.optJSONObject("visual")?.let { descriptor -> + (visual.parent as? android.view.ViewGroup)?.removeView(visual) + visual.bind(descriptor, image, item.key, theme, false, sourceScale) + addView( + visual, + LayoutParams( + if (image.has("width")) stylePx(image.optDouble("width")) else dp(32), + if (image.has("height")) stylePx(image.optDouble("height")) else dp(32), + ) + .apply { + marginEnd = dp(12) + bottomMargin = + if (style.has("leadingGap")) stylePx(style.optDouble("leadingGap")) + else if (style.has("lineGap")) stylePx(style.optDouble("lineGap")) else 0 + }, + ) + } ?: visual.recycle() + text( + title, + data.optString("value"), + "value", + if (data.optString("size") == "large") 24f else 18f, + "semibold", + color(theme, "primaryText", "#000000DF"), + ) + text( + label, + data.optString("title"), + "title", + 11f, + ink = color(theme, "disabledText", "#00000072"), + ) + text( + trend, + data.optString("trend"), + "trend", + 12f, + ink = + when (data.optString("trendTone")) { + "positive" -> color(theme, "positive", "#00713FDE") + "negative" -> color(theme, "negative", "#C40006D3") + else -> color(theme, "secondaryText", "#0000009B") + }, + ) + text(detail, data.optString("subtitle"), "subtitle", 14f) + NativeListResolvedText.resolve( + context, + data.optJSONObject("badge")?.optString("text") ?: "", + null, + 12f, + "medium", + color(theme, "accent", "#0D8200FC"), + 0, + 1, + sourceScale, + ) + .bind(badge) + (badge.layoutParams as LayoutParams).marginStart = dp(8) + for (view in listOf(label, trend, detail)) (view.layoutParams as LayoutParams).topMargin = + if (style.has("lineGap")) stylePx(style.optDouble("lineGap")) else 0 + (column.parent as? android.view.ViewGroup)?.removeView(column) + addView(column, weightedWidth()) + } + for (index in metricImages.indices) if (index !in usedImages) metricImages[index].recycle() + style + .optJSONObject("container") + ?.optString("contentVerticalAlignment") + ?.takeIf { it.isNotEmpty() } + ?.let { + gravity = + Gravity.START or + when (it) { + "top" -> Gravity.TOP + "bottom" -> Gravity.BOTTOM + else -> Gravity.CENTER_VERTICAL + } + } + } + + override fun recycleContent() { + visual.recycle() + metricImages.forEach { it.recycle() } + removeAllViews() + } + + override fun disposeContent() { + visual.dispose() + metricImages.forEach { it.dispose() } + removeAllViews() + } + + private fun bindCompositeMetricCard(item: NativeListItem, theme: JSONObject?, variant: String) { + val metrics = item.json.optJSONArray("metrics") ?: JSONArray() + addView( + metricText( + value = item.json.optString("title").uppercase(), + size = 11f, + lineHeight = 14, + typeface = NativeListFonts.regular(context), + textColor = color(theme, "disabledText", "#00000072"), + letterSpacingDp = 1.2f, + ) + .also { compositeMetricTitle = it }, + weightedWidth(), + ) + if (variant == "activity") { + addView(activityHeroMetrics(metrics, theme), weightedWidth().apply { topMargin = dp(14) }) + addView( + View(context).apply { setBackgroundColor(color(theme, "separator", "#0000001F")) }, + LayoutParams(LayoutParams.MATCH_PARENT, 1).apply { + topMargin = dp(14) + bottomMargin = dp(14) + }, + ) + addView(activityCompactMetrics(metrics, theme), weightedWidth()) + } else { + val winRateRow = + LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.BOTTOM + if (metrics.length() > 0) { + addView( + makeMetricColumn( + metric = metrics.getJSONObject(0), + visualSlot = 0, + theme = theme, + alignment = Gravity.START, + valueSize = 18f, + valueLineHeight = 24, + valueTypeface = NativeListFonts.semibold(context), + ), + LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f), + ) + } + if (metrics.length() > 1) { + addView( + makeMetricColumn( + metric = metrics.getJSONObject(1), + visualSlot = 1, + theme = theme, + alignment = Gravity.END, + valueSize = 14f, + valueLineHeight = 20, + valueTypeface = NativeListFonts.semibold(context), + ), + LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f), + ) + } + } + addView(winRateRow, weightedWidth().apply { topMargin = dp(14) }) + val progressValue = item.json.optDouble("progress", 0.0).coerceIn(0.0, 1.0) + val progress = + LinearLayout(context).apply { + orientation = HORIZONTAL + clipToOutline = true + outlineProvider = + object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect(0, 0, view.width, view.height, dp(2).toFloat()) + } + } + if (progressValue > 0.0) { + addView( + View(context).apply { setBackgroundColor(parseNativeListColor("#22AB15")) }, + LayoutParams(0, LayoutParams.MATCH_PARENT, progressValue.toFloat()), + ) + } + if (progressValue < 1.0) { + addView( + View(context).apply { setBackgroundColor(parseNativeListColor("#E5484D")) }, + LayoutParams(0, LayoutParams.MATCH_PARENT, (1.0 - progressValue).toFloat()), + ) + } + } + addView(progress, LayoutParams(LayoutParams.MATCH_PARENT, dp(4)).apply { topMargin = dp(8) }) + addView( + performanceAverageMetrics(metrics, theme), + weightedWidth().apply { topMargin = dp(14) }, + ) + } + } + + private fun activityHeroMetrics(metrics: JSONArray, theme: JSONObject?) = + LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.BOTTOM + if (metrics.length() > 0) { + addView( + makeMetricColumn( + metric = metrics.getJSONObject(0), + visualSlot = 0, + theme = theme, + alignment = Gravity.START, + valueSize = 16f, + valueLineHeight = 24, + valueTypeface = NativeListFonts.semibold(context), + ), + LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f), + ) + } + if (metrics.length() > 1) { + addView( + makeMetricColumn( + metric = metrics.getJSONObject(1), + visualSlot = 1, + theme = theme, + alignment = Gravity.END, + valueSize = 14f, + valueLineHeight = 20, + valueTypeface = NativeListFonts.semibold(context), + ), + LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f), + ) + } + } + + private fun activityCompactMetrics(metrics: JSONArray, theme: JSONObject?) = + LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + for (index in 2 until metrics.length()) { + val alignment = + when (index) { + 2 -> Gravity.START + metrics.length() - 1 -> Gravity.END + else -> Gravity.CENTER_HORIZONTAL + } + addView( + makeMetricColumn( + metric = metrics.getJSONObject(index), + visualSlot = index, + theme = theme, + alignment = alignment, + valueSize = 14f, + valueLineHeight = 20, + valueTypeface = NativeListFonts.medium(context), + ), + LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f), + ) + } + } + + private fun performanceAverageMetrics(metrics: JSONArray, theme: JSONObject?) = + LinearLayout(context).apply { + orientation = HORIZONTAL + val indices = + listOf(2, metrics.length() - 1).distinct().filter { it in 0 until metrics.length() } + indices.forEachIndexed { position, index -> + val metric = metrics.getJSONObject(index) + val card = + makeMetricColumn( + metric = metric, + visualSlot = index, + theme = theme, + alignment = Gravity.START, + valueSize = 14f, + valueLineHeight = 20, + valueTypeface = NativeListFonts.medium(context), + ) + .apply { + setPadding(dp(10), dp(10), dp(10), dp(10)) + background = roundedFill(color(theme, "strongBackground", "#0000000F"), 8f) + } + addView( + card, + LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f).apply { + if (position > 0) marginStart = dp(8) + }, + ) + } + } + + private fun makeMetricColumn( + metric: JSONObject, + visualSlot: Int, + theme: JSONObject?, + alignment: Int, + valueSize: Float, + valueLineHeight: Int, + valueTypeface: Typeface, + ) = + LinearLayout(context).apply { + orientation = VERTICAL + gravity = alignment + addView( + metricText( + value = metric.optString("label"), + size = 11f, + lineHeight = 14, + typeface = NativeListFonts.regular(context), + textColor = color(theme, "disabledText", "#00000072"), + gravity = alignment, + ) + ) + val valueText = + metricText( + value = metric.optString("value"), + size = valueSize, + lineHeight = valueLineHeight, + typeface = valueTypeface, + textColor = dataTextColor(metric.optString("tone"), theme), + gravity = alignment, + ) + val valueVisual = metric.optJSONObject("visual") + val valueView = + if (valueVisual != null && visualSlot in metricImages.indices) { + LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + val slot = metricImages[visualSlot] + usedImages.add(visualSlot) + val image = slot.view + (image.parent as? android.view.ViewGroup)?.removeView(image) + image.background = null + image.visibility = VISIBLE + image.outlineProvider = circleOutlineProvider + image.clipToOutline = true + valueVisual.optString("backgroundColor").takeIf(String::isNotEmpty)?.let { + backgroundColor -> + image.background = roundedFill(safeColor(backgroundColor, Color.WHITE), 8f) + } + visualSources(valueVisual).firstOrNull()?.let { (source, variant) -> + slot.bind(source, rowKey + ":" + visualSlot + ":" + metric.optString("key"), variant) + } ?: slot.recycle() + addView(image, LayoutParams(dp(16), dp(16)).apply { marginEnd = dp(6) }) + addView(valueText, wrap()) + } + } else { + valueText + } + addView( + valueView, + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + topMargin = dp(2) + }, + ) + } + + private fun metricText( + value: String, + size: Float, + lineHeight: Int, + typeface: Typeface, + textColor: Int, + letterSpacingDp: Float = 0f, + gravity: Int = Gravity.START, + ) = + NativeListTextView(context).apply { + includeFontPadding = false + text = value + textSize = sp(size) + this.typeface = typeface + setTextColor(textColor) + this.gravity = gravity + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + fontFeatureSettings = "tnum" + TextViewCompat.setLineHeight(this, dp(lineHeight)) + if (letterSpacingDp > 0f) { + letterSpacing = letterSpacingDp / sp(size) + } + } + + private fun weightedWidth() = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index 63a0816d2..6a8e0def2 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -27,10 +27,8 @@ internal fun isNativeListWholeRowInteractive( /** * Maps a style key - which names a model field - to the view slot that renders it. - * The view pool is shared across templates and the mapping is not one to one: - * metricCard renders `value` through the title view and its own `title` through - * the subtitle view, and one status view carries activity.status, - * metricCard.trend. See docs/STYLE_SPEC.md section 4. + * Legacy Identity and SectionHeader share a small text view pool. + * Migrated templates resolve their own slots. See docs/STYLE_SPEC.md section 4. * * Returns null when the template does not render that field, so an unmapped key * is ignored rather than reaching an unrelated view. @@ -41,14 +39,6 @@ internal fun nativeListStyleSlot(type: String, variant: String, field: String): "title", "subtitle", "tertiary", "badge", "value", "valueSecondary" -> field else -> null } - // The large number and the small label sit in swapped views. - "metricCard" -> when (field) { - "value" -> "title" - "title" -> "subtitle" - "subtitle" -> "metricSubtitle" - "trend" -> "status" - else -> null - } "sectionHeader" -> when (field) { "title", "subtitle", "value" -> field else -> null @@ -81,7 +71,7 @@ private fun updateLengthPrefixed(digest: MessageDigest, value: String) { // Message and Rail own dedicated renderers. Remaining templates still share // a compatible legacy tree; keys and style values never partition the pool. -internal enum class NativeListRendererKey { LEGACY, MESSAGE, RAIL, MEDIA_TILE, ACTION, SYSTEM, ACTIVITY, DATA_ROW } +internal enum class NativeListRendererKey { LEGACY, MESSAGE, RAIL, MEDIA_TILE, ACTION, SYSTEM, ACTIVITY, DATA_ROW, METRIC_CARD } internal data class NativeListItem( val key: String, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt index b15d9a98d..d7cdb4b01 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt @@ -9,6 +9,7 @@ internal object NativeListRendererRegistry { NativeListRendererKey.MESSAGE to ::NativeListMessageRowView, NativeListRendererKey.RAIL to ::NativeListRailRowView, NativeListRendererKey.MEDIA_TILE to ::NativeListMediaTileRowView, + NativeListRendererKey.METRIC_CARD to ::NativeListMetricCardRowView, NativeListRendererKey.DATA_ROW to ::NativeListDataRowView, NativeListRendererKey.ACTIVITY to ::NativeListActivityRowView, NativeListRendererKey.SYSTEM to ::NativeListSystemRowView, @@ -20,6 +21,7 @@ internal object NativeListRendererRegistry { "message" to NativeListRendererKey.MESSAGE, "rail" to NativeListRendererKey.RAIL, "mediaTile" to NativeListRendererKey.MEDIA_TILE, + "metricCard" to NativeListRendererKey.METRIC_CARD, "dataRow" to NativeListRendererKey.DATA_ROW, "activity" to NativeListRendererKey.ACTIVITY, "system" to NativeListRendererKey.SYSTEM, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index 8bd0ed058..6c9cb996e 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -242,7 +242,6 @@ internal class NativeListRowView( private val headerValueIcon = OneKeyIconView(context) private val leadingActionIcon = OneKeyIconView(context) private val secondaryImage = OneKeyImageReusableView(reactContext) - private val metricVisualImages = List(5) { OneKeyImageReusableView(reactContext) } private val mainColumn = LinearLayout(context) private val titleLine = PackedTitleLineLayout(context) private val title = DottedUnderlineTextView(context) @@ -251,7 +250,6 @@ internal class NativeListRowView( private val marketSubtitleLine = PackedTitleLineLayout(context) private val tertiary = NativeListTextView(context) private val status = NativeListTextView(context) - private val metricSubtitle = NativeListTextView(context) private val badgeLine = NativeListTextView(context) private val marketBadgeViews = List(3) { LinearLayout(context) } private val marketBadgeLabels = List(3) { TextView(context) } @@ -294,7 +292,6 @@ internal class NativeListRowView( // OneKey patch: a row style wrote view properties that resetViews does not restore. private var styledViewsDirty = false private val styledTextRestorations = mutableMapOf Unit>() - private var compositeMetricTitle: TextView? = null private val styledBoxRestorations = mutableListOf<() -> Unit>() private val semanticBadgeLabels = mutableListOf() private val semanticSubtitleLabels = mutableListOf() @@ -345,15 +342,13 @@ internal class NativeListRowView( subtitle.typeface = NativeListFonts.regular(context) tertiary.typeface = NativeListFonts.regular(context) status.typeface = NativeListFonts.regular(context) - metricSubtitle.typeface = NativeListFonts.regular(context) badgeLine.typeface = NativeListFonts.medium(context) - listOf(title, subtitle, tertiary, status, metricSubtitle, badgeLine).forEach { + listOf(title, subtitle, tertiary, status, badgeLine).forEach { it.includeFontPadding = false it.fontFeatureSettings = "tnum" } subtitle.maxLines = 2 status.maxLines = 1 - metricSubtitle.maxLines = 1 badgeLine.maxLines = 1 titleLine.orientation = HORIZONTAL titleLine.gravity = Gravity.CENTER_VERTICAL @@ -380,7 +375,6 @@ internal class NativeListRowView( mainColumn.addView(subtitle) mainColumn.addView(tertiary) mainColumn.addView(status) - mainColumn.addView(metricSubtitle) @@ -670,7 +664,6 @@ internal class NativeListRowView( selectorImages.forEach(OneKeyImageReusableView::prepareForReuse) leadingImages.forEach(OneKeyImageReusableView::prepareForReuse) secondaryImage.prepareForReuse() - metricVisualImages.forEach(OneKeyImageReusableView::prepareForReuse) } resetViews() @@ -704,9 +697,7 @@ internal class NativeListRowView( ) applySelectionState(item, theme, layout, itemIndex, selected) pressedRowBackground = groupedBackground( - when (item.type) { - else -> "single" - }, + "single", color( theme, "rowPressedBackground", @@ -715,17 +706,12 @@ internal class NativeListRowView( ) background = if (touchPressed || reorderActive) pressedRowBackground else restingRowBackground if (layout == "table") { - if (item.type == "dataRow") { - setPadding(dp(20), dp(10), dp(20), dp(10)) - } else { - setPadding(dp(16), dp(8), dp(16), dp(8)) - } + setPadding(dp(16), dp(8), dp(16), dp(8)) } title.setTextColor(primary) subtitle.setTextColor(secondary) tertiary.setTextColor(secondary) status.setTextColor(secondary) - metricSubtitle.setTextColor(secondary) badgeLine.setTextColor(accent) separatorPaint.color = safeColor( listStyle?.optJSONObject("separator")?.optString("color"), @@ -749,7 +735,6 @@ internal class NativeListRowView( "walletGroup" -> bindWalletGroup(item, theme, layout, listOrientation, checkboxState) "identity" -> bindIdentity(item, theme, selected, checkboxState) "market" -> bindMarket(item, theme) - "metricCard" -> bindMetricCard(item, theme) "sectionHeader" -> bindSectionHeader(item, theme, checkboxState) } // Keep passive rows out of accessibility and keyboard focus while allowing @@ -825,11 +810,8 @@ internal class NativeListRowView( val inset = styleDp(style.optDouble("verticalPadding")) setPadding(paddingLeft, inset, paddingRight, inset) } - if (style.has("lineGap") && item.type != "walletGroup" && item.type != "dataRow") { - val target = when { - item.type == "metricCard" && item.json.optString("variant") in setOf("activity", "performance") -> this - else -> mainColumn - } + if (style.has("lineGap") && item.type != "walletGroup") { + val target = mainColumn styleGap(target, styleDp(style.optDouble("lineGap"))) } if (style.has("leadingGap") && leadingFrame.parent != null) { @@ -895,12 +877,6 @@ internal class NativeListRowView( val variant = item.json.optString("variant") - if (item.type == "metricCard" && variant in setOf("activity", "performance")) { - style.optJSONObject("title")?.let { titleStyle -> - compositeMetricTitle?.let { applyStyledText(it, titleStyle) } - } - return - } val fields = style.keys() while (fields.hasNext()) { val field = fields.next() @@ -952,7 +928,6 @@ internal class NativeListRowView( "subtitle" -> subtitle "tertiary" -> tertiary "status" -> status - "metricSubtitle" -> metricSubtitle "badge" -> badgeLine "value" -> trailingViews[0] "valueSecondary" -> trailingViews[1] @@ -1064,7 +1039,6 @@ internal class NativeListRowView( selectorImages.forEach(OneKeyImageReusableView::prepareForReuse) leadingImages.forEach(OneKeyImageReusableView::prepareForReuse) secondaryImage.prepareForReuse() - metricVisualImages.forEach(OneKeyImageReusableView::prepareForReuse) walletGroupRows.forEach { row -> row.visibility = VISIBLE row.alpha = 1f @@ -1159,7 +1133,6 @@ internal class NativeListRowView( selectorImages.clear() leadingImages.forEach(OneKeyImageReusableView::dispose) secondaryImage.dispose() - metricVisualImages.forEach(OneKeyImageReusableView::dispose) marketBadgeImages.forEach(OneKeyImageReusableView::dispose) walletGroupRows.forEach(NativeListRowView::dispose) } @@ -1241,7 +1214,6 @@ internal class NativeListRowView( private fun resetViews() { resetRowStyle() - compositeMetricTitle = null semanticBadgeLabels.clear() semanticSubtitleLabels.clear() semanticValueViews.clear() @@ -1341,7 +1313,6 @@ internal class NativeListRowView( subtitle.text = "" tertiary.text = "" status.text = "" - metricSubtitle.text = "" badgeLine.text = "" title.setLineSpacing(0f, 1f) subtitle.setLineSpacing(0f, 1f) @@ -1364,7 +1335,6 @@ internal class NativeListRowView( subtitle.visibility = GONE tertiary.visibility = GONE status.visibility = GONE - metricSubtitle.visibility = GONE badgeLine.visibility = GONE badgeLine.background = null badgeLine.setPadding(0, 0, 0, 0) @@ -1406,13 +1376,6 @@ internal class NativeListRowView( leadingFrame.clipToPadding = false secondaryImage.visibility = GONE secondaryImage.foreground = null - metricVisualImages.forEach { - (it.parent as? ViewGroup)?.removeView(it) - it.visibility = GONE - it.background = null - it.clipToOutline = false - it.outlineProvider = ViewOutlineProvider.BACKGROUND - } unreadDot.visibility = GONE leadingIcon.visibility = GONE leadingIcon.iconName = "" @@ -2251,295 +2214,6 @@ internal class NativeListRowView( else -> color(theme, "primaryText", "#000000DF") } - private fun bindMetricCard(item: NativeListItem, theme: JSONObject?) { - orientation = VERTICAL - gravity = Gravity.START - setPadding(dp(14), dp(14), dp(14), dp(14)) - val variant = item.json.optString("variant", "standard") - if (variant == "activity" || variant == "performance") { - bindCompositeMetricCard(item, theme, variant) - return - } - item.json.optJSONObject("visual")?.let { - addLeading(it, 32) - leadingFrame.clipChildren = true - leadingFrame.clipToPadding = true - } - showText(subtitle, item.json.optString("title"), 1) - subtitle.setTextColor(color(theme, "disabledText", "#00000072")) - showText(title, item.json.optString("value"), 1) - title.textSize = sp(if (item.json.optString("size") == "large") 24f else 18f) - title.typeface = NativeListFonts.semibold(context) - showText(status, item.json.optString("trend"), 1) - status.setTextColor( - when (item.json.optString("trendTone", "neutral")) { - "positive" -> color(theme, "positive", "#00713FDE") - "negative" -> color(theme, "negative", "#C40006D3") - else -> color(theme, "secondaryText", "#0000009B") - }, - ) - showText(metricSubtitle, item.json.optString("subtitle"), 1) - item.json.optJSONObject("badge")?.optString("text")?.let { showText(badgeLine, it, 1) } - addView(mainColumn, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) - } - - private fun bindCompositeMetricCard(item: NativeListItem, theme: JSONObject?, variant: String) { - val metrics = item.json.optJSONArray("metrics") ?: JSONArray() - addView( - metricText( - value = item.json.optString("title").uppercase(), - size = 11f, - lineHeight = 14, - typeface = NativeListFonts.regular(context), - textColor = color(theme, "disabledText", "#00000072"), - letterSpacingDp = 1.2f, - ).also { compositeMetricTitle = it }, - weightedWidth(), - ) - if (variant == "activity") { - addView(activityHeroMetrics(metrics, theme), weightedWidth().apply { - topMargin = dp(14) - }) - addView(View(context).apply { - setBackgroundColor(color(theme, "separator", "#0000001F")) - }, LayoutParams(LayoutParams.MATCH_PARENT, 1).apply { - topMargin = dp(14) - bottomMargin = dp(14) - }) - addView(activityCompactMetrics(metrics, theme), weightedWidth()) - } else { - val winRateRow = LinearLayout(context).apply { - orientation = HORIZONTAL - gravity = Gravity.BOTTOM - if (metrics.length() > 0) { - addView( - makeMetricColumn( - metric = metrics.getJSONObject(0), - visualSlot = 0, - theme = theme, - alignment = Gravity.START, - valueSize = 18f, - valueLineHeight = 24, - valueTypeface = NativeListFonts.semibold(context), - ), - LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f), - ) - } - if (metrics.length() > 1) { - addView( - makeMetricColumn( - metric = metrics.getJSONObject(1), - visualSlot = 1, - theme = theme, - alignment = Gravity.END, - valueSize = 14f, - valueLineHeight = 20, - valueTypeface = NativeListFonts.semibold(context), - ), - LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f), - ) - } - } - addView(winRateRow, weightedWidth().apply { topMargin = dp(14) }) - val progressValue = item.json.optDouble("progress", 0.0).coerceIn(0.0, 1.0) - val progress = LinearLayout(context).apply { - orientation = HORIZONTAL - clipToOutline = true - outlineProvider = object : ViewOutlineProvider() { - override fun getOutline(view: View, outline: Outline) { - outline.setRoundRect(0, 0, view.width, view.height, dp(2).toFloat()) - } - } - if (progressValue > 0.0) { - addView(View(context).apply { - setBackgroundColor(parseNativeListColor("#22AB15")) - }, LayoutParams(0, LayoutParams.MATCH_PARENT, progressValue.toFloat())) - } - if (progressValue < 1.0) { - addView(View(context).apply { - setBackgroundColor(parseNativeListColor("#E5484D")) - }, LayoutParams(0, LayoutParams.MATCH_PARENT, (1.0 - progressValue).toFloat())) - } - } - addView(progress, LayoutParams(LayoutParams.MATCH_PARENT, dp(4)).apply { - topMargin = dp(8) - }) - addView(performanceAverageMetrics(metrics, theme), weightedWidth().apply { - topMargin = dp(14) - }) - } - } - - private fun activityHeroMetrics(metrics: JSONArray, theme: JSONObject?) = - LinearLayout(context).apply { - orientation = HORIZONTAL - gravity = Gravity.BOTTOM - if (metrics.length() > 0) { - addView( - makeMetricColumn( - metric = metrics.getJSONObject(0), - visualSlot = 0, - theme = theme, - alignment = Gravity.START, - valueSize = 16f, - valueLineHeight = 24, - valueTypeface = NativeListFonts.semibold(context), - ), - LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f), - ) - } - if (metrics.length() > 1) { - addView( - makeMetricColumn( - metric = metrics.getJSONObject(1), - visualSlot = 1, - theme = theme, - alignment = Gravity.END, - valueSize = 14f, - valueLineHeight = 20, - valueTypeface = NativeListFonts.semibold(context), - ), - LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f), - ) - } - } - - private fun activityCompactMetrics(metrics: JSONArray, theme: JSONObject?) = - LinearLayout(context).apply { - orientation = HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - for (index in 2 until metrics.length()) { - val alignment = when (index) { - 2 -> Gravity.START - metrics.length() - 1 -> Gravity.END - else -> Gravity.CENTER_HORIZONTAL - } - addView( - makeMetricColumn( - metric = metrics.getJSONObject(index), - visualSlot = index, - theme = theme, - alignment = alignment, - valueSize = 14f, - valueLineHeight = 20, - valueTypeface = NativeListFonts.medium(context), - ), - LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f), - ) - } - } - - private fun performanceAverageMetrics(metrics: JSONArray, theme: JSONObject?) = - LinearLayout(context).apply { - orientation = HORIZONTAL - val indices = listOf(2, metrics.length() - 1).distinct().filter { it in 0 until metrics.length() } - indices.forEachIndexed { position, index -> - val metric = metrics.getJSONObject(index) - val card = makeMetricColumn( - metric = metric, - visualSlot = index, - theme = theme, - alignment = Gravity.START, - valueSize = 14f, - valueLineHeight = 20, - valueTypeface = NativeListFonts.medium(context), - ).apply { - setPadding(dp(10), dp(10), dp(10), dp(10)) - background = roundedFill(color(theme, "strongBackground", "#0000000F"), 8f) - } - addView(card, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f).apply { - if (position > 0) marginStart = dp(8) - }) - } - } - - private fun makeMetricColumn( - metric: JSONObject, - visualSlot: Int, - theme: JSONObject?, - alignment: Int, - valueSize: Float, - valueLineHeight: Int, - valueTypeface: Typeface, - ) = LinearLayout(context).apply { - orientation = VERTICAL - gravity = alignment - addView( - metricText( - value = metric.optString("label"), - size = 11f, - lineHeight = 14, - typeface = NativeListFonts.regular(context), - textColor = color(theme, "disabledText", "#00000072"), - gravity = alignment, - ), - ) - val valueText = metricText( - value = metric.optString("value"), - size = valueSize, - lineHeight = valueLineHeight, - typeface = valueTypeface, - textColor = dataTextColor(metric.optString("tone"), theme), - gravity = alignment, - ) - val valueVisual = metric.optJSONObject("visual") - val valueView = if (valueVisual != null && visualSlot in metricVisualImages.indices) { - LinearLayout(context).apply { - orientation = HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - val image = metricVisualImages[visualSlot] - image.visibility = VISIBLE - image.outlineProvider = circleOutlineProvider - image.clipToOutline = true - valueVisual.optString("backgroundColor").takeIf(String::isNotEmpty)?.let { backgroundColor -> - image.background = roundedFill( - safeColor(backgroundColor, Color.WHITE), - 8f, - ) - } - visualSources(valueVisual).firstOrNull()?.let { (source, variant) -> - bindImage(source, image, boundKey ?: "metric", visualSlot, variant) - } - addView(image, LayoutParams(dp(16), dp(16)).apply { marginEnd = dp(6) }) - addView(valueText, wrap()) - } - } else { - valueText - } - addView( - valueView, - LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { - topMargin = dp(2) - }, - ) - } - - private fun metricText( - value: String, - size: Float, - lineHeight: Int, - typeface: Typeface, - textColor: Int, - letterSpacingDp: Float = 0f, - gravity: Int = Gravity.START, - ) = NativeListTextView(context).apply { - includeFontPadding = false - text = value - textSize = sp(size) - this.typeface = typeface - setTextColor(textColor) - this.gravity = gravity - maxLines = 1 - ellipsize = TextUtils.TruncateAt.END - fontFeatureSettings = "tnum" - TextViewCompat.setLineHeight(this, dp(lineHeight)) - if (letterSpacingDp > 0f) { - letterSpacing = letterSpacingDp / sp(size) - } - } - - private fun weightedWidth() = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) - private fun bindSectionHeader( item: NativeListItem, theme: JSONObject?, @@ -3200,17 +2874,11 @@ internal class NativeListRowView( if (selected) "rowSelectedBackground" else "rowBackground", if (selected) "#0000000F" else "#FFFFFF", ) - if (item.type == "metricCard" && !selected) { - rowBackground = color(theme, "subduedBackground", "#F9F9F9") - } val groupPosition = when { item.type == "identity" && item.json.optString("presentation") == "walletSidebar" -> "single" // OneKey patch: explicit selector rows preserve the v1 ListItem corner radius. item.type == "identity" && item.json.optString("presentation") in setOf("accountSelector", "networkSelector") && item.json.has("height") -> "single" - else -> when (item.type) { - "metricCard" -> "single" else -> item.json.optString("groupPosition") - } } var backgroundGroupPosition = groupPosition if (layout == "sectioned" && !item.json.optBoolean("selected", false)) { @@ -3218,14 +2886,6 @@ internal class NativeListRowView( // matching iOS and the app-monorepo network selector. rowBackground = color(theme, "rowBackground", "#FFFFFF") if (item.type == "sectionHeader") backgroundGroupPosition = "" - } else if ( - layout == "table" && - item.type == "dataRow" && - (if (item.json.has("index")) item.json.optInt("index") else itemIndex)?.rem(2) == 0 && - !selected - ) { - rowBackground = color(theme, "subduedBackground", "#F9F9F9") - backgroundGroupPosition = "" } // OneKey patch: section heading backgrounds are independent of list rows. if (item.json.has("backgroundColor")) rowBackground = safeColor(item.json.optString("backgroundColor"), rowBackground) @@ -3445,7 +3105,6 @@ internal class NativeListRowView( item.json.optString("variant") == "history" || item.sectionKey?.startsWith("history-") == true -> 12f else -> 14f } - "metricCard" -> if (item.json.optString("size") == "large") 24f else 18f else -> 16f } }, @@ -3462,12 +3121,10 @@ internal class NativeListRowView( item.sectionKey in setOf("linear-tokens", "action-tokens") -> NativeListFonts.regular(context) else -> NativeListFonts.semibold(context) } - "metricCard" -> NativeListFonts.semibold(context) else -> NativeListFonts.medium(context) } } subtitle.textSize = sp(when (item.type) { - "metricCard" -> 11f else -> 14f }) tertiary.textSize = sp(14f) @@ -3533,11 +3190,6 @@ internal class NativeListRowView( } isNetworkSelectorIdentity -> 47 else -> when (item.type) { - "metricCard" -> when (item.json.optString("variant")) { - "activity" -> 0 - "performance" -> 0 - else -> 132 - } "sectionHeader" -> when { isNetworkSelectorSection -> 44 currentLayout == "table" -> 28 diff --git a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt index 4b7a82e2c..4abcd117f 100644 --- a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt +++ b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt @@ -14,22 +14,16 @@ class NativeListStyleSlotTest { @Test fun styleKeyResolvesToTheViewThatRendersThatModelField() { val cases = listOf( - // metricCard swaps the two views: the large number is drawn by the title - // view and the small label by the subtitle view. - Case(type = "metricCard", field = "value", expected = "title"), - Case(type = "metricCard", field = "title", expected = "subtitle"), - Case(type = "metricCard", field = "subtitle", expected = "metricSubtitle"), - Case(type = "metricCard", field = "trend", expected = "status"), // identity keeps its own names. Case(type = "identity", field = "title", expected = "title"), Case(type = "identity", field = "valueSecondary", expected = "valueSecondary"), // Legacy status views only serve unmigrated templates. Case(type = "rail", field = "status", expected = null), Case(type = "message", field = "time", expected = null), - // Amounts, indices and values share the two trailing views. + // Migrated templates no longer resolve slots through the legacy pool. Case(type = "system", field = "title", variant = "retry", expected = null), Case(type = "sectionHeader", field = "value", expected = "value"), - // Only the warning variant renders a separate title. + Case(type = "system", field = "message", variant = "warning", expected = null), Case(type = "system", field = "message", variant = "noMatch", expected = null), // A field the template does not render is ignored, never remapped onto @@ -51,14 +45,8 @@ class NativeListStyleSlotTest { // A collision would make one of the two keys silently win. val templates = listOf( Triple("identity", "", listOf("title", "subtitle", "tertiary", "badge", "value", "valueSecondary")), - Triple("activity", "", listOf("title", "description", "status", "primaryAmount", "secondaryAmount")), - Triple("dataRow", "", listOf("columns", "columnSecondary", "index")), - Triple("metricCard", "", listOf("title", "value", "subtitle", "trend")), Triple("sectionHeader", "", listOf("title", "subtitle", "value")), - // Only `warning` carries both a title and a message; the other variants - // have no title field at all, so their message owning the title view is - // not a collision. - Triple("system", "warning", listOf("title", "message", "actionText")), + ) templates.forEach { (type, variant, fields) -> diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index 190205c7f..537f90e3e 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -388,3 +388,36 @@ changes three columns to two and back, clearing secondary labels and styles while retaining the unchanged image. Native builds and Android unit tests pass; all 158 package tests and typecheck pass. Evidence: external runtime `ios-data-*`, `android-data-*`; MetricCard is the last stage 3 template. + +### Stage 3: MetricCard acceptance and closeout (2026-09-23) + +MetricCard completes the seven-template migration. Standard, activity and +performance layouts use dedicated native hosts and a persistent Web body. +Legacy metric allocations, composite builders, slot remapping and height +dispatch are removed. Shared iOS asset classification also accepts arrays; +metric visuals use independent slot identities even when metric keys repeat. +Text changes retain unchanged image requests. Removing an image invalidates +its pending work before a different visual is bound. + +The dedicated iOS/Android runtimes exercised all three variants, style +set/clear, explicit height restoration, end/top scroll, empty/repopulate, +incompatible-family replacement and fixed-footer display. Their default +geometry was compared with pre-migration captures; standard badge color and +Android subtitle sizing retain their original defaults. Final DataRow audit +also restored native table padding to 20 horizontal / 10 vertical. +Headed Chrome exercised the same transitions. The regression changes content +while styled, clears styles, cycles the three variants, and verifies stable +image elements and restored 40px standard / 16px composite visual dimensions. + +Validation: iOS Debug build/link, Android Debug APK and seven Android unit tests +pass; package typecheck and all 159 tests in five suites pass. Focused lint has +zero errors. Evidence remains in the external-drive validation runtime as +`ios-metric-*`, `android-metric-*`, `web-metric-*`, `ios-data-*`, +`android-data-*` and native build logs. These are functional/visual checks, +not scrolling-performance measurements. + +Stage 3 is complete: Rail, MediaTile, Action, System, Activity, DataRow and +MetricCard (7/7). With the earlier Message pilot, eight template types now use +the closed renderer registry. Stage 4 still owns Market/Identity/SectionHeader, +stage 5 owns WalletGroup, and stage 6 removes their remaining legacy policies. +Historical “remaining stage 3” lists above describe each incremental checkpoint. diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index b4bbf36fb..fa08f49b5 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -67,7 +67,7 @@ migration adds no new input limits, retry policy or automatic layout correction. ## Performance and resources Snapshots and patches cross the bridge in batches. Visible rows use platform -recycling/windowing; shared primitives own image cancellation. Message, Rail, MediaTile and Action use dedicated lightweight native hosts and persistent Web bodies. +recycling/windowing; shared primitives own image cancellation. Message and all seven stage 3 templates use dedicated lightweight native hosts and persistent Web bodies. None allocates the legacy native tree. This structural change is not a measured scrolling-performance result; performance claims still need a separate workload and measurements. @@ -78,12 +78,12 @@ separate workload and measurements. | --- | --- | --- | | 1. Baseline | Complete: [implicit keys, defaults, special updates and acceptance baseline](MIGRATION_BASELINE.md) inventoried | Preserve each existing rule or explicitly migrate its caller | | 2. Message pilot | Complete: registered lightweight native hosts, persistent Web body, resolved styling/measurement and lifecycle acceptance | Lightweight native hosts, resolved styling/measurement, update classification and lifecycle acceptance | -| 3. Simple templates | In progress (6/7): Rail, MediaTile, Action, System, Activity and DataRow migrated on all three platforms; MetricCard remains | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | +| 3. Simple templates | Complete (7/7): Rail, MediaTile, Action, System, Activity, DataRow and MetricCard migrated on all three platforms | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | | 4. Complex templates | Not started | Migrate Market, Identity and SectionHeader while preserving quote, selection and header behavior | | 5. WalletGroup | Not started | Independent member renderers; verify member events/styles, compact dragging and height restoration | | 6. Cleanup | Not started | Remove migrated key inference and global style maps; container no longer manipulates template internals | -The closed internal registry selects Message, Rail, MediaTile, Action or the legacy family. Message owns +The closed internal registry selects Message and the seven stage 3 renderers by template type. Market, Identity, SectionHeader and WalletGroup remain in the legacy family. Message owns its text column, optional leading visual and thumbnail; its native host does not allocate Market, WalletGroup or table views. Web preserves the Message body, text nodes and unchanged image elements when rebinding. Remaining templates @@ -131,7 +131,7 @@ style-slot, reset and width branches have been removed. Horizontal measurement now responds to explicit fonts, image dimensions, padding and gaps while retaining the existing default width allowances and platform limits. Text-only rebinding preserves unchanged image requests; clearing styles restores defaults. -Stage 3 still requires mediaTile, action, system, activity, dataRow and metricCard. +All seven stage 3 templates are now migrated; dated acceptance for each is recorded in DESIGN.md. MediaTile owns its picture, network indicator, title/subtitle, badge and close control. Its legacy bind/style/reset allocation paths are removed. Images retain @@ -147,3 +147,16 @@ carry the shared host epoch and preserve the original account-control anchor inset. Fixed footers now also resolve their host through the closed registry and replace it when their renderer family changes. Placement and gesture routing remain list-owned. + +MetricCard owns standard, activity and performance variants. Its existing +per-platform measurement, badge colors, composite spacing and progress layout +remain template defaults. Standard-card styles address title/value/subtitle/trend; +composite cards expose the heading slot and keep metric internals fixed as +defined in STYLE_SPEC.md. Image slots retain unchanged requests across rebinding +and reset when a source disappears or its slot identity changes. + +Stage 3 completion is not completion of the six-stage architecture plan. +Remaining work is stage 4 (Market, Identity, SectionHeader), stage 5 +(WalletGroup member rendering and drag acceptance), then stage 6 (retire the +remaining compatibility inference/style maps and container access to internals). +No migrated simple template dispatches through a legacy binder. diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index 214b85e64..126168cde 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -218,8 +218,6 @@ final class NativeListCell: NativeListRowHost { private let marketSubtitleSpacer = UIView() private let tertiaryLabel = NativeListTextLabel() private let statusLabel = NativeListInsetLabel() - private let metricSubtitleLabel = NativeListTextLabel() - private let metricCompositeStack = UIStackView() private let badgeLabel = NativeListInsetLabel() // OneKey patch: reuse the existing explicit line-box layout for styled Market badges. // private let marketBadgeButtons = (0..<3).map { _ in UIButton(type: .system) } @@ -465,12 +463,8 @@ final class NativeListCell: NativeListRowHost { subtitleLabel.font = nativeListFont(ofSize: 14) tertiaryLabel.font = nativeListFont(ofSize: 14) statusLabel.font = nativeListFont(ofSize: 12) - metricSubtitleLabel.font = nativeListFont(ofSize: 12) - metricCompositeStack.axis = .vertical - metricCompositeStack.alignment = .fill - metricCompositeStack.spacing = 12 badgeLabel.font = nativeListFont(ofSize: 12, weight: .medium) - [titleRowStack, subtitleLabel, tertiaryLabel, statusLabel, metricSubtitleLabel] + [titleRowStack, subtitleLabel, tertiaryLabel, statusLabel] .forEach(mainStack.addArrangedSubview) trailingStack.axis = .vertical @@ -606,7 +600,6 @@ final class NativeListCell: NativeListRowHost { subtitleLabel.textColor = secondary tertiaryLabel.textColor = secondary statusLabel.textColor = secondary - metricSubtitleLabel.textColor = secondary badgeLabel.textColor = accent accessoryButtons.forEach { $0.setTitleColor(primary, for: .normal) } unreadDot.backgroundColor = UIColor(nativeListHex: "#E5484D", fallback: .systemRed) @@ -627,9 +620,6 @@ final class NativeListCell: NativeListRowHost { selected: selected ) pressedBackgroundColor = nativeListColor(theme, "rowPressedBackground", "#E8E8E8") - if item.type == "rail" { - pressedBackgroundColor = UIColor(nativeListHex: "#F0F0F0", fallback: .lightGray) - } updateBackgroundColor() if item.data.bool("backgroundFullWidth"), let background = (item.data.dictionary("style")?.dictionary("container")?["backgroundColor"] ?? item.data["backgroundColor"]) as? String { selectorFullWidthBackground.backgroundColor = UIColor(nativeListHex: background, fallback: .clear).cgColor @@ -637,16 +627,8 @@ final class NativeListCell: NativeListRowHost { clipsToBounds = false } if layout == "table" { - if item.type == "dataRow" { - rootLeadingConstraint.constant = 20 - rootTrailingConstraint.constant = -20 - rootTopConstraint.constant = 10 - rootBottomConstraint.constant = -10 - rootStack.spacing = 10 - } else { - rootLeadingConstraint.constant = 16 - rootTrailingConstraint.constant = -16 - } + rootLeadingConstraint.constant = 16 + rootTrailingConstraint.constant = -16 } applyGroupPosition(item.data.string("groupPosition")) isUserInteractionEnabled = !item.data.bool("disabled") @@ -661,7 +643,6 @@ final class NativeListCell: NativeListRowHost { case "walletGroup": bindWalletGroup(item, theme: theme, layout: layout, checkboxState) case "identity": bindIdentity(item, theme: theme, selected: selected, checkboxState) case "market": bindMarket(item, theme: theme) - case "metricCard": bindMetricCard(item, theme: theme) case "sectionHeader": bindSectionHeader(item, theme: theme, layout: layout, checkboxState) default: break } @@ -829,18 +810,8 @@ final class NativeListCell: NativeListRowHost { selected ? "rowSelectedBackground" : "rowBackground", selected ? "#F0F0F0" : "#FFFFFF" ) - if item.type == "metricCard", !selected { - color = nativeListColor(theme, "subduedBackground", "#F9F9F9") - - } else if layout == "sectioned", !item.data.bool("selected") { - // Checkbox-backed section lists in app-monorepo keep rows on $bg; - // selection is represented by the checkbox itself. + if layout == "sectioned", !item.data.bool("selected") { color = nativeListColor(theme, "rowBackground", "#FFFFFF") - } else if layout == "table", - item.type == "dataRow", - (item.data["index"] == nil ? itemIndex : item.data.int("index")).map({ $0 % 2 == 0 }) == true, - !selected { - color = nativeListColor(theme, "subduedBackground", "#F9F9F9") } // OneKey patch: portfolio group headers retain their source background. if let backgroundColor = item.data["backgroundColor"] as? String { @@ -1007,13 +978,6 @@ final class NativeListCell: NativeListRowHost { statusLabel.text = nil statusLabel.topInset = 0 statusLabel.bottomInset = 0 - metricSubtitleLabel.text = nil - metricCompositeStack.arrangedSubviews.forEach { - metricCompositeStack.removeArrangedSubview($0) - $0.removeFromSuperview() - } - mainStack.removeArrangedSubview(metricCompositeStack) - metricCompositeStack.removeFromSuperview() badgeLabel.text = nil badgeLabel.horizontalInset = 0 badgeLabel.topInset = 0 @@ -1047,7 +1011,7 @@ final class NativeListCell: NativeListRowHost { $0.imageEdgeInsets = .zero $0.titleEdgeInsets = .zero } - [titleLabel, subtitleLabel, tertiaryLabel, statusLabel, metricSubtitleLabel, badgeLabel] + [titleLabel, subtitleLabel, tertiaryLabel, statusLabel, badgeLabel] .forEach { $0.isHidden = true } separatorView.isHidden = true separatorLeadingConstraint.constant = 0 @@ -1390,7 +1354,7 @@ final class NativeListCell: NativeListRowHost { if isWalletSidebar { radius = 20 } else { - radius = currentItem?.type == "rail" ? 8 : 12 + radius = 12 } layer.maskedCorners = [ .layerMinXMinYCorner, @@ -1407,11 +1371,10 @@ final class NativeListCell: NativeListRowHost { } else { applyGroupPosition(currentItem?.data.string("groupPosition") ?? "") // OneKey patch: explicit account and network selectors preserve ListItem radius while idle. - // let restingRadius: CGFloat = currentItem?.type == "metricCard" ? 12 : 0 let isSelectorIdentity = currentItem?.type == "identity" && currentItem?.data["height"] != nil let isAccountSelector = isSelectorIdentity && ["accountSelector", "networkSelector"].contains(currentItem?.data.string("presentation") ?? "") let isWalletSidebar = isSelectorIdentity && currentItem?.data.string("presentation") == "walletSidebar" - let restingRadius: CGFloat = isWalletSidebar ? 20 : currentItem?.type == "metricCard" || isAccountSelector ? 12 : 0 + let restingRadius: CGFloat = isWalletSidebar ? 20 : isAccountSelector ? 12 : 0 contentView.layer.cornerRadius = restingRadius contentView.layer.cornerCurve = isWalletSidebar ? .continuous : .circular contentView.clipsToBounds = restingRadius > 0 @@ -1705,15 +1668,6 @@ final class NativeListCell: NativeListRowHost { case "identity": return ["title", "subtitle", "tertiary", "badge", "value", "valueSecondary"] .contains(field) ? field : nil - // The large number and the small label sit in swapped views. - case "metricCard": - switch field { - case "value": return "title" - case "title": return "subtitle" - case "subtitle": return "metricSubtitle" - case "trend": return "status" - default: return nil - } case "sectionHeader": return ["title", "subtitle", "value"].contains(field) ? field : nil default: @@ -1763,13 +1717,10 @@ final class NativeListCell: NativeListRowHost { rootTopConstraint.constant = inset rootBottomConstraint.constant = -inset } - if style["lineGap"] != nil, item.type != "walletGroup", item.type != "dataRow" { + if style["lineGap"] != nil, item.type != "walletGroup" { let gap = CGFloat(style.double("lineGap")) styleGap(mainStack, gap) - if item.type == "metricCard", ["activity", "performance"].contains(item.data.string("variant")) { - styleGap(metricCompositeStack, gap) - if item.data.string("variant") == "performance", let summary = metricCompositeStack.arrangedSubviews.first as? UIStackView { styleGap(summary, gap) } - } + } let leadingSlot = leadingContainer if style["leadingGap"] != nil, let stack = leadingSlot.superview as? UIStackView { @@ -1820,13 +1771,6 @@ final class NativeListCell: NativeListRowHost { let variant = item.data.string("variant") - if item.type == "metricCard" && ["activity", "performance"].contains(variant) { - // Composite cards render the heading, not the standard card's value slot. - if let titleStyle = style.dictionary("title") { - applyStyledText(titleLabel, titleStyle) - } - return - } for (field, value) in style { guard let slotStyle = value as? [String: Any], let slot = styleSlot(type: item.type, variant: variant, field: field) @@ -1836,7 +1780,6 @@ final class NativeListCell: NativeListRowHost { case "subtitle": (semanticSubtitleLabels.isEmpty ? [subtitleLabel] : semanticSubtitleLabels).forEach { applyStyledText($0, slotStyle) } case "tertiary": applyStyledText(tertiaryLabel, slotStyle) case "status": applyStyledText(statusLabel, slotStyle) - case "metricSubtitle": applyStyledText(metricSubtitleLabel, slotStyle) case "badge": (semanticBadgeLabels.isEmpty ? [badgeLabel] : semanticBadgeLabels).forEach { applyStyledText($0, slotStyle) } case "value", "valueSecondary": let index = slot == "value" ? 0 : 1 @@ -2406,261 +2349,6 @@ final class NativeListCell: NativeListRowHost { accessibilityLabel = item.data.string("accessibilityLabel", default: [item.data.string("title"), item.data.string("subtitle"), item.data.string("price"), changeData.string("text")].filter { !$0.isEmpty }.joined(separator: ", ")) } - private func bindMetricCard(_ item: NativeListItem, theme: [String: Any]?) { - rootStack.axis = .vertical - rootStack.alignment = .leading - rootStack.spacing = 4 - rootLeadingConstraint.constant = 14 - rootTrailingConstraint.constant = -14 - rootTopConstraint.constant = 14 - rootBottomConstraint.constant = -14 - let variant = item.data.string("variant", default: "standard") - if variant == "activity" || variant == "performance" { - bindCompositeMetricCard(item, theme: theme, variant: variant) - return - } - if let visual = item.data.dictionary("visual") { - leadingWidth.constant = 32 - leadingHeight.constant = 32 - addLeading(visual, key: item.key, to: rootStack) - leadingContainer.clipsToBounds = true - } - show(subtitleLabel, item.data.string("title"), lines: 1) - subtitleLabel.font = nativeListFont(ofSize: 11) - subtitleLabel.textColor = nativeListColor(theme, "disabledText", "#8D8D8D") - show(titleLabel, item.data.string("value"), lines: 1) - titleLabel.font = nativeListFont( - ofSize: item.data.string("size") == "large" ? 24 : 18, - weight: .semibold - ) - show(statusLabel, item.data.string("trend"), lines: 1) - let tone = item.data.string("trendTone", default: "neutral") - if tone == "positive" { - statusLabel.textColor = nativeListColor(theme, "positive", "#218358") - } else if tone == "negative" { - statusLabel.textColor = nativeListColor(theme, "negative", "#CE2C31") - } - show(metricSubtitleLabel, item.data.string("subtitle"), lines: 1) - if let badge = item.data.dictionary("badge") { - show(badgeLabel, badge.string("text"), lines: 1) - } - rootStack.addArrangedSubview(mainStack) - contentView.layer.cornerRadius = 12 - contentView.clipsToBounds = true - } - - private func bindCompositeMetricCard( - _ item: NativeListItem, - theme: [String: Any]?, - variant: String - ) { - rootStack.alignment = .fill - show(titleLabel, item.data.string("title"), lines: 1) - titleLabel.font = nativeListFont(ofSize: 11) - titleLabel.textColor = nativeListColor(theme, "disabledText", "#8D8D8D") - setLineHeight( - titleLabel, - text: item.data.string("title").uppercased(), - lineHeight: 14, - letterSpacing: 1.2 - ) - mainStack.spacing = 14 - metricCompositeStack.spacing = 14 - mainStack.addArrangedSubview(metricCompositeStack) - rootStack.addArrangedSubview(mainStack) - - let metrics = item.data.dictionaries("metrics") - let topCount = min(2, metrics.count) - if variant == "activity" { - metricCompositeStack.addArrangedSubview( - makeMetricRow( - Array(metrics.prefix(topCount)), - theme: theme, - style: "activityHero" - ) - ) - let divider = UIView() - divider.backgroundColor = nativeListColor(theme, "separator", "#E0E0E0") - divider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true - metricCompositeStack.addArrangedSubview(divider) - metricCompositeStack.addArrangedSubview( - makeMetricRow( - Array(metrics.dropFirst(topCount)), - theme: theme, - style: "compact" - ) - ) - } else { - let performanceSummary = UIStackView() - performanceSummary.axis = .vertical - performanceSummary.alignment = .fill - performanceSummary.spacing = 8 - performanceSummary.addArrangedSubview( - makeMetricRow( - Array(metrics.prefix(topCount)), - theme: theme, - style: "performanceHero" - ) - ) - let progress = min(1, max(0, item.data.double("progress"))) - let progressRow = UIStackView() - progressRow.axis = .horizontal - progressRow.spacing = 0 - progressRow.layer.cornerRadius = 2 - progressRow.clipsToBounds = true - let wins = UIView() - wins.backgroundColor = nativeListColor(theme, "positive", "#218358") - let losses = UIView() - losses.backgroundColor = nativeListColor(theme, "negative", "#CE2C31") - progressRow.addArrangedSubview(wins) - progressRow.addArrangedSubview(losses) - progressRow.heightAnchor.constraint(equalToConstant: 4).isActive = true - if progress <= 0 { - wins.isHidden = true - } else if progress >= 1 { - losses.isHidden = true - } else { - wins.widthAnchor.constraint( - equalTo: progressRow.widthAnchor, - multiplier: CGFloat(progress) - ).isActive = true - } - performanceSummary.addArrangedSubview(progressRow) - metricCompositeStack.addArrangedSubview(performanceSummary) - metricCompositeStack.addArrangedSubview( - makeMetricRow( - Array(metrics.dropFirst(topCount)), - theme: theme, - style: "compactShaded" - ) - ) - } - contentView.layer.cornerRadius = 12 - contentView.clipsToBounds = true - } - - private func makeMetricRow( - _ metrics: [[String: Any]], - theme: [String: Any]?, - style: String - ) -> UIStackView { - let shaded = style == "compactShaded" - let row = UIStackView() - row.axis = .horizontal - row.alignment = style.hasSuffix("Hero") ? .bottom : .fill - row.distribution = .fillEqually - row.spacing = shaded ? 8 : 12 - metrics.enumerated().forEach { index, metric in - let column = UIStackView() - column.axis = .vertical - column.alignment = shaded - ? .leading - : index == 0 ? .leading : index == metrics.count - 1 ? .trailing : .center - column.spacing = 2 - if shaded { - column.isLayoutMarginsRelativeArrangement = true - column.layoutMargins = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) - column.backgroundColor = UIColor(nativeListHex: "#0000000F", fallback: .lightGray) - column.layer.cornerRadius = 8 - } - let label = UILabel() - label.font = nativeListFont(ofSize: 11) - label.textColor = nativeListColor(theme, "disabledText", "#8D8D8D") - setLineHeight(label, text: metric.string("label"), lineHeight: 14) - let value = UILabel() - let valueSize: CGFloat - let valueLineHeight: CGFloat - let valueWeight: NativeListFontWeight - switch style { - case "activityHero" where index == 0: - valueSize = 16 - valueLineHeight = 24 - valueWeight = .semibold - case "performanceHero" where index == 0: - valueSize = 18 - valueLineHeight = 24 - valueWeight = .semibold - case "activityHero", "performanceHero": - valueSize = 14 - valueLineHeight = 20 - valueWeight = .semibold - default: - valueSize = 14 - valueLineHeight = 20 - valueWeight = .medium - } - value.font = nativeListTabularFont(ofSize: valueSize, weight: valueWeight) - value.textColor = dataTextColor(metric.string("tone"), theme: theme) - setLineHeight(value, text: metric.string("value"), lineHeight: valueLineHeight) - column.addArrangedSubview(label) - if let visual = metric.dictionary("visual") { - let valueRow = UIStackView() - valueRow.axis = .horizontal - valueRow.alignment = .center - valueRow.spacing = 6 - valueRow.addArrangedSubview( - makeMetricVisual(visual, key: metric.string("key"), slot: index) - ) - valueRow.addArrangedSubview(value) - column.addArrangedSubview(valueRow) - } else { - column.addArrangedSubview(value) - } - row.addArrangedSubview(column) - } - return row - } - - private func makeMetricVisual( - _ visual: [String: Any], - key: String, - slot: Int - ) -> UIView { - let container = UIView() - container.translatesAutoresizingMaskIntoConstraints = false - container.backgroundColor = UIColor( - nativeListHex: visual.string("backgroundColor", default: "#0000000F"), - fallback: .lightGray - ) - container.layer.cornerRadius = visual.string("shape") == "square" ? 0 : 8 - container.clipsToBounds = true - NSLayoutConstraint.activate([ - container.widthAnchor.constraint(equalToConstant: 16), - container.heightAnchor.constraint(equalToConstant: 16), - ]) - - if visual.string("kind") == "icon" { - let imageView = UIImageView(image: nativeListIcon(named: visual.string("name"))) - imageView.translatesAutoresizingMaskIntoConstraints = false - imageView.tintColor = UIColor( - nativeListHex: visual.string("tintColor", default: "#00000072"), - fallback: .darkGray - ) - imageView.contentMode = .scaleAspectFit - container.addSubview(imageView) - NSLayoutConstraint.activate([ - imageView.leadingAnchor.constraint(equalTo: container.leadingAnchor), - imageView.trailingAnchor.constraint(equalTo: container.trailingAnchor), - imageView.topAnchor.constraint(equalTo: container.topAnchor), - imageView.bottomAnchor.constraint(equalTo: container.bottomAnchor), - ]) - } else if let source = visualSources(visual).first { - let imageView = OneKeyImageReusableView(frame: .zero) - imageView.translatesAutoresizingMaskIntoConstraints = false - imageView.clipsToBounds = true - imageView.layer.cornerRadius = container.layer.cornerRadius - container.addSubview(imageView) - NSLayoutConstraint.activate([ - imageView.leadingAnchor.constraint(equalTo: container.leadingAnchor), - imageView.trailingAnchor.constraint(equalTo: container.trailingAnchor), - imageView.topAnchor.constraint(equalTo: container.topAnchor), - imageView.bottomAnchor.constraint(equalTo: container.bottomAnchor), - ]) - bindImage(source.data, into: imageView, token: key, slot: slot, variant: source.variant) - } - return container - } - private func bindSectionHeader( _ item: NativeListItem, theme: [String: Any]?, diff --git a/native-views/react-native-native-list/ios/NativeListMetricCardRenderer.swift b/native-views/react-native-native-list/ios/NativeListMetricCardRenderer.swift new file mode 100644 index 000000000..abb9c4eae --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListMetricCardRenderer.swift @@ -0,0 +1,400 @@ +import UIKit + +final class NativeListMetricCardCell: NativeListRendererCell { + private let visual = NativeListLeadingVisual() + private let mainStack = UIStackView() + private let titleLine = UIStackView() + private let titleLabel = NativeListTextLabel() + private let subtitleLabel = NativeListTextLabel() + private let statusLabel = NativeListTextLabel() + private let detailLabel = NativeListTextLabel() + private let badgeLabel = NativeListTextLabel() + private let metricCompositeStack = UIStackView() + private var dimensions: [NSLayoutConstraint] = [] + private var metricVisuals: [String: NativeListMetricVisual] = [:] + private var usedVisuals = Set() + private var rowKey = "" + override var assetFields: [String] { ["visual", "metrics"] } + override var defaultCornerRadius: CGFloat { 12 } + override func unselectedBackground( + _ item: NativeListItem, theme: [String: Any]?, layout: String, itemIndex: Int? + ) -> UIColor { nativeListColor(theme, "subduedBackground", "#F9F9F9") } + override init(frame: CGRect) { + super.init(frame: frame) + mainStack.axis = .vertical + mainStack.alignment = .fill + titleLine.axis = .horizontal + titleLine.alignment = .center + titleLine.spacing = 8 + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + badgeLabel.setContentHuggingPriority(.required, for: .horizontal) + titleLine.addArrangedSubview(titleLabel) + titleLine.addArrangedSubview(badgeLabel) + [titleLine, subtitleLabel, statusLabel, detailLabel].forEach(mainStack.addArrangedSubview) + metricCompositeStack.axis = .vertical + metricCompositeStack.alignment = .fill + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func bindContent( + _ item: NativeListItem, theme: [String: Any]?, layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + rowKey = item.key + usedVisuals = [] + contentView.clipsToBounds = true + root.arrangedSubviews.forEach { + root.removeArrangedSubview($0) + $0.removeFromSuperview() + } + mainStack.removeArrangedSubview(metricCompositeStack) + metricCompositeStack.removeFromSuperview() + metricCompositeStack.arrangedSubviews.forEach { + metricCompositeStack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + let data = item.data + let style = data.dictionary("style") ?? [:] + let image = style.dictionary("image") ?? [:] + root.axis = .vertical + root.alignment = .leading + root.spacing = CGFloat(style.double("lineGap", default: 4)) + mainStack.spacing = CGFloat(style.double("lineGap", default: 2)) + let hp = CGFloat(style.double("horizontalPadding", default: 14)) + let vp = CGFloat(style.double("verticalPadding", default: 14)) + contentInsets = UIEdgeInsets(top: vp, left: hp, bottom: vp, right: hp) + for label in [titleLabel, subtitleLabel, statusLabel, detailLabel, badgeLabel] { + label.attributedText = nil + label.text = nil + label.isHidden = true + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + label.textAlignment = .natural + label.rowVerticalAlignment = nil + label.rowOffsetY = 0 + label.transform = .identity + } + titleLabel.font = nativeListFont( + ofSize: data.string("size") == "large" ? 24 : 18, weight: .semibold) + subtitleLabel.font = nativeListFont(ofSize: 11) + statusLabel.font = nativeListFont(ofSize: 12) + detailLabel.font = nativeListFont(ofSize: 12) + badgeLabel.font = nativeListFont(ofSize: 12, weight: .medium) + titleLabel.textColor = nativeListColor(theme, "primaryText", "#202020") + subtitleLabel.textColor = nativeListColor(theme, "disabledText", "#8D8D8D") + [statusLabel, detailLabel].forEach { + $0.textColor = nativeListColor(theme, "secondaryText", "#646464") + } + badgeLabel.textColor = nativeListColor(theme, "accent", "#108303") + let variant = data.string("variant", default: "standard") + NSLayoutConstraint.deactivate(dimensions) + dimensions = [] + if variant == "activity" || variant == "performance" { + visual.recycle() + bindCompositeMetricCard(item, theme: theme, variant: variant) + if style["lineGap"] != nil { + mainStack.spacing = CGFloat(style.double("lineGap")) + metricCompositeStack.spacing = CGFloat(style.double("lineGap")) + if variant == "performance", + let summary = metricCompositeStack.arrangedSubviews.first as? UIStackView + { + summary.spacing = CGFloat(style.double("lineGap")) + } + } + if let text = style.dictionary("title") { + NativeListTextStyles.applyStyledText(titleLabel, text) + } + } else { + if let descriptor = data.dictionary("visual") { + visual.bind(descriptor, style: image, key: item.key, theme: theme, isUnread: false) + root.addArrangedSubview(visual) + dimensions = [ + visual.widthAnchor.constraint( + equalToConstant: CGFloat(image.double("width", default: 32))), + visual.heightAnchor.constraint( + equalToConstant: CGFloat(image.double("height", default: 32))), + ] + NSLayoutConstraint.activate(dimensions) + root.setCustomSpacing( + CGFloat(style.double("leadingGap", default: Double(root.spacing))), after: visual) + } else { + visual.recycle() + } + show(titleLabel, data.string("value"), lines: 1) + show(subtitleLabel, data.string("title"), lines: 1) + show(statusLabel, data.string("trend"), lines: 1) + show(detailLabel, data.string("subtitle"), lines: 1) + show(badgeLabel, data.dictionary("badge")?.string("text") ?? "", lines: 1) + let tone = data.string("trendTone") + if tone == "positive" || tone == "negative" { + statusLabel.textColor = nativeListColor( + theme, tone, tone == "positive" ? "#218358" : "#CE2C31") + } + for (slot, label) in [ + ("title", subtitleLabel), ("value", titleLabel), ("subtitle", detailLabel), + ("trend", statusLabel), + ] { + if let text = style.dictionary(slot) { NativeListTextStyles.applyStyledText(label, text) } + } + root.addArrangedSubview(mainStack) + } + for key in Array(metricVisuals.keys) where !usedVisuals.contains(key) { + metricVisuals.removeValue(forKey: key)?.recycle() + } + } + override func recycleContent() { + visual.recycle() + metricVisuals.values.forEach { $0.recycle() } + metricVisuals = [:] + usedVisuals = [] + } + private func show(_ view: UILabel, _ text: String, lines: Int) { + view.text = text + view.numberOfLines = lines + view.isHidden = text.isEmpty + } + private func setLineHeight( + _ view: UILabel, text: String, lineHeight: CGFloat, letterSpacing: CGFloat? = nil + ) { + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = lineHeight + paragraph.maximumLineHeight = lineHeight + paragraph.alignment = view.textAlignment + var attrs: [NSAttributedString.Key: Any] = [ + .font: view.font as Any, .foregroundColor: view.textColor as Any, .paragraphStyle: paragraph, + ] + if let letterSpacing { attrs[.kern] = letterSpacing } + view.attributedText = NSAttributedString(string: text, attributes: attrs) + } + private func makeMetricVisual(_ visual: [String: Any], key: String, slot: Int, role: String) + -> UIView + { + let identity = "\(rowKey):\(role):\(slot):\(key)" + usedVisuals.insert(identity) + let view = metricVisuals[identity] ?? NativeListMetricVisual() + metricVisuals[identity] = view + view.bind(visual, key: identity) + return view + } + private func bindCompositeMetricCard( + _ item: NativeListItem, + theme: [String: Any]?, + variant: String + ) { + root.alignment = .fill + show(titleLabel, item.data.string("title"), lines: 1) + titleLabel.font = nativeListFont(ofSize: 11) + titleLabel.textColor = nativeListColor(theme, "disabledText", "#8D8D8D") + setLineHeight( + titleLabel, + text: item.data.string("title").uppercased(), + lineHeight: 14, + letterSpacing: 1.2 + ) + mainStack.spacing = 14 + metricCompositeStack.spacing = 14 + mainStack.addArrangedSubview(metricCompositeStack) + root.addArrangedSubview(mainStack) + + let metrics = item.data.dictionaries("metrics") + let topCount = min(2, metrics.count) + if variant == "activity" { + metricCompositeStack.addArrangedSubview( + makeMetricRow( + Array(metrics.prefix(topCount)), + theme: theme, + style: "activityHero" + ) + ) + let divider = UIView() + divider.backgroundColor = nativeListColor(theme, "separator", "#E0E0E0") + divider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true + metricCompositeStack.addArrangedSubview(divider) + metricCompositeStack.addArrangedSubview( + makeMetricRow( + Array(metrics.dropFirst(topCount)), + theme: theme, + style: "compact" + ) + ) + } else { + let performanceSummary = UIStackView() + performanceSummary.axis = .vertical + performanceSummary.alignment = .fill + performanceSummary.spacing = 8 + performanceSummary.addArrangedSubview( + makeMetricRow( + Array(metrics.prefix(topCount)), + theme: theme, + style: "performanceHero" + ) + ) + let progress = min(1, max(0, item.data.double("progress"))) + let progressRow = UIStackView() + progressRow.axis = .horizontal + progressRow.spacing = 0 + progressRow.layer.cornerRadius = 2 + progressRow.clipsToBounds = true + let wins = UIView() + wins.backgroundColor = nativeListColor(theme, "positive", "#218358") + let losses = UIView() + losses.backgroundColor = nativeListColor(theme, "negative", "#CE2C31") + progressRow.addArrangedSubview(wins) + progressRow.addArrangedSubview(losses) + progressRow.heightAnchor.constraint(equalToConstant: 4).isActive = true + if progress <= 0 { + wins.isHidden = true + } else if progress >= 1 { + losses.isHidden = true + } else { + wins.widthAnchor.constraint( + equalTo: progressRow.widthAnchor, + multiplier: CGFloat(progress) + ).isActive = true + } + performanceSummary.addArrangedSubview(progressRow) + metricCompositeStack.addArrangedSubview(performanceSummary) + metricCompositeStack.addArrangedSubview( + makeMetricRow( + Array(metrics.dropFirst(topCount)), + theme: theme, + style: "compactShaded" + ) + ) + } + contentView.layer.cornerRadius = 12 + contentView.clipsToBounds = true + } + private func makeMetricRow( + _ metrics: [[String: Any]], + theme: [String: Any]?, + style: String + ) -> UIStackView { + let shaded = style == "compactShaded" + let row = UIStackView() + row.axis = .horizontal + row.alignment = style.hasSuffix("Hero") ? .bottom : .fill + row.distribution = .fillEqually + row.spacing = shaded ? 8 : 12 + metrics.enumerated().forEach { index, metric in + let column = UIStackView() + column.axis = .vertical + column.alignment = + shaded + ? .leading + : index == 0 ? .leading : index == metrics.count - 1 ? .trailing : .center + column.spacing = 2 + if shaded { + column.isLayoutMarginsRelativeArrangement = true + column.layoutMargins = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) + column.backgroundColor = UIColor(nativeListHex: "#0000000F", fallback: .lightGray) + column.layer.cornerRadius = 8 + } + let label = UILabel() + label.font = nativeListFont(ofSize: 11) + label.textColor = nativeListColor(theme, "disabledText", "#8D8D8D") + setLineHeight(label, text: metric.string("label"), lineHeight: 14) + let value = UILabel() + let valueSize: CGFloat + let valueLineHeight: CGFloat + let valueWeight: NativeListFontWeight + switch style { + case "activityHero" where index == 0: + valueSize = 16 + valueLineHeight = 24 + valueWeight = .semibold + case "performanceHero" where index == 0: + valueSize = 18 + valueLineHeight = 24 + valueWeight = .semibold + case "activityHero", "performanceHero": + valueSize = 14 + valueLineHeight = 20 + valueWeight = .semibold + default: + valueSize = 14 + valueLineHeight = 20 + valueWeight = .medium + } + value.font = nativeListTabularFont(ofSize: valueSize, weight: valueWeight) + value.textColor = dataTextColor(metric.string("tone"), theme: theme) + setLineHeight(value, text: metric.string("value"), lineHeight: valueLineHeight) + column.addArrangedSubview(label) + if let visual = metric.dictionary("visual") { + let valueRow = UIStackView() + valueRow.axis = .horizontal + valueRow.alignment = .center + valueRow.spacing = 6 + valueRow.addArrangedSubview( + makeMetricVisual(visual, key: metric.string("key"), slot: index, role: style) + ) + valueRow.addArrangedSubview(value) + column.addArrangedSubview(valueRow) + } else { + column.addArrangedSubview(value) + } + row.addArrangedSubview(column) + } + return row + } + private func dataTextColor(_ tone: String, theme: [String: Any]?) -> UIColor { + switch tone.isEmpty ? "primary" : tone { + // OneKey patch: account warnings and hidden balances use existing theme tokens. + case "disabled": return nativeListColor(theme, "disabledText", "#8D8D8D") + case "caution": return nativeListColor(theme, "caution", "#AB6400") + case "secondary": return nativeListColor(theme, "secondaryText", "#646464") + case "positive": return nativeListColor(theme, "positive", "#218358") + case "negative": return nativeListColor(theme, "negative", "#CE2C31") + default: return nativeListColor(theme, "primaryText", "#202020") + } + } +} + +private final class NativeListMetricVisual: UIView { + private let icon = UIImageView() + private let image = NativeListImageSlot() + init() { + super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = false + icon.contentMode = .scaleAspectFit + addSubview(icon) + addSubview(image.view) + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalToConstant: 16), heightAnchor.constraint(equalToConstant: 16), + ]) + clipsToBounds = true + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + func bind(_ visual: [String: Any], key: String) { + backgroundColor = UIColor( + nativeListHex: visual.string("backgroundColor", default: "#0000000F"), fallback: .lightGray) + layer.cornerRadius = visual.string("shape") == "square" ? 0 : 8 + let kind = visual.string("kind") + icon.isHidden = kind != "icon" + image.view.isHidden = true + if kind == "icon" { + image.recycle() + icon.image = nativeListIcon(named: visual.string("name")) + icon.tintColor = UIColor( + nativeListHex: visual.string("tintColor", default: "#00000072"), fallback: .darkGray) + } else if let source = kind == "stackedImages" + ? visual.dictionaries("images").first : visual.dictionary("image") + { + image.view.isHidden = false + image.bind( + source, key: key, + variant: kind == "token" + ? "token" + : kind == "network" + ? "network" : ["wallet", "account"].contains(kind) ? "avatar" : "generic", fit: nil, + placeholder: "#00000000") + } else { + image.recycle() + } + } + override func layoutSubviews() { + super.layoutSubviews() + icon.frame = bounds + image.view.frame = bounds + } + func recycle() { image.recycle() } +} diff --git a/native-views/react-native-native-list/ios/NativeListModels.swift b/native-views/react-native-native-list/ios/NativeListModels.swift index a2d237690..c91c2f102 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -4,7 +4,7 @@ import UIKit // Message is the first migrated renderer. Other templates still use the // compatible legacy tree; style, content and row keys do not affect reuse. enum NativeListRendererKey: String, CaseIterable { - case legacy, message, rail, mediaTile, action, system, activity, dataRow + case legacy, message, rail, mediaTile, action, system, activity, dataRow, metricCard } struct NativeListItem { diff --git a/native-views/react-native-native-list/ios/NativeListRendererCell.swift b/native-views/react-native-native-list/ios/NativeListRendererCell.swift index 1ec3503f4..ada33c240 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererCell.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererCell.swift @@ -25,8 +25,8 @@ class NativeListRendererCell: NativeListRowHost { guard let old, old.key == new.key, old.rendererKey == new.rendererKey else { return .replace } if old.content == new.content { return .unchanged } for field in assetFields { - if NativeListImageSlot.signature(old.data.dictionary(field) ?? [:]) - != NativeListImageSlot.signature(new.data.dictionary(field) ?? [:]) + if NativeListImageSlot.signature([field: old.data[field] ?? NSNull()]) + != NativeListImageSlot.signature([field: new.data[field] ?? NSNull()]) { return .assets } diff --git a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift index ac47993b0..c2621d96f 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift @@ -9,6 +9,7 @@ enum NativeListRendererRegistry { .message: (NativeListMessageCell.self, { NativeListMessageCell(frame: .zero) }), .rail: (NativeListRailCell.self, { NativeListRailCell(frame: .zero) }), .mediaTile: (NativeListMediaTileCell.self, { NativeListMediaTileCell(frame: .zero) }), + .metricCard: (NativeListMetricCardCell.self, { NativeListMetricCardCell(frame: .zero) }), .dataRow: (NativeListDataRowCell.self, { NativeListDataRowCell(frame: .zero) }), .activity: (NativeListActivityCell.self, { NativeListActivityCell(frame: .zero) }), .system: (NativeListSystemCell.self, { NativeListSystemCell(frame: .zero) }), @@ -32,6 +33,9 @@ enum NativeListRendererRegistry { static func measure(_ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String) -> CGFloat? { + if item.rendererKey == .metricCard { + return item.data.string("variant") == "activity" ? 160 + 1 / UIScreen.main.scale : item.data.string("variant") == "performance" ? 178 : 132 + } if item.rendererKey == .dataRow { let secondary = item.data.dictionaries("columns").contains { !$0.string("secondaryText").isEmpty } return secondary ? 60 : layout == "table" ? 48 : 56 diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index de9ef733a..fda34b262 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -1731,10 +1731,6 @@ final class NativeListView: UIView { } let base: CGFloat switch item.type { - case "metricCard": - base = item.data.string("variant") == "activity" - ? 160 + 1 / UIScreen.main.scale - : item.data.string("variant") == "performance" ? 178 : 132 case "sectionHeader": let variant = item.data.string("variant") let isNetworkSelector = item.data.string("presentation") == "networkSelector" diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index 436faa1f1..ba144997d 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -2267,7 +2267,12 @@ describe('DataRow renderer lifecycle', () => { const dataSnapshot = ( layout: NativeListSnapshot['layout'], items: readonly RowModel[] - ): NativeListSnapshot => ({ schemaVersion: 1, generation: 1, layout, rows: items }); + ): NativeListSnapshot => ({ + schemaVersion: 1, + generation: 1, + layout, + rows: items, + }); const { JSDOM } = require('jsdom') as { JSDOM: new (html: string) => { window: { document: Document } }; }; @@ -2344,3 +2349,125 @@ describe('DataRow renderer lifecycle', () => { } }); }); + +describe('MetricCard renderer lifecycle', () => { + const { JSDOM } = require('jsdom') as { + JSDOM: new (html: string) => { window: { document: Document } }; + }; + it('restores standard and composite defaults without restarting unchanged images', () => { + const { document } = new JSDOM('').window; + const host = document.createElement('div'); + document.body.appendChild(host); + const row: Extract = { + type: 'metricCard', + key: 'metric', + title: 'Portfolio', + value: '$42,000', + subtitle: 'Total balance', + trend: '+2.4%', + visual: { kind: 'image', image }, + metrics: [ + { + key: 'volume', + label: 'Volume', + value: '$42M', + visual: { kind: 'image', image }, + }, + { key: 'trades', label: 'Trades', value: '123' }, + { key: 'change', label: 'Change', value: '+2.4%' }, + ], + progress: 0.5, + }; + const metricSnapshot = (item: typeof row): NativeListSnapshot => ({ + schemaVersion: 1, + generation: 1, + layout: { kind: 'linear' }, + rows: [item], + }); + const engine = new NativeListWebEngine( + host, + metricSnapshot(row), + {}, + false + ); + try { + const body = host.querySelector( + '[data-nl-renderer="metricCard"]' + )!; + const leading = body.querySelector('img'); + const styled: typeof row = { + ...row, + style: { + container: { height: 240 }, + image: { width: 28, height: 30 }, + value: { fontSize: 22 }, + lineGap: 9, + }, + }; + engine.applySnapshot(metricSnapshot(styled)); + expect(body.querySelector('img')).toBe(leading); + expect( + body.querySelector('[data-nl-slot="value"]')?.style + .fontSize + ).toBe('22px'); + // A content update during styling must not promote styles into defaults. + engine.applySnapshot(metricSnapshot({ ...styled, value: '$43,000' })); + engine.applySnapshot(metricSnapshot({ ...row, value: '$43,000' })); + expect(body.querySelector('img')).toBe(leading); + expect( + body.querySelector('[data-nl-slot="value"]')?.style + .fontSize + ).toBe(''); + expect( + body.querySelector('.ok-native-list-visual')?.style.width + ).toBe('40px'); + for (const variant of ['activity', 'performance'] as const) { + const composite = { ...row, variant }; + engine.applySnapshot(metricSnapshot(composite)); + expect(host.querySelector('[data-nl-renderer="metricCard"]')).toBe( + body + ); + const icon = body.querySelector('img'); + engine.applySnapshot( + metricSnapshot({ + ...composite, + style: { + title: { fontSize: 20 }, + value: { fontSize: 30 }, + image: { width: 80, height: 80 }, + }, + }) + ); + expect(body.querySelector('img')).toBe(icon); + expect( + body.querySelector('[data-nl-slot="title"]')?.style + .fontSize + ).toBe('20px'); + expect( + body.querySelector('.ok-native-list-composite-value') + ?.style.fontSize + ).toBe(''); + expect( + body.querySelector('.ok-native-list-visual')?.style.width + ).toBe('16px'); + engine.applySnapshot(metricSnapshot(composite)); + expect( + body.querySelector('[data-nl-slot="title"]')?.style + .fontSize + ).toBe(''); + expect(body.querySelectorAll('.ok-native-list-progress')).toHaveLength( + variant === 'performance' ? 1 : 0 + ); + } + engine.applySnapshot(metricSnapshot(row)); + expect( + body.querySelectorAll('.ok-native-list-composite-cell') + ).toHaveLength(0); + expect( + body.querySelector('.ok-native-list-visual')?.style.width + ).toBe('40px'); + } finally { + engine.destroy(); + } + }); +}); diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index 6765860d1..dd903f206 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -514,14 +514,6 @@ export function estimateWebRowHeight( ); let base: number; switch (row.type) { - case 'metricCard': - base = - row.variant === 'activity' - ? 161 - : row.variant === 'performance' - ? 178 - : 132; - break; case 'sectionHeader': { const isHistory = row.variant === 'history' || @@ -2337,186 +2329,6 @@ function createSectionHeader( return body; } -function createMetricCell( - context: RenderContext, - metric: NonNullable< - Extract['metrics'] - >[number], - shaded: boolean -): HTMLElement { - const cell = createElement( - context.document, - 'div', - 'ok-native-list-composite-cell' - ); - setData(cell, 'shaded', shaded); - cell.appendChild( - createElement( - context.document, - 'div', - 'ok-native-list-secondary', - metric.label - ) - ); - const valueLine = createElement(context.document, 'div'); - valueLine.style.display = 'flex'; - valueLine.style.alignItems = 'center'; - valueLine.style.gap = '6px'; - if (metric.visual) { - const visual = createVisual(context, metric.visual); - if (visual) { - visual.style.width = '16px'; - visual.style.height = '16px'; - visual.style.flexBasis = '16px'; - const image = visual.querySelector('img'); - if (image) { - image.style.width = '16px'; - image.style.height = '16px'; - } - valueLine.appendChild(visual); - } - } - const value = createElement( - context.document, - 'span', - 'ok-native-list-composite-value', - metric.value - ); - value.style.color = toneColor(metric.tone, 'primary'); - valueLine.appendChild(value); - cell.appendChild(valueLine); - return cell; -} - -function createMetricRow( - context: RenderContext, - row: Extract -): HTMLElement { - if (row.variant === 'activity' || row.variant === 'performance') { - const body = createElement( - context.document, - 'div', - 'ok-native-list-row ok-native-list-composite' - ); - body.appendChild( - tagSlot( - createElement( - context.document, - 'div', - 'ok-native-list-composite-heading', - row.title - ), - 'title' - ) - ); - const metrics = row.metrics ?? []; - const firstLine = createElement( - context.document, - 'div', - 'ok-native-list-composite-row' - ); - metrics - .slice(0, 2) - .forEach((metric) => - firstLine.appendChild(createMetricCell(context, metric, false)) - ); - body.appendChild(firstLine); - if (row.variant === 'activity') { - body.appendChild( - createElement(context.document, 'div', 'ok-native-list-divider') - ); - } else { - const progress = createElement( - context.document, - 'div', - 'ok-native-list-progress' - ); - const fill = createElement(context.document, 'span'); - fill.style.width = String(Math.round((row.progress ?? 0) * 100)) + '%'; - progress.appendChild(fill); - body.appendChild(progress); - } - const secondLine = createElement( - context.document, - 'div', - 'ok-native-list-composite-row' - ); - metrics - .slice(2) - .forEach((metric) => - secondLine.appendChild( - createMetricCell(context, metric, row.variant === 'performance') - ) - ); - body.appendChild(secondLine); - return body; - } - - const body = createElement( - context.document, - 'div', - 'ok-native-list-row ok-native-list-metric' - ); - if (row.visual) { - const visual = createVisual(context, row.visual); - if (visual) body.appendChild(visual); - } - body.appendChild( - tagSlot( - createElement( - context.document, - 'div', - 'ok-native-list-secondary', - row.title - ), - 'title' - ) - ); - body.appendChild( - tagSlot( - createElement( - context.document, - 'div', - 'ok-native-list-metric-value', - row.value - ), - 'value' - ) - ); - if (row.trend) { - const trend = tagSlot( - createElement( - context.document, - 'div', - 'ok-native-list-secondary', - row.trend - ), - 'trend' - ); - trend.style.color = - row.trendTone === 'positive' - ? 'var(--nl-positive)' - : row.trendTone === 'negative' - ? 'var(--nl-negative)' - : 'var(--nl-secondary)'; - body.appendChild(trend); - } - if (row.subtitle) - body.appendChild( - tagSlot( - createElement( - context.document, - 'div', - 'ok-native-list-secondary', - row.subtitle - ), - 'subtitle' - ) - ); - if (row.badge) body.appendChild(createBadge(context, row.badge)); - return body; -} - function createIdentityRow( context: RenderContext, row: Extract< @@ -3334,69 +3146,45 @@ export function applyRowStyle(body: HTMLElement, row: RowModel): void { body.style.paddingInline = String(box.horizontalPadding) + 'px'; if (box.verticalPadding !== undefined) body.style.paddingBlock = String(box.verticalPadding) + 'px'; - const compositeMetric = - row.type === 'metricCard' && - (row.variant === 'activity' || row.variant === 'performance'); const vertical = - row.type === 'metricCard' || - (row.type === 'identity' && row.presentation === 'walletSidebar'); + row.type === 'identity' && row.presentation === 'walletSidebar'; const leading = body.querySelector( ':scope > .ok-native-list-visual, :scope > .ok-native-list-stacked' ); const defaultGap = body.style.gap || - (row.type === 'metricCard' - ? '5px' - : row.type === 'identity' && row.presentation === 'walletSidebar' + (row.type === 'identity' && row.presentation === 'walletSidebar' ? '4px' : row.type === 'identity' && row.presentation === 'accountSelector' ? '8px' : '12px'); if (box.lineGap !== undefined) { - const targets = - row.type === 'dataRow' - ? body.querySelectorAll('.ok-native-list-data-cell') - : row.type === 'metricCard' || - (row.type === 'system' && row.variant === 'warning') - ? [body] - : body.querySelectorAll(':scope > .ok-native-list-flex'); + const targets = body.querySelectorAll( + ':scope > .ok-native-list-flex' + ); targets.forEach((column) => { column.style.rowGap = String(box.lineGap) + 'px'; }); } if (leading && box.leadingGap !== undefined) { - const gap = - 'calc(' + - String(box.leadingGap) + - 'px - ' + - (row.type === 'metricCard' && box.lineGap !== undefined - ? String(box.lineGap) + 'px' - : defaultGap) + - ')'; + const gap = 'calc(' + String(box.leadingGap) + 'px - ' + defaultGap + ')'; if (vertical) leading.style.marginBottom = gap; else leading.style.marginInlineEnd = gap; } if (box.titleBadgeGap !== undefined) { const gap = String(box.titleBadgeGap) + 'px'; - if (row.type === 'dataRow') - body - .querySelectorAll('.ok-native-list-data-primary') - .forEach((line) => { - line.style.gap = gap; - }); - else - body - .querySelectorAll( - '.ok-native-list-badges, .ok-native-list-wallet-badges' - ) - .forEach((badges) => { - if (badges.classList.contains('ok-native-list-wallet-badges')) - badges.style.marginTop = - box.lineGap === undefined - ? gap - : 'calc(' + gap + ' - ' + String(box.lineGap) + 'px)'; - else badges.style.marginInlineStart = gap; - }); + body + .querySelectorAll( + '.ok-native-list-badges, .ok-native-list-wallet-badges' + ) + .forEach((badges) => { + if (badges.classList.contains('ok-native-list-wallet-badges')) + badges.style.marginTop = + box.lineGap === undefined + ? gap + : 'calc(' + gap + ' - ' + String(box.lineGap) + 'px)'; + else badges.style.marginInlineStart = gap; + }); } if (box.trailingGap !== undefined) { const gap = String(box.trailingGap) + 'px'; @@ -3418,7 +3206,7 @@ export function applyRowStyle(body: HTMLElement, row: RowModel): void { }); } } - if (leading && box.image && !compositeMetric) { + if (leading && box.image) { const image = box.image; if (image.width !== undefined) { leading.style.width = String(image.width) + 'px'; @@ -3464,15 +3252,7 @@ export function applyRowStyle(body: HTMLElement, row: RowModel): void { body .querySelectorAll('[data-nl-slot="' + key + '"]') .forEach((element) => { - applyTextStyleToSlot( - element, - slotStyle, - row.type === 'system' && row.variant === 'warning' ? 0 : undefined - ); - if (row.type === 'dataRow' && slotStyle.alignment) { - element.style.flex = '1'; - if (element.parentElement) element.parentElement.style.width = '100%'; - } + applyTextStyleToSlot(element, slotStyle); }); }); } @@ -3503,8 +3283,6 @@ export function createRowBody( return createWalletGroupRow(context, row); case 'sectionHeader': return createSectionHeader(context, row); - case 'metricCard': - return createMetricRow(context, row); case 'market': return createMarketRow(context, row); case 'identity': @@ -3546,6 +3324,7 @@ export class NativeListWebEngine { system: [], activity: [], dataRow: [], + metricCard: [], }; private readonly rendererKeys = new WeakMap(); private frameHandle: number | undefined; diff --git a/native-views/react-native-native-list/src/web/templates/MetricCardRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/MetricCardRowRenderer.ts new file mode 100644 index 000000000..e07933ab2 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/MetricCardRowRenderer.ts @@ -0,0 +1,328 @@ +import type { + MetricCardRow, + NativeListTextStyle, + TextTone, +} from '../../models'; +import { createElement, setData, tagSlot } from './RowElements'; +import { RowVisualStyle, type RowPrimitives } from './RowVisual'; +const toneColor = (tone: TextTone | undefined, fallback: string) => + tone === 'positive' + ? 'var(--nl-positive)' + : tone === 'negative' + ? 'var(--nl-negative)' + : tone === 'secondary' + ? 'var(--nl-secondary)' + : `var(--nl-${fallback})`; +function createBadge( + document: Document, + badge: { text: string; tone?: string } +) { + const label = tagSlot( + createElement(document, 'span', 'ok-native-list-badge', badge.text), + 'badge' + ); + label.dataset.tone = badge.tone ?? ''; + return label; +} +function createMetricCell( + document: Document, + primitives: RowPrimitives, + metric: NonNullable[number], + shaded: boolean +): HTMLElement { + const cell = createElement(document, 'div', 'ok-native-list-composite-cell'); + setData(cell, 'shaded', shaded); + cell.appendChild( + createElement(document, 'div', 'ok-native-list-secondary', metric.label) + ); + const valueLine = createElement(document, 'div'); + valueLine.style.display = 'flex'; + valueLine.style.alignItems = 'center'; + valueLine.style.gap = '6px'; + if (metric.visual) { + const visual = primitives.visual(metric.visual); + if (visual) { + visual.style.width = '16px'; + visual.style.height = '16px'; + visual.style.flexBasis = '16px'; + const image = visual.querySelector('img'); + if (image) { + image.style.width = '16px'; + image.style.height = '16px'; + } + valueLine.appendChild(visual); + } + } + const value = createElement( + document, + 'span', + 'ok-native-list-composite-value', + metric.value + ); + value.style.color = toneColor(metric.tone, 'primary'); + valueLine.appendChild(value); + cell.appendChild(valueLine); + return cell; +} + +function createMetricRow( + document: Document, + primitives: RowPrimitives, + row: MetricCardRow +): HTMLElement { + if (row.variant === 'activity' || row.variant === 'performance') { + const body = createElement( + document, + 'div', + 'ok-native-list-row ok-native-list-composite' + ); + body.appendChild( + tagSlot( + createElement( + document, + 'div', + 'ok-native-list-composite-heading', + row.title + ), + 'title' + ) + ); + const metrics = row.metrics ?? []; + const firstLine = createElement( + document, + 'div', + 'ok-native-list-composite-row' + ); + metrics + .slice(0, 2) + .forEach((metric) => + firstLine.appendChild( + createMetricCell(document, primitives, metric, false) + ) + ); + body.appendChild(firstLine); + if (row.variant === 'activity') { + body.appendChild( + createElement(document, 'div', 'ok-native-list-divider') + ); + } else { + const progress = createElement( + document, + 'div', + 'ok-native-list-progress' + ); + const fill = createElement(document, 'span'); + fill.style.width = String(Math.round((row.progress ?? 0) * 100)) + '%'; + progress.appendChild(fill); + body.appendChild(progress); + } + const secondLine = createElement( + document, + 'div', + 'ok-native-list-composite-row' + ); + metrics + .slice(2) + .forEach((metric) => + secondLine.appendChild( + createMetricCell( + document, + primitives, + metric, + row.variant === 'performance' + ) + ) + ); + body.appendChild(secondLine); + return body; + } + + const body = createElement( + document, + 'div', + 'ok-native-list-row ok-native-list-metric' + ); + if (row.visual) { + const visual = primitives.visual(row.visual); + if (visual) body.appendChild(visual); + } + body.appendChild( + tagSlot( + createElement(document, 'div', 'ok-native-list-secondary', row.title), + 'title' + ) + ); + body.appendChild( + tagSlot( + createElement(document, 'div', 'ok-native-list-metric-value', row.value), + 'value' + ) + ); + if (row.trend) { + const trend = tagSlot( + createElement(document, 'div', 'ok-native-list-secondary', row.trend), + 'trend' + ); + trend.style.color = + row.trendTone === 'positive' + ? 'var(--nl-positive)' + : row.trendTone === 'negative' + ? 'var(--nl-negative)' + : 'var(--nl-secondary)'; + body.appendChild(trend); + } + if (row.subtitle) + body.appendChild( + tagSlot( + createElement( + document, + 'div', + 'ok-native-list-secondary', + row.subtitle + ), + 'subtitle' + ) + ); + if (row.badge) body.appendChild(createBadge(document, row.badge)); + return body; +} + +type VisualSlot = { key: string; node: HTMLElement }; +type Views = { + contentKey: string; + visuals: VisualSlot[]; + defaults: Map; + primitives: RowPrimitives; + visualStyle?: RowVisualStyle; +}; +const views = new WeakMap(); +function bind( + body: HTMLElement, + row: MetricCardRow, + primitives: RowPrimitives +) { + const v = views.get(body)!; + v.primitives = primitives; + const contentKey = JSON.stringify({ ...row, style: undefined }); + if (contentKey !== v.contentKey) { + let index = 0; + v.defaults.forEach((css, node) => { + node.style.cssText = css; + }); + const next = createMetricRow( + body.ownerDocument, + { + ...primitives, + visual: (source) => { + const slot = index++; + const key = JSON.stringify([ + row.key, + row.variant ?? 'standard', + source, + ]); + const previous = v.visuals[slot]; + if (previous?.key === key) return previous.node; + if (previous) primitives.dispose(previous.node); + const node = primitives.visual(source); + if (node) v.visuals[slot] = { key, node }; + return node; + }, + }, + row + ); + v.visuals.slice(index).forEach((slot) => primitives.dispose(slot.node)); + v.visuals.length = index; + body.replaceChildren(...Array.from(next.childNodes)); + body.className = next.className; + body.style.cssText = next.style.cssText; + v.defaults = new Map( + [body, ...body.querySelectorAll('*')].map((node) => [ + node, + node.style.cssText, + ]) + ); + const leading = body.querySelector( + ':scope > .ok-native-list-visual, :scope > .ok-native-list-stacked' + ); + v.visualStyle = leading ? new RowVisualStyle(leading) : undefined; + v.contentKey = contentKey; + } + v.defaults.forEach((css, node) => { + node.style.cssText = css; + }); + body.removeAttribute('data-nl-container-background'); + body.removeAttribute('data-nl-container-border'); + const style = row.style; + const composite = row.variant === 'activity' || row.variant === 'performance'; + if (style?.horizontalPadding !== undefined) + body.style.paddingInline = `${style.horizontalPadding}px`; + if (style?.verticalPadding !== undefined) + body.style.paddingBlock = `${style.verticalPadding}px`; + if (style?.lineGap !== undefined) body.style.rowGap = `${style.lineGap}px`; + if (!composite && v.visualStyle) { + v.visualStyle.apply( + style?.image, + style?.image?.width ?? 40, + style?.image?.height ?? 40, + 0 + ); + const leading = body.querySelector( + ':scope > .ok-native-list-visual, :scope > .ok-native-list-stacked' + )!; + leading.style.marginBottom = + style?.leadingGap === undefined + ? '' + : `${style.leadingGap - (style.lineGap ?? 5)}px`; + } + const slots: ReadonlyArray<'title' | 'value' | 'trend' | 'subtitle'> = + composite ? ['title'] : ['title', 'value', 'trend', 'subtitle']; + slots.forEach((slot) => { + const text: NativeListTextStyle | undefined = style?.[slot]; + if (text) + body + .querySelectorAll(`[data-nl-slot="${slot}"]`) + .forEach((node) => primitives.textStyle(node, text)); + }); +} +function create( + document: Document, + row: MetricCardRow, + primitives: RowPrimitives +) { + const body = document.createElement('div'); + body.dataset.nlRenderer = 'metricCard'; + views.set(body, { + contentKey: '', + visuals: [], + defaults: new Map(), + primitives, + }); + bind(body, row, primitives); + return body; +} +function recycle(body: HTMLElement) { + const v = views.get(body); + if (!v) return; + v.visuals.forEach((slot) => v.primitives.dispose(slot.node)); + v.visuals = []; + v.defaults.clear(); + v.contentKey = ''; + v.visualStyle = undefined; + body.replaceChildren(); +} +export const metricCardRowRenderer = { + key: 'metricCard' as const, + create, + bind, + recycle, + measure: (row: MetricCardRow, _width: number) => + row.variant === 'activity' + ? 161 + : row.variant === 'performance' + ? 178 + : 132, + measureRendered: ( + _body: HTMLElement, + _row: MetricCardRow + ): number | undefined => undefined, +}; diff --git a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts index 41a708d39..22f5e71ea 100644 --- a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts +++ b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts @@ -1,3 +1,5 @@ +import type { MetricCardRow } from '../../models'; +import { metricCardRowRenderer } from './MetricCardRowRenderer'; import type { DataRow } from '../../models'; import { dataRowRenderer } from './DataRowRenderer'; import type { ActivityRow } from '../../models'; @@ -38,6 +40,8 @@ function registration(renderer: Renderer, row: R) { }; } const factories = { + metricCard: (row: RowModel) => + registration(metricCardRowRenderer, row as MetricCardRow), dataRow: (row: RowModel) => registration(dataRowRenderer, row as DataRow), activity: (row: RowModel) => registration(activityRowRenderer, row as ActivityRow), @@ -50,6 +54,7 @@ const factories = { registration(mediaTileRowRenderer, row as MediaTileRow), }; const renderers = { + metricCard: metricCardRowRenderer, dataRow: dataRowRenderer, activity: activityRowRenderer, system: systemRowRenderer, From 107efab8e4933e93ab6e642eb38fc09a07d53778 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 20:34:04 +0800 Subject: [PATCH 47/71] refactor(native-list): keep iOS measurement with renderer hosts --- .../react-native-native-list/docs/DESIGN.md | 7 +++++++ .../ios/NativeListActionRenderer.swift | 11 +++++++++- .../ios/NativeListActivityRenderer.swift | 6 ++++++ .../ios/NativeListDataRowRenderer.swift | 9 ++++++++ .../ios/NativeListMediaTileRenderer.swift | 6 ++++++ .../ios/NativeListMessageRenderer.swift | 7 +++++++ .../ios/NativeListMetricCardRenderer.swift | 7 +++++++ .../ios/NativeListRailRenderer.swift | 6 ++++++ .../ios/NativeListRendererRegistry.swift | 21 ++----------------- .../ios/NativeListRowHost.swift | 5 +++++ .../ios/NativeListSystemRenderer.swift | 13 +++++++++++- 11 files changed, 77 insertions(+), 21 deletions(-) diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index 537f90e3e..29130a5e7 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -421,3 +421,10 @@ MetricCard (7/7). With the earlier Message pilot, eight template types now use the closed renderer registry. Stage 4 still owns Market/Identity/SectionHeader, stage 5 owns WalletGroup, and stage 6 removes their remaining legacy policies. Historical “remaining stage 3” lists above describe each incremental checkpoint. + +The final iOS ownership pass moves measurement and size-preset policy into each +registered renderer host. The registry now only forwards through its type table. +After rebuilding and reinstalling, default → styled → cleared heights were +verified as MediaTile 233 → 260 → 233, Action 60 → 100 → 60, System retry +44 → 160 → 44, Activity 60 → 160 → 60, DataRow 60 → 120 → 60 and MetricCard +132 → 240 → 132 points. The runtime stores `ios-renderer-measurement-final.json`. diff --git a/native-views/react-native-native-list/ios/NativeListActionRenderer.swift b/native-views/react-native-native-list/ios/NativeListActionRenderer.swift index f7577cb9c..ac5159fc0 100644 --- a/native-views/react-native-native-list/ios/NativeListActionRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListActionRenderer.swift @@ -1,6 +1,13 @@ import UIKit final class NativeListActionCell: NativeListRendererCell { + override class func measure( + _ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String + ) -> CGFloat? { + return item.data.string("presentation") == "accountSelector" + ? 48 : item.data.dictionary("icon") == nil ? 44 : 60 + } + private let visual = NativeListLeadingVisual(frame: .zero) private let title = NativeListTextLabel() private let accessories = NativeListAccessoryStack(frame: .zero) @@ -21,7 +28,9 @@ final class NativeListActionCell: NativeListRendererCell { title.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal) title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) accessories.onAction = { [weak self] key, view, slot, target in - self?.emitAction(key, from: view, source: "trailingAccessory", slot: slot, target: target, anchorInset: self?.accessories.anchorInset(for: view) ?? 0) + self?.emitAction( + key, from: view, source: "trailingAccessory", slot: slot, target: target, + anchorInset: self?.accessories.anchorInset(for: view) ?? 0) } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift b/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift index be123ade2..18e1ba654 100644 --- a/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift @@ -1,6 +1,12 @@ import UIKit final class NativeListActivityCell: NativeListRendererCell { + override class func measure( + _ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String + ) -> CGFloat? { + return item.data.dictionaries("footerActions").isEmpty ? 60 : 100 + } + private let visual = NativeListLeadingVisual() private let column = UIStackView() private let titleLine = UIStackView() diff --git a/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift b/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift index a2c8b262b..adc8d057d 100644 --- a/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift @@ -1,6 +1,15 @@ import UIKit final class NativeListDataRowCell: NativeListRendererCell { + override class func measure( + _ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String + ) -> CGFloat? { + let secondary = item.data.dictionaries("columns").contains { + !$0.string("secondaryText").isEmpty + } + return secondary ? 60 : layout == "table" ? 48 : 56 + } + private let visual = NativeListLeadingVisual() private let favorite = UIImageView() private let accessories = NativeListAccessoryStack() diff --git a/native-views/react-native-native-list/ios/NativeListMediaTileRenderer.swift b/native-views/react-native-native-list/ios/NativeListMediaTileRenderer.swift index e59e5dfa2..af0b72f5a 100644 --- a/native-views/react-native-native-list/ios/NativeListMediaTileRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListMediaTileRenderer.swift @@ -1,6 +1,12 @@ import UIKit final class NativeListMediaTileCell: NativeListRendererCell { + override class func measure( + _ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String + ) -> CGFloat? { + return 244 + } + private let picture = UIView() private let image = NativeListImageSlot() private let network = NativeListImageSlot() diff --git a/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift b/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift index f13a43017..af2c74369 100644 --- a/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift @@ -143,6 +143,13 @@ enum NativeListMessageRenderer { } final class NativeListMessageCell: NativeListRendererCell { + override class func measure( + _ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String + ) -> CGFloat? { + return NativeListMessageRenderer.Resolved(item, theme: theme, layout: layout).measure( + width: width) + } + override var assetFields: [String] { ["leading", "thumbnail"] } private lazy var views = NativeListMessageRenderer.Views(root: root) override init(frame: CGRect) { super.init(frame: frame) } diff --git a/native-views/react-native-native-list/ios/NativeListMetricCardRenderer.swift b/native-views/react-native-native-list/ios/NativeListMetricCardRenderer.swift index abb9c4eae..9fb013dc0 100644 --- a/native-views/react-native-native-list/ios/NativeListMetricCardRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListMetricCardRenderer.swift @@ -1,6 +1,13 @@ import UIKit final class NativeListMetricCardCell: NativeListRendererCell { + override class func measure( + _ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String + ) -> CGFloat? { + return item.data.string("variant") == "activity" + ? 160 + 1 / UIScreen.main.scale : item.data.string("variant") == "performance" ? 178 : 132 + } + private let visual = NativeListLeadingVisual() private let mainStack = UIStackView() private let titleLine = UIStackView() diff --git a/native-views/react-native-native-list/ios/NativeListRailRenderer.swift b/native-views/react-native-native-list/ios/NativeListRailRenderer.swift index 98b0f3874..1422d2085 100644 --- a/native-views/react-native-native-list/ios/NativeListRailRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListRailRenderer.swift @@ -74,6 +74,12 @@ enum NativeListRailRenderer { } final class NativeListRailCell: NativeListRendererCell { + override class func measure( + _ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String + ) -> CGFloat? { + return 40 + } + private let title = NativeListTextLabel() private let badge = NativeListTextLabel() private let status = NativeListTextLabel() diff --git a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift index c2621d96f..84922ece2 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift @@ -28,28 +28,11 @@ enum NativeListRendererRegistry { } } static func appliesSizePreset(_ item: NativeListItem) -> Bool { - item.rendererKey != .system || NativeListSystemRenderer.appliesSizePreset(item) + hosts[item.rendererKey]!.type.appliesSizePreset(item) } static func measure(_ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String) -> CGFloat? { - if item.rendererKey == .metricCard { - return item.data.string("variant") == "activity" ? 160 + 1 / UIScreen.main.scale : item.data.string("variant") == "performance" ? 178 : 132 - } - if item.rendererKey == .dataRow { - let secondary = item.data.dictionaries("columns").contains { !$0.string("secondaryText").isEmpty } - return secondary ? 60 : layout == "table" ? 48 : 56 - } - if item.rendererKey == .activity { return item.data.dictionaries("footerActions").isEmpty ? 60 : 100 } - if item.rendererKey == .system { return NativeListSystemRenderer.measure(item, width: width) } - if item.rendererKey == .action { - return item.data.string("presentation") == "accountSelector" - ? 48 : item.data.dictionary("icon") == nil ? 44 : 60 - } - if item.rendererKey == .rail { return 40 } - if item.rendererKey == .mediaTile { return 244 } - guard item.rendererKey == .message else { return nil } - return NativeListMessageRenderer.Resolved(item, theme: theme, layout: layout).measure( - width: width) + hosts[item.rendererKey]!.type.measure(item, width: width, theme: theme, layout: layout) } } diff --git a/native-views/react-native-native-list/ios/NativeListRowHost.swift b/native-views/react-native-native-list/ios/NativeListRowHost.swift index 1a469a788..3ad6bf64f 100644 --- a/native-views/react-native-native-list/ios/NativeListRowHost.swift +++ b/native-views/react-native-native-list/ios/NativeListRowHost.swift @@ -3,6 +3,11 @@ import UIKit // Common list-facing lifecycle. Legacy templates and migrated renderers share // this contract, not their view allocation or reset implementation. class NativeListRowHost: UICollectionViewCell { + class func measure(_ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String) + -> CGFloat? + { nil } + class func appliesSizePreset(_ item: NativeListItem) -> Bool { true } + var bindingEpoch = 0 var listStyle: [String: Any]? var onAction: ((NativeListItem, String, NativeSelectionTarget?, NativeListActionOrigin?) -> Void)? diff --git a/native-views/react-native-native-list/ios/NativeListSystemRenderer.swift b/native-views/react-native-native-list/ios/NativeListSystemRenderer.swift index 0cbcc7d33..c7565954f 100644 --- a/native-views/react-native-native-list/ios/NativeListSystemRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListSystemRenderer.swift @@ -1,6 +1,15 @@ import UIKit final class NativeListSystemCell: NativeListRendererCell { + override class func measure( + _ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String + ) -> CGFloat? { + return NativeListSystemRenderer.measure(item, width: width) + } + override class func appliesSizePreset(_ item: NativeListItem) -> Bool { + NativeListSystemRenderer.appliesSizePreset(item) + } + private let mainStack = UIStackView() private let titleRowStack = UIStackView() private let titleLabel = NativeListTextLabel() @@ -498,7 +507,9 @@ private final class NativeListMarketSkeleton: UIView { } enum NativeListSystemRenderer { - static func appliesSizePreset(_ item: NativeListItem) -> Bool { !["warning", "spacer"].contains(item.data.string("variant")) } + static func appliesSizePreset(_ item: NativeListItem) -> Bool { + !["warning", "spacer"].contains(item.data.string("variant")) + } static func measure(_ item: NativeListItem, width: CGFloat) -> CGFloat { if item.type == "system", item.data.string("variant") == "spacer" { return CGFloat(item.data.int("height")) From 98c87db77aa9f16ba5b526a7b0c263b9915a613a Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 21:31:21 +0800 Subject: [PATCH 48/71] refactor(native-list): extract complex Web row renderers --- .../src/__tests__/NativeList.web.test.ts | 126 ++ .../src/web/NativeListWebEngine.ts | 1317 +---------------- .../src/web/templates/IdentityRowRenderer.ts | 519 +++++++ .../src/web/templates/MarketRowRenderer.ts | 467 ++++++ .../src/web/templates/RowElements.ts | 65 + .../src/web/templates/RowRendererRegistry.ts | 18 +- .../src/web/templates/RowText.ts | 177 +++ .../src/web/templates/RowVisual.ts | 31 +- .../web/templates/SectionHeaderRowRenderer.ts | 243 +++ 9 files changed, 1668 insertions(+), 1295 deletions(-) create mode 100644 native-views/react-native-native-list/src/web/templates/IdentityRowRenderer.ts create mode 100644 native-views/react-native-native-list/src/web/templates/MarketRowRenderer.ts create mode 100644 native-views/react-native-native-list/src/web/templates/RowText.ts create mode 100644 native-views/react-native-native-list/src/web/templates/SectionHeaderRowRenderer.ts diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index ba144997d..e59f828b6 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -2471,3 +2471,129 @@ describe('MetricCard renderer lifecycle', () => { } }); }); + +describe('complex renderer lifecycle', () => { + const { JSDOM } = require('jsdom') as { + JSDOM: new (html: string) => { window: { document: Document } }; + }; + const snap = (row: RowModel): NativeListSnapshot => ({ + schemaVersion: 1, + generation: 1, + layout: { kind: 'linear' }, + rows: [row], + }); + it('keeps Market image slots and action context through quotes, styles and clear', () => { + const { document } = new JSDOM('').window; + const host = document.createElement('div'); + document.body.appendChild(host); + const row: Extract = { + type: 'market', + key: 'quote', + variant: 'token', + leading: { kind: 'token', image }, + title: 'Bitcoin', + price: '$42', + change: { text: '+2%', tone: 'positive' }, + badges: [{ key: 'badge', text: 'Info', icon: image, actionKey: 'info' }], + }; + const engine = new NativeListWebEngine(host, snap(row), {}, false); + try { + const body = host.querySelector( + '[data-nl-renderer="market"]' + )!; + const images = Array.from(body.querySelectorAll('img')); + images.forEach((bitmap) => + bitmap.dispatchEvent(new document.defaultView!.Event('load')) + ); + const badge = body.querySelector('.ok-native-list-market-badge'); + engine.applyPatches([ + { + type: 'market', + key: row.key, + changes: { price: '$43', change: { text: '-1%', tone: 'negative' } }, + }, + ]); + expect(body.querySelector('.ok-native-list-market-badge')).toBe(badge); + expect( + body.querySelector('.ok-native-list-market-price')?.textContent + ).toBe('$43'); + const styled = { + ...row, + title: 'Updated', + style: { + container: { height: 100 }, + image: { width: 38 }, + title: { fontSize: 19 }, + price: { fontSize: 20 }, + }, + }; + engine.applySnapshot(snap(styled)); + expect(host.querySelector('[data-nl-renderer="market"]')).toBe(body); + expect(Array.from(body.querySelectorAll('img'))).toEqual(images); + expect(images.every((bitmap) => bitmap.style.opacity === '1')).toBe(true); + engine.applySnapshot(snap(row)); + expect(Array.from(body.querySelectorAll('img'))).toEqual(images); + expect(images.every((bitmap) => bitmap.style.opacity === '1')).toBe(true); + expect( + body.querySelector('.ok-native-list-market-title')?.style + .fontSize + ).toBe('16px'); + expect( + body.querySelector('.ok-native-list-visual')?.style.width + ).toBe('32px'); + } finally { + engine.destroy(); + } + }); + it('retains Identity images while replacing accessories and switching to a header on the same key', () => { + const { document } = new JSDOM('').window; + const host = document.createElement('div'); + document.body.appendChild(host); + const row: IdentityRow = { + type: 'identity', + key: 'complex', + title: 'Wallet', + leading: { kind: 'account', image }, + trailing: [{ kind: 'value', text: '$42' }], + }; + const engine = new NativeListWebEngine(host, snap(row), {}, false); + try { + const body = host.querySelector( + '[data-nl-renderer="identity"]' + )!; + const imageElement = body.querySelector('img'); + engine.applySnapshot( + snap({ + ...row, + title: 'Renamed', + trailing: [{ kind: 'menu', actionKey: 'menu' }], + style: { image: { width: 30 }, title: { fontSize: 20 } }, + }) + ); + expect(body.querySelector('img')).toBe(imageElement); + expect(body.textContent).not.toContain('$42'); + engine.applySnapshot(snap(row)); + expect(body.querySelector('img')).toBe(imageElement); + expect( + body.querySelector('[data-nl-slot="title"]')?.style + .fontSize + ).toBe(''); + engine.applySnapshot( + snap({ + type: 'sectionHeader', + key: row.key, + sectionKey: 'a', + variant: 'summary', + title: 'Assets', + value: '$100', + }) + ); + expect(host.querySelector('[data-nl-renderer="identity"]')).toBeNull(); + expect( + host.querySelector('[data-nl-renderer="sectionHeader"]')?.textContent + ).toBe('Assets$100'); + } finally { + engine.destroy(); + } + }); +}); diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index dd903f206..40cc48900 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -1,3 +1,10 @@ +import { identityRowRenderer } from './templates/IdentityRowRenderer'; +import { applyTextStyleToSlot, applyValueSegments } from './templates/RowText'; +import { setMarketQuoteContent } from './templates/MarketRowRenderer'; +export { + resolveWebMarketLayoutStyle, + type WebMarketLayoutStyle, +} from './templates/MarketRowRenderer'; import { measureRailWidth } from './templates/RailRowRenderer'; import { rowRenderer, @@ -10,16 +17,12 @@ import type { CheckboxState, ImageSource, LeadingVisual, - MarketRow, - MarketTextStyle, NativeListSnapshot, NativeListActionAnchor, NativeListActionSource, NativeListTheme, - NativeListTextStyle, ReorderEvent, RowActionEvent, - RowBoxStyle, RowModel, RowPatch, SelectionDeltaEvent, @@ -198,49 +201,7 @@ export function webSectionIndexActiveKey( return activeKey; } -export type WebMarketLayoutStyle = Readonly<{ - horizontalPadding: number; - verticalPadding: number; - leadingGap: number; - titleBadgeGap: number; - trailingGap: number; - imageWidth: number; - imageHeight: number; - imageCornerRadius: number; - changeWidth: number; - changeHeight: number; - changeCornerRadius: number; -}>; - /** Resolves every reusable Market geometry field, including legacy defaults. */ -export function resolveWebMarketLayoutStyle( - row: MarketRow -): WebMarketLayoutStyle { - const style = row.style; - const imageWidth = style?.image?.width ?? (row.variant === 'stock' ? 40 : 32); - const imageHeight = - style?.image?.height ?? (row.variant === 'stock' ? 40 : 32); - return { - horizontalPadding: - style?.horizontalPadding ?? (row.variant === 'perp' ? 16 : 20), - verticalPadding: style?.verticalPadding ?? 12, - leadingGap: style?.leadingGap ?? (row.variant === 'perp' ? 8 : 14), - titleBadgeGap: style?.titleBadgeGap ?? 4, - trailingGap: style?.trailingGap ?? 8, - imageWidth, - imageHeight, - imageCornerRadius: - style?.image?.cornerRadius ?? - (style?.image?.shape === 'square' - ? 0 - : style?.image?.shape === 'rounded' - ? 8 - : Math.min(imageWidth, imageHeight) / 2), - changeWidth: style?.changeWidth ?? 80, - changeHeight: style?.changeHeight ?? 32, - changeCornerRadius: style?.changeCornerRadius ?? 8, - }; -} export type NativeListWebCallbacks = Readonly<{ onRowAction?: (event: RowActionEvent) => void; @@ -495,69 +456,14 @@ export function estimateWebRowHeight( row.children.length * 12 + (row.parent.height !== undefined ? 2 : 0) ); - // OneKey patch: reserve the source badge line below wallet names. - // if (row.type === 'identity' && row.presentation === 'walletSidebar') - // return 68; - if (row.type === 'identity' && row.presentation === 'walletSidebar') - return 68 + (row.badges?.length ? 24 : 0); - if (row.type === 'identity' && row.presentation === 'networkSelector') - return 47; - if (row.type === 'identity' && row.presentation === 'accountSelector') - return 58; - const registered = rowRenderer(row); if (registered) return Math.max( 0, - registered.measure(availableWidth) + + registered.measure(availableWidth, snapshot.layout.kind) + (registered.appliesSizePreset ? sizeModifier(row) : 0) ); - let base: number; - switch (row.type) { - case 'sectionHeader': { - const isHistory = - row.variant === 'history' || - row.key.startsWith('history-') || - row.sectionKey.startsWith('history-'); - base = - snapshot.layout.kind === 'table' - ? 28 - : row.presentation === 'networkSelector' - ? 47 - : isHistory - ? 16 - : row.variant === 'summary' - ? 68 - : row.variant === 'gallery' - ? 32 - : row.checkbox - ? 56 - : snapshot.layout.kind === 'linear' - ? 30 - : 36; - break; - } - case 'market': { - const marketStyle = resolveWebMarketLayoutStyle(row); - base = Math.max( - row.variant === 'stock' ? 72 : 68, - marketStyle.imageHeight + marketStyle.verticalPadding * 2 - ); - break; - } - case 'identity': - base = row.tertiary ? 72 : row.subtitle ? 60 : 56; - break; - default: - base = 56; - } - const tableAdjustment = - snapshot.layout.kind === 'table' && - row.type === 'dataRow' && - !row.columns.some((column) => column.secondaryText) - ? -8 - : 0; - return Math.max(0, base + sizeModifier(row) + tableAdjustment); + return Math.max(0, 56 + sizeModifier(row)); } function estimateHorizontalWidth(row: RowModel): number { @@ -1550,17 +1456,6 @@ function iconGlyph(name: string): string { return name.slice(0, 1).toLocaleUpperCase(); } -function visualFromRow(row: RowModel): LeadingVisual | undefined { - if (row.type === 'identity') return row.leading; - if (row.type === 'rail') return row.visual; - if (row.type === 'activity') return row.leading; - if (row.type === 'message') return row.leading; - if (row.type === 'dataRow') return row.leading; - if (row.type === 'market') return row.leading; - if (row.type === 'metricCard') return row.visual; - return undefined; -} - function createVisual( context: RenderContext, visual: LeadingVisual | undefined, @@ -1774,18 +1669,6 @@ function toneColor( return fallback === 'secondary' ? 'var(--nl-secondary)' : 'var(--nl-primary)'; } -function createBadge( - context: RenderContext, - badge: Readonly<{ text: string; tone?: string }> -): HTMLElement { - const element = tagSlot( - createElement(context.document, 'span', 'ok-native-list-badge', badge.text), - 'badge' - ); - setData(element, 'tone', badge.tone); - return element; -} - function selectionKeysForTarget( target: SelectionTarget | undefined, rowKey: string, @@ -1922,34 +1805,6 @@ function markActionAnchorSource( } // OneKey patch: the compact zero-count digits share the amount baseline. -function applyValueSegments( - element: HTMLElement, - segments: - | readonly Readonly<{ text: string; style?: 'subscript' }>[] - | undefined, - fontSize = 16, - lineHeight = 24, - weight = 500 -) { - if (!segments?.length) return; - element.textContent = ''; - element.style.fontSize = String(fontSize) + 'px'; - element.style.lineHeight = String(lineHeight) + 'px'; - element.style.fontWeight = String(weight); - segments.forEach((segment) => { - const span = createElement( - element.ownerDocument, - 'span', - undefined, - segment.text - ); - if (segment.style === 'subscript') { - span.style.fontSize = String(Math.ceil(fontSize * 0.6)) + 'px'; - span.style.lineHeight = String(fontSize) + 'px'; - } - element.appendChild(span); - }); -} function createAccessory( context: RenderContext, @@ -2056,463 +1911,10 @@ function createAccessory( return element; } -function appendAccessories( - parent: HTMLElement, - context: RenderContext, - rowKey: string, - accessories: readonly TrailingAccessory[] | undefined -) { - if (!accessories?.length) return; - const container = createElement( - context.document, - 'span', - 'ok-native-list-accessories' - ); - const row = context.snapshot.rows[context.itemIndex]; - if ( - row && - row.height !== undefined && - 'presentation' in row && - row.presentation === 'networkSelector' - ) { - container.style.gap = accessories.some( - (accessory) => accessory.kind === 'checkbox' - ) - ? '12px' - : '20px'; - } - if ( - row && - row.height !== undefined && - 'presentation' in row && - row.presentation === 'accountSelector' && - accessories.length === 1 && - accessories[0]?.kind === 'icon' && - accessories[0].name === 'PlusSmallOutline' - ) { - setData(container, 'nativeListAccountControl', 'createAddress'); - } - let valueIndex = 0; - accessories.forEach((accessory, slot) => { - const element = createAccessory(context, rowKey, accessory, slot); - if (accessory.kind === 'value' || accessory.kind === 'valuePair') { - tagSlot(element, valueIndex++ === 0 ? 'value' : 'valueSecondary'); - } - container.appendChild(element); - }); - parent.appendChild(container); -} - /** * Marks the element that renders one model field. Style keys name model fields, * not views, and the view pool is shared. See docs/STYLE_SPEC.md §4. */ -function tagSlot(element: T, slot: string): T { - element.dataset.nlSlot = slot; - return element; -} - -function createTextColumn( - context: RenderContext, - title: string, - subtitle?: string, - tertiary?: string, - tertiaryTone?: 'secondary' | 'info', - badges?: readonly Readonly<{ text: string; tone?: string }>[], - slots: Readonly<{ title: string; subtitle: string; tertiary: string }> = { - title: 'title', - subtitle: 'subtitle', - tertiary: 'tertiary', - } -): HTMLElement { - const column = createElement(context.document, 'span', 'ok-native-list-flex'); - const titleLine = tagSlot( - createElement(context.document, 'span', 'ok-native-list-title', title), - slots.title - ); - if (badges?.length) { - titleLine.removeAttribute('data-nl-slot'); - titleLine.replaceChildren( - tagSlot( - createElement(context.document, 'span', undefined, title), - slots.title - ) - ); - const badgeLine = createElement( - context.document, - 'span', - 'ok-native-list-badges' - ); - badges.forEach((badge) => - badgeLine.appendChild(createBadge(context, badge)) - ); - titleLine.appendChild(badgeLine); - } - column.appendChild(titleLine); - if (subtitle) - column.appendChild( - tagSlot( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - subtitle - ), - slots.subtitle - ) - ); - if (tertiary) { - const element = tagSlot( - createElement( - context.document, - 'span', - tertiaryTone === 'info' - ? 'ok-native-list-secondary ok-native-list-info' - : 'ok-native-list-secondary ok-native-list-tertiary', - tertiary - ), - slots.tertiary - ); - column.appendChild(element); - } - return column; -} - -function createSectionHeader( - context: RenderContext, - row: Extract -): HTMLElement { - const body = createElement( - context.document, - 'div', - 'ok-native-list-row ok-native-list-section' - ); - setData(body, 'variant', row.variant); - if (row.titleIcon) { - const titleIcon = createIconAction( - context, - row.titleIcon.name, - row.titleIcon.actionKey, - row.titleIcon.disabled, - row.titleIcon.tintColor - ); - markActionAnchorSource(titleIcon, 'leadingAction'); - body.appendChild(titleIcon); - } - // OneKey patch: section title help has its own measurable action target. - // body.appendChild(createTextColumn(context, row.title, row.subtitle)); - const column = createTextColumn(context, row.title, row.subtitle); - const title = column.firstElementChild as HTMLElement; - title.classList.add('ok-native-list-section-title'); - if (row.titleActionKey) { - setData(title, 'nativeListAction', row.titleActionKey); - markActionAnchorSource(title, 'leadingAction'); - title.setAttribute('role', 'button'); - title.tabIndex = 0; - title.style.alignSelf = 'flex-start'; - title.style.maxWidth = '100%'; - // OneKey patch: explicit network headers reserve a separate 3-point underline area. - if (row.presentation !== 'networkSelector' || row.height === undefined) { - title.style.textDecoration = 'underline dotted'; - title.style.textUnderlineOffset = '6px'; - } - if (row.titleActionOnHover) setData(title, 'nativeListHoverAction', true); - } - if (row.presentation === 'networkSelector' && row.height !== undefined) { - body.style.padding = - row.variant === 'summary' ? '24px 12px 20px' : '0 12px'; - body.style.backgroundColor = 'var(--nl-bg)'; - body.style.gap = row.checkbox ? '12px' : '8px'; - title.style.fontSize = row.variant === 'summary' ? '16px' : '14px'; - title.style.lineHeight = row.variant === 'summary' ? '24px' : '20px'; - title.style.fontWeight = - row.variant === 'summary' || (row.titleActionKey && !row.checkbox) - ? '500' - : '600'; - if (row.titleActionKey) { - const text = createElement( - context.document, - 'span', - 'ok-native-list-section-title-text', - row.title - ); - text.style.overflow = 'hidden'; - text.style.textOverflow = 'ellipsis'; - text.style.maxWidth = '100%'; - const dotted = context.document.createElementNS( - 'http://www.w3.org/2000/svg', - 'svg' - ); - dotted.setAttribute('height', '2'); - dotted.style.cssText = - 'display:block;position:absolute;left:0;bottom:0;width:100%;height:2px;color:var(--nl-secondary)'; - const line = context.document.createElementNS( - 'http://www.w3.org/2000/svg', - 'line' - ); - for (const [key, value] of Object.entries({ - x1: '1', - y1: '1', - x2: '100%', - y2: '1', - stroke: 'currentColor', - 'stroke-width': '1.5', - 'stroke-dasharray': '0,4', - 'stroke-linecap': 'round', - })) - line.setAttribute(key, value); - // OneKey patch: keep both round caps inside the original full-width viewport. - const lineViewport = context.document.createElementNS( - 'http://www.w3.org/2000/svg', - 'svg' - ); - lineViewport.setAttribute('width', 'calc(100% - 1px)'); - lineViewport.setAttribute('height', '2'); - lineViewport.setAttribute('overflow', 'visible'); - lineViewport.appendChild(line); - dotted.appendChild(lineViewport); - // OneKey patch: SVG intrinsic width must not expand the title action beyond its text. - title.style.display = 'block'; - title.style.position = 'relative'; - title.style.width = 'fit-content'; - title.style.paddingBottom = '3px'; - text.style.display = 'block'; - title.replaceChildren(text, dotted); - } - } - body.appendChild(column); - if (row.value) { - const value = tagSlot( - createElement( - context.document, - row.valueActionKey ? 'button' : 'span', - row.valueActionKey - ? 'ok-native-list-action-button ok-native-list-section-value' - : 'ok-native-list-value ok-native-list-section-value', - row.value - ), - 'value' - ); - applyValueSegments(value, row.valueSegments); - if (row.presentation === 'networkSelector' && row.height !== undefined) { - value.style.fontFamily = 'inherit'; - value.style.fontSize = '16px'; - value.style.lineHeight = '24px'; - value.style.fontWeight = '500'; - if (row.valueActionKey) { - value.style.color = 'var(--nl-secondary)'; - value.style.padding = '0'; - value.style.flexShrink = '0'; - } - } - if (row.valueActionTestID) setData(value, 'testid', row.valueActionTestID); - if (row.valueActionKey) { - value.setAttribute('type', 'button'); - setData(value, 'nativeListAction', row.valueActionKey); - markActionAnchorSource(value, 'trailingAccessory', 0); - } - body.appendChild(value); - } - if (row.valueIcon) { - const valueIcon = createIconAction( - context, - row.valueIcon.name, - row.valueIcon.actionKey, - row.valueIcon.disabled, - row.valueIcon.tintColor - ); - markActionAnchorSource(valueIcon, 'trailingAccessory', 1); - body.appendChild(valueIcon); - } - if (row.checkbox) - body.appendChild(createCheckbox(context, row.key, row.checkbox)); - return body; -} - -function createIdentityRow( - context: RenderContext, - row: Extract< - RowModel, - { - type: 'identity'; - } - > -): HTMLElement { - const presentation = row.presentation; - const body = createElement( - context.document, - 'div', - [ - 'ok-native-list-row', - 'ok-native-list-standard', - !presentation ? 'ok-native-list-identity-row' : '', - presentation === 'networkSelector' ? 'ok-native-list-network-row' : '', - presentation === 'walletSidebar' ? 'ok-native-list-wallet-row' : '', - presentation === 'accountSelector' ? 'ok-native-list-account-row' : '', - ] - .filter(Boolean) - .join(' ') - ); - setData( - body, - 'nativeListSelector', - row.height !== undefined ? presentation : undefined - ); - if (row.titleActionKey && row.titleActionOnHover) { - setData(body, 'nativeListHoverAction', row.titleActionKey); - markActionAnchorSource(body, 'leadingAction'); - } - if (row.leadingAction) { - const action = createIconAction( - context, - row.leadingAction.name, - row.leadingAction.actionKey, - row.leadingAction.disabled, - row.leadingAction.tintColor - ); - markActionAnchorSource(action, 'leadingAction'); - body.appendChild(action); - } - const visual = createVisual( - context, - visualFromRow(row), - row.height !== undefined ? presentation : undefined - ); - if ( - visual && - true && - row.height !== undefined && - row.presentation === 'walletSidebar' && - 'fallbackIcon' in row.leading && - row.leading.fallbackIcon?.name === 'LockSolid' - ) { - // OneKey patch: hidden-wallet locks use WalletAvatar's full 40-point icon. - const icon = visual.querySelector( - '.ok-native-list-visual-fallback svg' - ); - if (icon) { - icon.style.width = '40px'; - icon.style.height = '40px'; - } - const fallback = visual.querySelector( - '.ok-native-list-visual-fallback' - ); - if (fallback) { - fallback.style.borderRadius = '0'; - fallback.style.overflow = 'visible'; - } - } - if ( - visual && - true && - row.height !== undefined && - row.presentation === 'walletSidebar' && - 'borderStyle' in row.leading && - row.leading.borderStyle === 'dashed' - ) - visual.style.borderWidth = '1px'; - if (visual) body.appendChild(visual); - const title = row.title; - const subtitle = row.subtitle; - const column = createTextColumn( - context, - title, - subtitle, - row.tertiary, - row.tertiaryTone, - presentation !== 'walletSidebar' ? row.badges : undefined, - { - title: 'title', - subtitle: 'subtitle', - tertiary: 'tertiary', - } - ); - { - const titleElement = column.firstElementChild as HTMLElement; - if (row.titleMatch?.length) { - const textTarget = - titleElement.querySelector('[data-nl-slot="title"]') ?? - titleElement; - const firstText = textTarget.firstChild; - if (firstText) firstText.remove(); - const fragment = context.document.createDocumentFragment(); - let offset = 0; - row.titleMatch.forEach(({ start, end }) => { - fragment.appendChild( - context.document.createTextNode(row.title.slice(offset, start)) - ); - const match = createElement( - context.document, - 'span', - 'ok-native-list-info', - row.title.slice(start, end) - ); - fragment.appendChild(match); - offset = end; - }); - fragment.appendChild( - context.document.createTextNode(row.title.slice(offset)) - ); - textTarget.prepend(fragment); - } - if (row.subtitleSegments?.length) { - column.querySelector('.ok-native-list-secondary')?.remove(); - const segments = createElement( - context.document, - 'span', - 'ok-native-list-subtitle-segments' - ); - row.subtitleSegments.forEach((segment) => { - if (segment.separatorBefore) - segments.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-subtitle-dot' - ) - ); - const text = createElement( - context.document, - 'span', - 'ok-native-list-secondary', - segment.text - ); - tagSlot(text, 'subtitle'); - applyValueSegments(text, segment.textSegments, 14, 20, 400); - setData(text, 'tone', segment.tone); - text.style.color = - segment.tone === 'disabled' - ? 'var(--nl-disabled)' - : segment.tone === 'caution' - ? 'var(--nl-caution)' - : toneColor(segment.tone, 'secondary'); - segments.appendChild(text); - }); - column.insertBefore(segments, titleElement.nextSibling); - } - if (presentation === 'walletSidebar' && row.badges?.length) { - const badges = createElement( - context.document, - 'span', - 'ok-native-list-wallet-badges' - ); - row.badges.forEach((badge) => - badges.appendChild(createBadge(context, badge)) - ); - column.appendChild(badges); - } - for (const key of ['title', 'subtitle'] as const) { - const lines = row.style?.[key]?.lines ?? row[`${key}Lines`]; - if (lines !== undefined) - column - .querySelectorAll(`[data-nl-slot="${key}"]`) - .forEach((text) => applyTextLayout(text, { lines })); - } - } - body.appendChild(column); - appendAccessories(body, context, row.key, row.trailing); - return body; -} // OneKey patch: SizableText enables tabular digits without replacing its font family. function applySelectorTabularNumbers(body: HTMLElement, row: RowModel) { @@ -2549,8 +1951,12 @@ function createWalletGroupRow( setData(memberElement, 'nativeListGroupParent', memberIndex === 0); setData(memberElement, 'nativeListSelected', member.selected); // OneKey patch: grouped members have the same selector typography as standalone wallets. - // memberElement.appendChild(createIdentityRow(context, member)); - const memberBody = createIdentityRow(context, member); + // memberElement.appendChild(identityRowRenderer.create(context.document,member,rendererPrimitives(context))); + const memberBody = identityRowRenderer.create( + context.document, + member, + rendererPrimitives(context) + ); applySelectorTabularNumbers(memberBody, member); applyRowStyle(memberBody, member); memberElement.appendChild(memberBody); @@ -2573,469 +1979,7 @@ function createWalletGroupRow( return body; } -function marketFontWeight( - value: MarketTextStyle['fontWeight'] | undefined, - fallback: number -): number { - if (value === 'bold') return 700; - if (value === 'semibold') return 600; - if (value === 'medium') return 500; - if (value === 'regular') return 400; - return fallback; -} - -function applyMarketTextStyle( - element: HTMLElement, - style: MarketTextStyle | undefined, - defaults: Readonly<{ - fontSize: number; - lineHeight: number; - weight: number; - alignment: 'start' | 'center' | 'end'; - }> -) { - element.style.fontSize = String(style?.fontSize ?? defaults.fontSize) + 'px'; - element.style.lineHeight = - String(style?.lineHeight ?? defaults.lineHeight) + 'px'; - element.style.fontWeight = String( - marketFontWeight(style?.fontWeight, defaults.weight) - ); - element.style.color = style?.color ?? ''; - element.style.textAlign = style?.alignment ?? defaults.alignment; - element.style.whiteSpace = (style?.lines ?? 1) > 1 ? 'normal' : 'nowrap'; - element.style.display = ''; - element.style.removeProperty('-webkit-line-clamp'); - element.style.removeProperty('-webkit-box-orient'); - if ((style?.lines ?? 1) > 1) { - element.style.display = '-webkit-box'; - element.style.setProperty('-webkit-line-clamp', String(style?.lines ?? 2)); - element.style.setProperty('-webkit-box-orient', 'vertical'); - } -} - -function setMarketQuoteContent(element: HTMLElement, row: MarketRow) { - const style = row.style; - const layoutStyle = resolveWebMarketLayoutStyle(row); - const price = element.querySelector( - '.ok-native-list-market-price' - ); - if (price) { - price.textContent = row.price; - applyMarketTextStyle(price, style?.price, { - fontSize: 16, - lineHeight: 24, - weight: 500, - alignment: 'end', - }); - applyValueSegments( - price, - row.priceSegments, - style?.price?.fontSize ?? 16, - style?.price?.lineHeight ?? 24, - marketFontWeight(style?.price?.fontWeight, 500) - ); - } - const change = element.querySelector( - '.ok-native-list-market-change' - ); - if (change) { - change.textContent = row.change.text; - setData(change, 'tone', row.change.tone); - applyMarketTextStyle(change, style?.change, { - fontSize: 14, - lineHeight: 20, - weight: 500, - alignment: 'center', - }); - applyValueSegments( - change, - row.change.textSegments, - style?.change?.fontSize ?? 14, - style?.change?.lineHeight ?? 20, - marketFontWeight(style?.change?.fontWeight, 500) - ); - change.style.width = String(layoutStyle.changeWidth) + 'px'; - change.style.height = String(layoutStyle.changeHeight) + 'px'; - change.style.borderRadius = String(layoutStyle.changeCornerRadius) + 'px'; - change.style.color = style?.change?.color ?? row.change.textColor ?? ''; - change.style.background = row.change.backgroundColor ?? ''; - } - if (price) applyTextStyleToSlot(price, style?.price ?? {}, 1); - if (change) applyTextStyleToSlot(change, style?.change ?? {}, 1); - element.setAttribute( - 'aria-label', - row.accessibilityLabel ?? - [row.title, row.subtitle, row.price, row.change.text] - .filter(Boolean) - .join(', ') - ); -} - -function createMarketRow(context: RenderContext, row: MarketRow): HTMLElement { - const body = createElement( - context.document, - 'div', - 'ok-native-list-row ok-native-list-market' - ); - setData(body, 'variant', row.variant); - const style = row.style; - const layoutStyle = resolveWebMarketLayoutStyle(row); - body.style.padding = - String(layoutStyle.verticalPadding) + - 'px ' + - String(layoutStyle.horizontalPadding) + - 'px'; - body.style.gap = '0px'; - if (row.leadingAction) { - const action = createIconAction( - context, - row.leadingAction.name, - row.leadingAction.actionKey, - row.leadingAction.disabled, - row.leadingAction.tintColor - ); - action.style.flex = '0 0 36px'; - action.style.width = '36px'; - action.style.height = '36px'; - action.style.marginRight = '5px'; - setData(action, 'testid', row.leadingAction.testID); - if (row.leadingAction.accessibilityLabel) - action.setAttribute('aria-label', row.leadingAction.accessibilityLabel); - markActionAnchorSource(action, 'leadingAction'); - body.appendChild(action); - } - const visual = createVisual(context, row.leading); - if (visual) { - const width = layoutStyle.imageWidth; - const height = layoutStyle.imageHeight; - visual.style.width = String(width) + 'px'; - visual.style.height = String(height) + 'px'; - visual.style.flexBasis = String(width) + 'px'; - visual.style.borderRadius = String(layoutStyle.imageCornerRadius) + 'px'; - const borderColor = - 'borderColor' in row.leading ? row.leading.borderColor : undefined; - if (borderColor) { - visual.style.border = '1px solid ' + borderColor; - visual.style.boxSizing = 'border-box'; - } - visual.querySelectorAll('img').forEach((image) => { - if (!image.classList.contains('ok-native-list-visual-corner')) { - image.style.width = '100%'; - image.style.height = '100%'; - if (borderColor) { - image.style.borderRadius = '0'; - image.style.clipPath = - 'inset(-1px round ' + String(layoutStyle.imageCornerRadius) + 'px)'; - } - image.style.objectFit = - style?.image?.contentFit === 'center' - ? 'none' - : style?.image?.contentFit ?? image.style.objectFit; - } else { - image.style.width = '20px'; - image.style.height = '20px'; - if (borderColor) { - image.style.right = '-5px'; - image.style.bottom = '-5px'; - } - } - }); - visual.style.marginInlineEnd = String(layoutStyle.leadingGap) + 'px'; - body.appendChild(visual); - } - const main = createElement( - context.document, - 'span', - 'ok-native-list-market-main' - ); - main.style.gap = String(style?.lineGap ?? 0) + 'px'; - const titleLine = createElement( - context.document, - 'span', - 'ok-native-list-market-title-line' - ); - titleLine.style.gap = String(layoutStyle.titleBadgeGap) + 'px'; - const title = createElement( - context.document, - 'span', - 'ok-native-list-market-title', - row.title - ); - applyMarketTextStyle(title, style?.title, { - fontSize: 16, - lineHeight: 24, - weight: 500, - alignment: 'start', - }); - titleLine.appendChild(title); - row.badges?.forEach((badge, slot) => { - const element = createElement( - context.document, - badge.actionKey ? 'button' : 'span', - 'ok-native-list-market-badge', - badge.text - ); - if (badge.actionKey) { - element.setAttribute('type', 'button'); - setData(element, 'nativeListAction', badge.actionKey); - markActionAnchorSource(element, 'marketBadge', slot); - } - setData(element, 'tone', badge.tone); - element.style.color = - badge.textColor ?? - (badge.tone === 'success' - ? 'var(--nl-positive)' - : badge.tone === 'danger' - ? 'var(--nl-negative)' - : badge.tone === 'info' - ? 'var(--nl-info)' - : badge.tone === 'warning' - ? 'var(--nl-primary)' - : ''); - element.style.background = - badge.backgroundColor ?? - ((badge.icon || badge.iconName) && !badge.text ? 'transparent' : ''); - if ((badge.icon || badge.iconName) && !badge.text) { - element.style.padding = '0'; - } - if (badge.style) { - applyTextStyleToSlot(element, badge.style); - if (badge.style.height !== undefined) - element.style.height = `${badge.style.height}px`; - if (badge.style.horizontalPadding !== undefined && badge.text) - element.style.paddingInline = `${badge.style.horizontalPadding}px`; - } - if (badge.accessibilityLabel) - element.setAttribute('aria-label', badge.accessibilityLabel); - if (badge.icon) { - const icon = createImage(context, badge.icon); - if (icon) { - icon.style.borderRadius = '50%'; - element.prepend(icon); - } - } - if (badge.iconName === 'verified') { - const icon = createElement( - context.document, - 'span', - 'ok-native-list-market-badge-icon' - ); - applySelectorIcon(icon, 'BadgeVerifiedSolid'); - element.prepend(icon); - } - titleLine.appendChild(element); - }); - main.appendChild(titleLine); - if (row.subtitlePrefix || row.subtitle || row.subtitleSegments?.length) { - const subtitleLine = createElement( - context.document, - 'span', - 'ok-native-list-market-subtitle-line' - ); - subtitleLine.style.display = 'flex'; - subtitleLine.style.alignItems = 'center'; - subtitleLine.style.minWidth = '0'; - subtitleLine.style.overflow = 'hidden'; - subtitleLine.style.gap = - String(row.subtitlePrefix ? row.subtitlePrefix.gap ?? 4 : 0) + 'px'; - if (row.subtitlePrefix) { - const prefix = createElement( - context.document, - 'span', - 'ok-native-list-market-subtitle-prefix', - row.subtitlePrefix.text - ); - applyMarketTextStyle(prefix, row.subtitlePrefix.style, { - fontSize: 12, - lineHeight: 16, - weight: 400, - alignment: 'start', - }); - prefix.style.display = 'block'; - prefix.style.flex = '1 1 auto'; - prefix.style.minWidth = '0'; - prefix.style.overflow = 'hidden'; - prefix.style.textOverflow = 'ellipsis'; - prefix.style.color = - row.subtitlePrefix.style?.color ?? 'var(--nl-secondary)'; - if (row.subtitlePrefix.maxWidth !== undefined) { - prefix.style.maxWidth = String(row.subtitlePrefix.maxWidth) + 'px'; - } - subtitleLine.appendChild(prefix); - } - if (row.subtitle || row.subtitleSegments?.length) { - const subtitle = createElement( - context.document, - 'span', - 'ok-native-list-market-subtitle', - row.subtitle - ); - applyMarketTextStyle(subtitle, style?.subtitle, { - fontSize: 14, - lineHeight: 20, - weight: 400, - alignment: 'start', - }); - applyValueSegments( - subtitle, - row.subtitleSegments, - style?.subtitle?.fontSize ?? 14, - style?.subtitle?.lineHeight ?? 20, - marketFontWeight(style?.subtitle?.fontWeight, 400) - ); - subtitle.style.flex = row.subtitlePrefix ? '0 0 auto' : '1 1 auto'; - subtitleLine.appendChild(subtitle); - } - main.appendChild(subtitleLine); - } - body.appendChild(main); - const trailing = createElement( - context.document, - 'span', - 'ok-native-list-market-trailing' - ); - trailing.style.gap = String(layoutStyle.trailingGap) + 'px'; - trailing.append( - createElement(context.document, 'span', 'ok-native-list-market-price'), - createElement(context.document, 'span', 'ok-native-list-market-change') - ); - body.appendChild(trailing); - setMarketQuoteContent(body, row); - for (const key of ['title', 'subtitle'] as const) { - const text = body.querySelector( - `.ok-native-list-market-${key}` - ); - if (text) applyTextStyleToSlot(text, style?.[key] ?? {}, 1); - } - const prefix = body.querySelector( - '.ok-native-list-market-subtitle-prefix' - ); - if (prefix) applyTextStyleToSlot(prefix, row.subtitlePrefix?.style ?? {}, 1); - return body; -} - -const ROW_BOX_STYLE_KEYS: ReadonlySet = new Set([ - 'container', - 'horizontalPadding', - 'verticalPadding', - 'leadingGap', - 'lineGap', - 'titleBadgeGap', - 'trailingGap', - 'image', -]); - /** Text owns a single wrapping budget, including its rich runs. */ -function applyTextLayout( - element: HTMLElement, - style: NativeListTextStyle, - defaultLines?: number -): void { - if ( - style.lines === undefined && - style.truncate === undefined && - style.verticalAlignment === undefined && - style.offsetY === undefined && - defaultLines === undefined - ) - return; - let content = element.querySelector( - ':scope > .ok-native-list-text-content' - ); - const computed = element.ownerDocument.defaultView?.getComputedStyle(element); - const inheritedClamp = Number( - element.style.getPropertyValue('-webkit-line-clamp') || - computed?.getPropertyValue('-webkit-line-clamp') - ); - const lines = - style.lines ?? - defaultLines ?? - (Number( - element.style.getPropertyValue('-webkit-line-clamp') || - computed?.getPropertyValue('-webkit-line-clamp') - ) || - 1); - if ( - !content && - (style.verticalAlignment !== undefined || - style.offsetY !== undefined || - element.classList.contains('ok-native-list-market-change')) - ) { - content = element.ownerDocument.createElement('span'); - content.className = 'ok-native-list-text-content'; - content.append(...Array.from(element.childNodes)); - element.appendChild(content); - element.style.display = 'flex'; - element.style.flexDirection = 'column'; - element.style.removeProperty('-webkit-line-clamp'); - element.style.removeProperty('-webkit-box-orient'); - content.style.minWidth = '0'; - content.style.flex = '0 1 auto'; - content.style.width = '100%'; - } - const text = content ?? element; - if (style.verticalAlignment !== undefined) { - element.style.justifyContent = { - top: 'flex-start', - center: 'center', - bottom: 'flex-end', - }[style.verticalAlignment]; - } - if (style.offsetY !== undefined) - text.style.transform = `translateY(${style.offsetY}px)`; - if ( - style.lines === undefined && - style.truncate === undefined && - defaultLines === undefined && - !inheritedClamp - ) { - if (content) { - content.style.whiteSpace = computed?.whiteSpace || 'inherit'; - content.style.textOverflow = computed?.textOverflow || 'inherit'; - content.style.overflow = 'hidden'; - } - return; - } - if (lines === 1) { - const walker = element.ownerDocument.createTreeWalker( - text, - 4 /* SHOW_TEXT */ - ); - let previousCarriageReturn = false; - while (walker.nextNode()) { - const value = walker.currentNode.nodeValue ?? ''; - if (!value) continue; - // A CRLF pair may straddle two styled runs. - const textValue = - previousCarriageReturn && value.startsWith('\n') - ? value.slice(1) - : value; - walker.currentNode.nodeValue = textValue.replace(/\r\n|[\r\n]/g, ' '); - previousCarriageReturn = value.endsWith('\r'); - } - } - text.style.whiteSpace = lines === 1 ? 'nowrap' : 'pre-wrap'; - text.style.overflow = 'hidden'; - text.style.textOverflow = style.truncate === 'clip' ? 'clip' : 'ellipsis'; - text.style.removeProperty('-webkit-line-clamp'); - text.style.removeProperty('-webkit-box-orient'); - text.style.maxHeight = ''; - if (lines > 1 && style.truncate !== 'clip') { - text.style.display = '-webkit-box'; - text.style.setProperty('-webkit-line-clamp', String(lines)); - text.style.setProperty('-webkit-box-orient', 'vertical'); - } else { - text.style.display = 'block'; - if (lines > 1) { - // Rows are styled before mounting, when computed typography is unavailable. - // Resolve omitted line height in CSS after the template rules take effect. - text.style.maxHeight = - style.lineHeight !== undefined - ? `${lines * style.lineHeight}px` - : `${lines}lh`; - } - } -} function applyRowContainerStyle(body: HTMLElement, row: RowModel): void { if ( @@ -3102,171 +2046,30 @@ function applyRowContainerStyle(body: HTMLElement, row: RowModel): void { } } -function applyTextStyleToSlot( - element: HTMLElement, - style: NativeListTextStyle, - defaultLines?: number -): void { - if (style.fontSize !== undefined) - element.style.fontSize = String(style.fontSize) + 'px'; - if (style.lineHeight !== undefined) - element.style.lineHeight = String(style.lineHeight) + 'px'; - if (style.fontWeight !== undefined) - element.style.fontWeight = String(marketFontWeight(style.fontWeight, 400)); - if (style.color !== undefined) element.style.color = style.color; - // Explicit overrides also apply to rich text runs, but never to sibling badges. - element.querySelectorAll('span').forEach((run) => { - if (style.color !== undefined) run.style.color = style.color; - if (style.fontSize !== undefined) - run.style.fontSize = String(style.fontSize) + 'px'; - if (style.fontWeight !== undefined) - run.style.fontWeight = String(marketFontWeight(style.fontWeight, 400)); - if (style.lineHeight !== undefined) - run.style.lineHeight = String(style.lineHeight) + 'px'; - if (style.lines !== undefined || style.truncate !== undefined) - run.style.whiteSpace = 'inherit'; - }); - if (style.alignment !== undefined) element.style.textAlign = style.alignment; - applyTextLayout(element, style, defaultLines); -} - /** * Applies a row style once the template has been built. A style key names the * model field it modifies and the element rendering that field carries * `data-nl-slot`, because the view pool is shared across templates. Market * keeps its own richer path. See docs/STYLE_SPEC.md §4 and §8. */ -export function applyRowStyle(body: HTMLElement, row: RowModel): void { - applyRowContainerStyle(body, row); - if (row.type === 'market' || rowRenderer(row)) return; - const style = (row as { style?: Record }).style; - if (!style) return; - const box = style as RowBoxStyle; - if (box.horizontalPadding !== undefined) - body.style.paddingInline = String(box.horizontalPadding) + 'px'; - if (box.verticalPadding !== undefined) - body.style.paddingBlock = String(box.verticalPadding) + 'px'; - const vertical = - row.type === 'identity' && row.presentation === 'walletSidebar'; - const leading = body.querySelector( - ':scope > .ok-native-list-visual, :scope > .ok-native-list-stacked' - ); - const defaultGap = - body.style.gap || - (row.type === 'identity' && row.presentation === 'walletSidebar' - ? '4px' - : row.type === 'identity' && row.presentation === 'accountSelector' - ? '8px' - : '12px'); - if (box.lineGap !== undefined) { - const targets = body.querySelectorAll( - ':scope > .ok-native-list-flex' - ); - targets.forEach((column) => { - column.style.rowGap = String(box.lineGap) + 'px'; - }); - } - if (leading && box.leadingGap !== undefined) { - const gap = 'calc(' + String(box.leadingGap) + 'px - ' + defaultGap + ')'; - if (vertical) leading.style.marginBottom = gap; - else leading.style.marginInlineEnd = gap; - } - if (box.titleBadgeGap !== undefined) { - const gap = String(box.titleBadgeGap) + 'px'; - body - .querySelectorAll( - '.ok-native-list-badges, .ok-native-list-wallet-badges' - ) - .forEach((badges) => { - if (badges.classList.contains('ok-native-list-wallet-badges')) - badges.style.marginTop = - box.lineGap === undefined - ? gap - : 'calc(' + gap + ' - ' + String(box.lineGap) + 'px)'; - else badges.style.marginInlineStart = gap; - }); - } - if (box.trailingGap !== undefined) { - const gap = String(box.trailingGap) + 'px'; - body - .querySelectorAll( - '.ok-native-list-accessories, .ok-native-list-amounts' - ) - .forEach((trailing) => { - trailing.style.gap = gap; - }); - if (row.type === 'sectionHeader') { - const children = Array.from(body.children) as HTMLElement[]; - const columnIndex = children.findIndex((child) => - child.classList.contains('ok-native-list-flex') - ); - children.slice(columnIndex + 2).forEach((child) => { - child.style.marginInlineStart = - 'calc(' + gap + ' - ' + defaultGap + ')'; - }); - } - } - if (leading && box.image) { - const image = box.image; - if (image.width !== undefined) { - leading.style.width = String(image.width) + 'px'; - if (!vertical) leading.style.flexBasis = String(image.width) + 'px'; - } - if (image.height !== undefined) - leading.style.height = String(image.height) + 'px'; - const radius = - image.cornerRadius !== undefined - ? String(image.cornerRadius) + 'px' - : image.shape === 'circle' - ? '50%' - : image.shape === 'square' - ? '0px' - : image.shape === 'rounded' - ? '10px' - : undefined; - if (radius !== undefined) { - leading.style.setProperty('border-radius', radius, 'important'); - // Clip the bitmap itself; decorations may extend beyond the visual slot. - leading.style.overflow = leading.matches('img') ? 'hidden' : 'visible'; - } - const images = leading.matches('img') - ? [leading] - : leading.querySelectorAll( - ':scope > img, :scope > .ok-native-list-visual-fallback' - ); - images.forEach((bitmap) => { - if (bitmap !== leading) { - if (image.width !== undefined) bitmap.style.width = '100%'; - if (image.height !== undefined) bitmap.style.height = '100%'; - } - if (radius !== undefined) bitmap.style.borderRadius = radius; - if (image.contentFit !== undefined) - bitmap.style.objectFit = - image.contentFit === 'center' ? 'none' : image.contentFit; - }); - } - Object.keys(style).forEach((key) => { - if (ROW_BOX_STYLE_KEYS.has(key)) return; - const slotStyle = style[key] as NativeListTextStyle | undefined; - if (!slotStyle) return; - body - .querySelectorAll('[data-nl-slot="' + key + '"]') - .forEach((element) => { - applyTextStyleToSlot(element, slotStyle); - }); - }); -} function rendererPrimitives(context: RenderContext) { return { background: resolvedTheme(context.snapshot).background, accessory: (key: string, descriptor: TrailingAccessory, slot: number) => createAccessory(context, key, descriptor, slot), - visual: (source: LeadingVisual | undefined) => - createVisual(context, source), + visual: (source: LeadingVisual | undefined, presentation?: string) => + createVisual(context, source, presentation), thumbnail: (source: ImageSource) => createImage(context, source, 'ok-native-list-thumbnail'), textStyle: applyTextStyleToSlot, + icon: applySelectorIcon, + iconAction: ( + name: string, + actionKey: string | undefined, + disabled?: boolean, + tintColor?: string + ) => createIconAction(context, name, actionKey, disabled, tintColor), dispose: disposeWebImageRetries, }; } @@ -3281,12 +2084,6 @@ export function createRowBody( switch (row.type) { case 'walletGroup': return createWalletGroupRow(context, row); - case 'sectionHeader': - return createSectionHeader(context, row); - case 'market': - return createMarketRow(context, row); - case 'identity': - return createIdentityRow(context, row); } throw new Error('No renderer registered for ' + row.type); } @@ -3325,6 +2122,9 @@ export class NativeListWebEngine { activity: [], dataRow: [], metricCard: [], + market: [], + identity: [], + sectionHeader: [], }; private readonly rendererKeys = new WeakMap(); private frameHandle: number | undefined; @@ -4185,65 +2985,6 @@ export class NativeListWebEngine { 'px 0 ' + restingBackground; } - if (row.type === 'identity' && row.height !== undefined) { - const title = body.querySelector('.ok-native-list-title'); - if (row.presentation === 'accountSelector') { - body.style.gap = '12px'; - body.style.borderRadius = '12px'; - if ('shape' in row.leading && row.leading.shape === 'rounded') { - const visual = body.querySelector( - '.ok-native-list-visual' - ); - if (visual) visual.style.borderRadius = '8px'; - } - if (title) title.style.lineHeight = '24px'; - } - if (row.presentation === 'networkSelector') { - body.style.borderRadius = '12px'; - const visual = body.querySelector( - '.ok-native-list-visual' - ); - if (visual) { - visual.style.width = '32px'; - visual.style.height = '32px'; - visual.style.flexBasis = '32px'; - } - if ( - row.leading.kind === 'network' && - !row.leading.image && - !row.leading.fallbackIcon && - row.leading.fallbackText - ) { - const fallback = visual?.querySelector( - '.ok-native-list-visual-fallback' - ); - if (fallback) { - fallback.style.fontSize = '19px'; - fallback.style.lineHeight = '27px'; - fallback.style.fontWeight = '600'; - fallback.style.color = 'var(--nl-inverse-text)'; - } - } - visual - ?.querySelectorAll('.ok-native-list-visual-main') - .forEach((image) => { - image.style.width = '32px'; - image.style.height = '32px'; - }); - if (title) { - title.style.fontSize = '16px'; - title.style.lineHeight = '24px'; - title.style.fontWeight = '500'; - } - body - .querySelectorAll('.ok-native-list-accessory') - .forEach((value) => { - value.style.fontSize = '16px'; - value.style.lineHeight = '24px'; - value.style.fontWeight = '500'; - }); - } - } // Template and presentation defaults must precede caller overrides. applyRowStyle(body, row); if (body.parentElement !== element) element.replaceChildren(body); @@ -6080,3 +4821,7 @@ export class NativeListWebEngine { }); } } + +export function applyRowStyle(body: HTMLElement, row: RowModel): void { + applyRowContainerStyle(body, row); +} diff --git a/native-views/react-native-native-list/src/web/templates/IdentityRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/IdentityRowRenderer.ts new file mode 100644 index 000000000..8e436d762 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/IdentityRowRenderer.ts @@ -0,0 +1,519 @@ +import type { + IdentityRow, + NativeListTextStyle, + RowBoxStyle, + TrailingAccessory, + TextTone, +} from '../../models'; +import { + createElement, + setData, + tagSlot, + markActionAnchorSource, + createTextColumn, + createBadge, +} from './RowElements'; +import { + applyTextLayout, + applyTextStyleToSlot, + applyValueSegments, +} from './RowText'; +import { RowVisualStyle, type RowPrimitives } from './RowVisual'; +function toneColor( + tone: TextTone | undefined, + fallback: 'primary' | 'secondary' +): string { + if (tone === 'positive') return 'var(--nl-positive)'; + if (tone === 'negative') return 'var(--nl-negative)'; + if (tone === 'secondary') return 'var(--nl-secondary)'; + return fallback === 'secondary' ? 'var(--nl-secondary)' : 'var(--nl-primary)'; +} +function appendAccessories( + parent: HTMLElement, + document: Document, + primitives: RowPrimitives, + row: IdentityRow, + accessories: readonly TrailingAccessory[] | undefined +) { + if (!accessories?.length) return; + const container = createElement( + document, + 'span', + 'ok-native-list-accessories' + ); + + if ( + row && + row.height !== undefined && + 'presentation' in row && + row.presentation === 'networkSelector' + ) { + container.style.gap = accessories.some( + (accessory) => accessory.kind === 'checkbox' + ) + ? '12px' + : '20px'; + } + if ( + row && + row.height !== undefined && + 'presentation' in row && + row.presentation === 'accountSelector' && + accessories.length === 1 && + accessories[0]?.kind === 'icon' && + accessories[0].name === 'PlusSmallOutline' + ) { + setData(container, 'nativeListAccountControl', 'createAddress'); + } + let valueIndex = 0; + accessories.forEach((accessory, slot) => { + const element = primitives.accessory(row.key, accessory, slot); + if (accessory.kind === 'value' || accessory.kind === 'valuePair') { + tagSlot(element, valueIndex++ === 0 ? 'value' : 'valueSecondary'); + } + container.appendChild(element); + }); + parent.appendChild(container); +} +function createIdentityRow( + document: Document, + primitives: RowPrimitives, + row: IdentityRow +): HTMLElement { + const presentation = row.presentation; + const body = createElement( + document, + 'div', + [ + 'ok-native-list-row', + 'ok-native-list-standard', + !presentation ? 'ok-native-list-identity-row' : '', + presentation === 'networkSelector' ? 'ok-native-list-network-row' : '', + presentation === 'walletSidebar' ? 'ok-native-list-wallet-row' : '', + presentation === 'accountSelector' ? 'ok-native-list-account-row' : '', + ] + .filter(Boolean) + .join(' ') + ); + setData( + body, + 'nativeListSelector', + row.height !== undefined ? presentation : undefined + ); + if (row.titleActionKey && row.titleActionOnHover) { + setData(body, 'nativeListHoverAction', row.titleActionKey); + markActionAnchorSource(body, 'leadingAction'); + } + if (row.leadingAction) { + const action = primitives.iconAction( + row.leadingAction.name, + row.leadingAction.actionKey, + row.leadingAction.disabled, + row.leadingAction.tintColor + ); + markActionAnchorSource(action, 'leadingAction'); + body.appendChild(action); + } + const visual = primitives.visual( + row.leading, + row.height !== undefined ? presentation : undefined + ); + if ( + visual && + true && + row.height !== undefined && + row.presentation === 'walletSidebar' && + 'fallbackIcon' in row.leading && + row.leading.fallbackIcon?.name === 'LockSolid' + ) { + // OneKey patch: hidden-wallet locks use WalletAvatar's full 40-point icon. + const icon = visual.querySelector( + '.ok-native-list-visual-fallback svg' + ); + if (icon) { + icon.style.width = '40px'; + icon.style.height = '40px'; + } + const fallback = visual.querySelector( + '.ok-native-list-visual-fallback' + ); + if (fallback) { + fallback.style.borderRadius = '0'; + fallback.style.overflow = 'visible'; + } + } + if ( + visual && + true && + row.height !== undefined && + row.presentation === 'walletSidebar' && + 'borderStyle' in row.leading && + row.leading.borderStyle === 'dashed' + ) + visual.style.borderWidth = '1px'; + if (visual) body.appendChild(visual); + const title = row.title; + const subtitle = row.subtitle; + const column = createTextColumn( + document, + title, + subtitle, + row.tertiary, + row.tertiaryTone, + presentation !== 'walletSidebar' ? row.badges : undefined, + { + title: 'title', + subtitle: 'subtitle', + tertiary: 'tertiary', + } + ); + { + const titleElement = column.firstElementChild as HTMLElement; + if (row.titleMatch?.length) { + const textTarget = + titleElement.querySelector('[data-nl-slot="title"]') ?? + titleElement; + const firstText = textTarget.firstChild; + if (firstText) firstText.remove(); + const fragment = document.createDocumentFragment(); + let offset = 0; + row.titleMatch.forEach(({ start, end }) => { + fragment.appendChild( + document.createTextNode(row.title.slice(offset, start)) + ); + const match = createElement( + document, + 'span', + 'ok-native-list-info', + row.title.slice(start, end) + ); + fragment.appendChild(match); + offset = end; + }); + fragment.appendChild(document.createTextNode(row.title.slice(offset))); + textTarget.prepend(fragment); + } + if (row.subtitleSegments?.length) { + column.querySelector('.ok-native-list-secondary')?.remove(); + const segments = createElement( + document, + 'span', + 'ok-native-list-subtitle-segments' + ); + row.subtitleSegments.forEach((segment) => { + if (segment.separatorBefore) + segments.appendChild( + createElement(document, 'span', 'ok-native-list-subtitle-dot') + ); + const text = createElement( + document, + 'span', + 'ok-native-list-secondary', + segment.text + ); + tagSlot(text, 'subtitle'); + applyValueSegments(text, segment.textSegments, 14, 20, 400); + setData(text, 'tone', segment.tone); + text.style.color = + segment.tone === 'disabled' + ? 'var(--nl-disabled)' + : segment.tone === 'caution' + ? 'var(--nl-caution)' + : toneColor(segment.tone, 'secondary'); + segments.appendChild(text); + }); + column.insertBefore(segments, titleElement.nextSibling); + } + if (presentation === 'walletSidebar' && row.badges?.length) { + const badges = createElement( + document, + 'span', + 'ok-native-list-wallet-badges' + ); + row.badges.forEach((badge) => + badges.appendChild(createBadge(document, badge)) + ); + column.appendChild(badges); + } + for (const key of ['title', 'subtitle'] as const) { + const lines = row.style?.[key]?.lines ?? row[`${key}Lines`]; + if (lines !== undefined) + column + .querySelectorAll(`[data-nl-slot="${key}"]`) + .forEach((text) => applyTextLayout(text, { lines })); + } + } + body.appendChild(column); + appendAccessories(body, document, primitives, row, row.trailing); + return body; +} +const ROW_BOX_STYLE_KEYS: ReadonlySet = new Set([ + 'container', + 'horizontalPadding', + 'verticalPadding', + 'leadingGap', + 'lineGap', + 'titleBadgeGap', + 'trailingGap', + 'image', +]); +function applyIdentityStyle(body: HTMLElement, row: IdentityRow) { + const style = (row as { style?: Record }).style; + if (!style) return; + const box = style as RowBoxStyle; + if (box.horizontalPadding !== undefined) + body.style.paddingInline = String(box.horizontalPadding) + 'px'; + if (box.verticalPadding !== undefined) + body.style.paddingBlock = String(box.verticalPadding) + 'px'; + const vertical = + row.type === 'identity' && row.presentation === 'walletSidebar'; + const leading = body.querySelector( + ':scope > .ok-native-list-visual, :scope > .ok-native-list-stacked' + ); + const defaultGap = + body.style.gap || + (row.type === 'identity' && row.presentation === 'walletSidebar' + ? '4px' + : row.type === 'identity' && row.presentation === 'accountSelector' + ? '8px' + : '12px'); + if (box.lineGap !== undefined) { + const targets = body.querySelectorAll( + ':scope > .ok-native-list-flex' + ); + targets.forEach((column) => { + column.style.rowGap = String(box.lineGap) + 'px'; + }); + } + if (leading && box.leadingGap !== undefined) { + const gap = 'calc(' + String(box.leadingGap) + 'px - ' + defaultGap + ')'; + if (vertical) leading.style.marginBottom = gap; + else leading.style.marginInlineEnd = gap; + } + if (box.titleBadgeGap !== undefined) { + const gap = String(box.titleBadgeGap) + 'px'; + body + .querySelectorAll( + '.ok-native-list-badges, .ok-native-list-wallet-badges' + ) + .forEach((badges) => { + if (badges.classList.contains('ok-native-list-wallet-badges')) + badges.style.marginTop = + box.lineGap === undefined + ? gap + : 'calc(' + gap + ' - ' + String(box.lineGap) + 'px)'; + else badges.style.marginInlineStart = gap; + }); + } + if (box.trailingGap !== undefined) { + const gap = String(box.trailingGap) + 'px'; + body + .querySelectorAll( + '.ok-native-list-accessories, .ok-native-list-amounts' + ) + .forEach((trailing) => { + trailing.style.gap = gap; + }); + } + if (leading && box.image) { + const image = box.image; + if (image.width !== undefined) { + leading.style.width = String(image.width) + 'px'; + if (!vertical) leading.style.flexBasis = String(image.width) + 'px'; + } + if (image.height !== undefined) + leading.style.height = String(image.height) + 'px'; + const radius = + image.cornerRadius !== undefined + ? String(image.cornerRadius) + 'px' + : image.shape === 'circle' + ? '50%' + : image.shape === 'square' + ? '0px' + : image.shape === 'rounded' + ? '10px' + : undefined; + if (radius !== undefined) { + leading.style.setProperty('border-radius', radius, 'important'); + // Clip the bitmap itself; decorations may extend beyond the visual slot. + leading.style.overflow = leading.matches('img') ? 'hidden' : 'visible'; + } + const images = leading.matches('img') + ? [leading] + : leading.querySelectorAll( + ':scope > img, :scope > .ok-native-list-visual-fallback' + ); + images.forEach((bitmap) => { + if (bitmap !== leading) { + if (image.width !== undefined) bitmap.style.width = '100%'; + if (image.height !== undefined) bitmap.style.height = '100%'; + } + if (radius !== undefined) bitmap.style.borderRadius = radius; + if (image.contentFit !== undefined) + bitmap.style.objectFit = + image.contentFit === 'center' ? 'none' : image.contentFit; + }); + } + Object.keys(style).forEach((key) => { + if (ROW_BOX_STYLE_KEYS.has(key)) return; + const slotStyle = style[key] as NativeListTextStyle | undefined; + if (!slotStyle) return; + body + .querySelectorAll('[data-nl-slot="' + key + '"]') + .forEach((element) => { + applyTextStyleToSlot(element, slotStyle); + }); + }); +} +type Views = { + visual?: { + key: string; + node: HTMLElement; + defaults: RowVisualStyle; + }; + primitives: RowPrimitives; +}; +const views = new WeakMap(); +function bind(body: HTMLElement, row: IdentityRow, primitives: RowPrimitives) { + const v = views.get(body)!; + v.primitives = primitives; + let used = false; + const next = createIdentityRow( + body.ownerDocument, + { + ...primitives, + visual: (source, presentation) => { + used = true; + const key = JSON.stringify([row.key, source, presentation]); + if (v.visual?.key !== key) { + if (v.visual) primitives.dispose(v.visual.node); + const node = primitives.visual(source, presentation); + if (!node) { + v.visual = undefined; + return; + } + v.visual = { + key, + node, + defaults: new RowVisualStyle(node), + }; + } + v.visual.defaults.restore(); + return v.visual.node; + }, + }, + row + ); + if (!used && v.visual) { + primitives.dispose(v.visual.node); + v.visual = undefined; + } + body.replaceChildren(...Array.from(next.childNodes)); + body.className = next.className; + body.style.cssText = next.style.cssText; + for (const name of body.getAttributeNames()) + if (name.startsWith('data-') && name !== 'data-nl-renderer') + body.removeAttribute(name); + for (const name of next.getAttributeNames()) + if (name.startsWith('data-')) + body.setAttribute(name, next.getAttribute(name)!); + if (row.type === 'identity' && row.height !== undefined) { + const title = body.querySelector('.ok-native-list-title'); + if (row.presentation === 'accountSelector') { + body.style.gap = '12px'; + body.style.borderRadius = '12px'; + if ('shape' in row.leading && row.leading.shape === 'rounded') { + const visual = body.querySelector( + '.ok-native-list-visual' + ); + if (visual) visual.style.borderRadius = '8px'; + } + if (title) title.style.lineHeight = '24px'; + } + if (row.presentation === 'networkSelector') { + body.style.borderRadius = '12px'; + const visual = body.querySelector('.ok-native-list-visual'); + if (visual) { + visual.style.width = '32px'; + visual.style.height = '32px'; + visual.style.flexBasis = '32px'; + } + if ( + row.leading.kind === 'network' && + !row.leading.image && + !row.leading.fallbackIcon && + row.leading.fallbackText + ) { + const fallback = visual?.querySelector( + '.ok-native-list-visual-fallback' + ); + if (fallback) { + fallback.style.fontSize = '19px'; + fallback.style.lineHeight = '27px'; + fallback.style.fontWeight = '600'; + fallback.style.color = 'var(--nl-inverse-text)'; + } + } + visual + ?.querySelectorAll('.ok-native-list-visual-main') + .forEach((image) => { + image.style.width = '32px'; + image.style.height = '32px'; + }); + if (title) { + title.style.fontSize = '16px'; + title.style.lineHeight = '24px'; + title.style.fontWeight = '500'; + } + body + .querySelectorAll('.ok-native-list-accessory') + .forEach((value) => { + value.style.fontSize = '16px'; + value.style.lineHeight = '24px'; + value.style.fontWeight = '500'; + }); + } + } + + applyIdentityStyle(body, row); +} +function create( + document: Document, + row: IdentityRow, + primitives: RowPrimitives +) { + const body = document.createElement('div'); + body.dataset.nlRenderer = 'identity'; + views.set(body, { primitives }); + bind(body, row, primitives); + return body; +} +function recycle(body: HTMLElement) { + const v = views.get(body); + if (v?.visual) v.primitives.dispose(v.visual.node); + if (v) v.visual = undefined; + body.replaceChildren(); +} +export const identityRowRenderer = { + key: 'identity' as const, + create, + bind, + recycle, + appliesSizePreset: (row: IdentityRow) => !row.presentation, + measure: (row: IdentityRow, _width: number) => + row.presentation === 'walletSidebar' + ? 68 + (row.badges?.length ? 24 : 0) + : row.presentation === 'networkSelector' + ? 47 + : row.presentation === 'accountSelector' + ? 58 + : row.tertiary + ? 72 + : row.subtitle + ? 60 + : 56, + measureRendered: ( + _body: HTMLElement, + _row: IdentityRow + ): number | undefined => undefined, +}; diff --git a/native-views/react-native-native-list/src/web/templates/MarketRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/MarketRowRenderer.ts new file mode 100644 index 000000000..4caabe148 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/MarketRowRenderer.ts @@ -0,0 +1,467 @@ +import type { MarketRow, MarketTextStyle } from '../../models'; +import { createElement, setData, markActionAnchorSource } from './RowElements'; +import { + applyTextStyleToSlot, + applyValueSegments, + fontWeight, +} from './RowText'; +import { RowVisualStyle, type RowPrimitives } from './RowVisual'; +export type WebMarketLayoutStyle = Readonly<{ + horizontalPadding: number; + verticalPadding: number; + leadingGap: number; + titleBadgeGap: number; + trailingGap: number; + imageWidth: number; + imageHeight: number; + imageCornerRadius: number; + changeWidth: number; + changeHeight: number; + changeCornerRadius: number; +}>; +export function resolveWebMarketLayoutStyle( + row: MarketRow +): WebMarketLayoutStyle { + const style = row.style; + const imageWidth = style?.image?.width ?? (row.variant === 'stock' ? 40 : 32); + const imageHeight = + style?.image?.height ?? (row.variant === 'stock' ? 40 : 32); + return { + horizontalPadding: + style?.horizontalPadding ?? (row.variant === 'perp' ? 16 : 20), + verticalPadding: style?.verticalPadding ?? 12, + leadingGap: style?.leadingGap ?? (row.variant === 'perp' ? 8 : 14), + titleBadgeGap: style?.titleBadgeGap ?? 4, + trailingGap: style?.trailingGap ?? 8, + imageWidth, + imageHeight, + imageCornerRadius: + style?.image?.cornerRadius ?? + (style?.image?.shape === 'square' + ? 0 + : style?.image?.shape === 'rounded' + ? 8 + : Math.min(imageWidth, imageHeight) / 2), + changeWidth: style?.changeWidth ?? 80, + changeHeight: style?.changeHeight ?? 32, + changeCornerRadius: style?.changeCornerRadius ?? 8, + }; +} +function applyMarketTextStyle( + element: HTMLElement, + style: MarketTextStyle | undefined, + defaults: Readonly<{ + fontSize: number; + lineHeight: number; + weight: number; + alignment: 'start' | 'center' | 'end'; + }> +) { + element.style.fontSize = String(style?.fontSize ?? defaults.fontSize) + 'px'; + element.style.lineHeight = + String(style?.lineHeight ?? defaults.lineHeight) + 'px'; + element.style.fontWeight = String( + fontWeight(style?.fontWeight, defaults.weight) + ); + element.style.color = style?.color ?? ''; + element.style.textAlign = style?.alignment ?? defaults.alignment; + element.style.whiteSpace = (style?.lines ?? 1) > 1 ? 'normal' : 'nowrap'; + element.style.display = ''; + element.style.removeProperty('-webkit-line-clamp'); + element.style.removeProperty('-webkit-box-orient'); + if ((style?.lines ?? 1) > 1) { + element.style.display = '-webkit-box'; + element.style.setProperty('-webkit-line-clamp', String(style?.lines ?? 2)); + element.style.setProperty('-webkit-box-orient', 'vertical'); + } +} +export function setMarketQuoteContent(element: HTMLElement, row: MarketRow) { + const style = row.style; + const layoutStyle = resolveWebMarketLayoutStyle(row); + const price = element.querySelector( + '.ok-native-list-market-price' + ); + if (price) { + price.textContent = row.price; + applyMarketTextStyle(price, style?.price, { + fontSize: 16, + lineHeight: 24, + weight: 500, + alignment: 'end', + }); + applyValueSegments( + price, + row.priceSegments, + style?.price?.fontSize ?? 16, + style?.price?.lineHeight ?? 24, + fontWeight(style?.price?.fontWeight, 500) + ); + } + const change = element.querySelector( + '.ok-native-list-market-change' + ); + if (change) { + change.textContent = row.change.text; + setData(change, 'tone', row.change.tone); + applyMarketTextStyle(change, style?.change, { + fontSize: 14, + lineHeight: 20, + weight: 500, + alignment: 'center', + }); + applyValueSegments( + change, + row.change.textSegments, + style?.change?.fontSize ?? 14, + style?.change?.lineHeight ?? 20, + fontWeight(style?.change?.fontWeight, 500) + ); + change.style.width = String(layoutStyle.changeWidth) + 'px'; + change.style.height = String(layoutStyle.changeHeight) + 'px'; + change.style.borderRadius = String(layoutStyle.changeCornerRadius) + 'px'; + change.style.color = style?.change?.color ?? row.change.textColor ?? ''; + change.style.background = row.change.backgroundColor ?? ''; + } + if (price) applyTextStyleToSlot(price, style?.price ?? {}, 1); + if (change) applyTextStyleToSlot(change, style?.change ?? {}, 1); + element.setAttribute( + 'aria-label', + row.accessibilityLabel ?? + [row.title, row.subtitle, row.price, row.change.text] + .filter(Boolean) + .join(', ') + ); +} +function createMarketRow( + document: Document, + primitives: RowPrimitives, + row: MarketRow +): HTMLElement { + const body = createElement( + document, + 'div', + 'ok-native-list-row ok-native-list-market' + ); + setData(body, 'variant', row.variant); + const style = row.style; + const layoutStyle = resolveWebMarketLayoutStyle(row); + body.style.padding = + String(layoutStyle.verticalPadding) + + 'px ' + + String(layoutStyle.horizontalPadding) + + 'px'; + body.style.gap = '0px'; + if (row.leadingAction) { + const action = primitives.iconAction( + row.leadingAction.name, + row.leadingAction.actionKey, + row.leadingAction.disabled, + row.leadingAction.tintColor + ); + action.style.flex = '0 0 36px'; + action.style.width = '36px'; + action.style.height = '36px'; + action.style.marginRight = '5px'; + setData(action, 'testid', row.leadingAction.testID); + if (row.leadingAction.accessibilityLabel) + action.setAttribute('aria-label', row.leadingAction.accessibilityLabel); + markActionAnchorSource(action, 'leadingAction'); + body.appendChild(action); + } + const visual = primitives.visual(row.leading); + if (visual) { + const width = layoutStyle.imageWidth; + const height = layoutStyle.imageHeight; + visual.style.width = String(width) + 'px'; + visual.style.height = String(height) + 'px'; + visual.style.flexBasis = String(width) + 'px'; + visual.style.borderRadius = String(layoutStyle.imageCornerRadius) + 'px'; + const borderColor = + 'borderColor' in row.leading ? row.leading.borderColor : undefined; + if (borderColor) { + visual.style.border = '1px solid ' + borderColor; + visual.style.boxSizing = 'border-box'; + } + visual.querySelectorAll('img').forEach((image) => { + if (!image.classList.contains('ok-native-list-visual-corner')) { + image.style.width = '100%'; + image.style.height = '100%'; + if (borderColor) { + image.style.borderRadius = '0'; + image.style.clipPath = + 'inset(-1px round ' + String(layoutStyle.imageCornerRadius) + 'px)'; + } + image.style.objectFit = + style?.image?.contentFit === 'center' + ? 'none' + : style?.image?.contentFit ?? image.style.objectFit; + } else { + image.style.width = '20px'; + image.style.height = '20px'; + if (borderColor) { + image.style.right = '-5px'; + image.style.bottom = '-5px'; + } + } + }); + visual.style.marginInlineEnd = String(layoutStyle.leadingGap) + 'px'; + body.appendChild(visual); + } + const main = createElement(document, 'span', 'ok-native-list-market-main'); + main.style.gap = String(style?.lineGap ?? 0) + 'px'; + const titleLine = createElement( + document, + 'span', + 'ok-native-list-market-title-line' + ); + titleLine.style.gap = String(layoutStyle.titleBadgeGap) + 'px'; + const title = createElement( + document, + 'span', + 'ok-native-list-market-title', + row.title + ); + applyMarketTextStyle(title, style?.title, { + fontSize: 16, + lineHeight: 24, + weight: 500, + alignment: 'start', + }); + titleLine.appendChild(title); + row.badges?.forEach((badge, slot) => { + const element = createElement( + document, + badge.actionKey ? 'button' : 'span', + 'ok-native-list-market-badge', + badge.text + ); + if (badge.actionKey) { + element.setAttribute('type', 'button'); + setData(element, 'nativeListAction', badge.actionKey); + markActionAnchorSource(element, 'marketBadge', slot); + } + setData(element, 'tone', badge.tone); + element.style.color = + badge.textColor ?? + (badge.tone === 'success' + ? 'var(--nl-positive)' + : badge.tone === 'danger' + ? 'var(--nl-negative)' + : badge.tone === 'info' + ? 'var(--nl-info)' + : badge.tone === 'warning' + ? 'var(--nl-primary)' + : ''); + element.style.background = + badge.backgroundColor ?? + ((badge.icon || badge.iconName) && !badge.text ? 'transparent' : ''); + if ((badge.icon || badge.iconName) && !badge.text) { + element.style.padding = '0'; + } + if (badge.style) { + applyTextStyleToSlot(element, badge.style); + if (badge.style.height !== undefined) + element.style.height = `${badge.style.height}px`; + if (badge.style.horizontalPadding !== undefined && badge.text) + element.style.paddingInline = `${badge.style.horizontalPadding}px`; + } + if (badge.accessibilityLabel) + element.setAttribute('aria-label', badge.accessibilityLabel); + if (badge.icon) { + const icon = primitives.thumbnail(badge.icon); + if (icon) { + icon.className = ''; + icon.style.borderRadius = '50%'; + element.prepend(icon); + } + } + if (badge.iconName === 'verified') { + const icon = createElement( + document, + 'span', + 'ok-native-list-market-badge-icon' + ); + primitives.icon(icon, 'BadgeVerifiedSolid'); + element.prepend(icon); + } + titleLine.appendChild(element); + }); + main.appendChild(titleLine); + if (row.subtitlePrefix || row.subtitle || row.subtitleSegments?.length) { + const subtitleLine = createElement( + document, + 'span', + 'ok-native-list-market-subtitle-line' + ); + subtitleLine.style.display = 'flex'; + subtitleLine.style.alignItems = 'center'; + subtitleLine.style.minWidth = '0'; + subtitleLine.style.overflow = 'hidden'; + subtitleLine.style.gap = + String(row.subtitlePrefix ? row.subtitlePrefix.gap ?? 4 : 0) + 'px'; + if (row.subtitlePrefix) { + const prefix = createElement( + document, + 'span', + 'ok-native-list-market-subtitle-prefix', + row.subtitlePrefix.text + ); + applyMarketTextStyle(prefix, row.subtitlePrefix.style, { + fontSize: 12, + lineHeight: 16, + weight: 400, + alignment: 'start', + }); + prefix.style.display = 'block'; + prefix.style.flex = '1 1 auto'; + prefix.style.minWidth = '0'; + prefix.style.overflow = 'hidden'; + prefix.style.textOverflow = 'ellipsis'; + prefix.style.color = + row.subtitlePrefix.style?.color ?? 'var(--nl-secondary)'; + if (row.subtitlePrefix.maxWidth !== undefined) { + prefix.style.maxWidth = String(row.subtitlePrefix.maxWidth) + 'px'; + } + subtitleLine.appendChild(prefix); + } + if (row.subtitle || row.subtitleSegments?.length) { + const subtitle = createElement( + document, + 'span', + 'ok-native-list-market-subtitle', + row.subtitle + ); + applyMarketTextStyle(subtitle, style?.subtitle, { + fontSize: 14, + lineHeight: 20, + weight: 400, + alignment: 'start', + }); + applyValueSegments( + subtitle, + row.subtitleSegments, + style?.subtitle?.fontSize ?? 14, + style?.subtitle?.lineHeight ?? 20, + fontWeight(style?.subtitle?.fontWeight, 400) + ); + subtitle.style.flex = row.subtitlePrefix ? '0 0 auto' : '1 1 auto'; + subtitleLine.appendChild(subtitle); + } + main.appendChild(subtitleLine); + } + body.appendChild(main); + const trailing = createElement( + document, + 'span', + 'ok-native-list-market-trailing' + ); + trailing.style.gap = String(layoutStyle.trailingGap) + 'px'; + trailing.append( + createElement(document, 'span', 'ok-native-list-market-price'), + createElement(document, 'span', 'ok-native-list-market-change') + ); + body.appendChild(trailing); + setMarketQuoteContent(body, row); + for (const key of ['title', 'subtitle'] as const) { + const text = body.querySelector( + `.ok-native-list-market-${key}` + ); + if (text) applyTextStyleToSlot(text, style?.[key] ?? {}, 1); + } + const prefix = body.querySelector( + '.ok-native-list-market-subtitle-prefix' + ); + if (prefix) applyTextStyleToSlot(prefix, row.subtitlePrefix?.style ?? {}, 1); + return body; +} +// A quote payload changes only the quote nodes; full binds retain equal image slots. +type Asset = { + key: string; + node: HTMLElement; + defaults: RowVisualStyle; +}; +type Views = { assets: Asset[]; content: string; primitives: RowPrimitives }; +const views = new WeakMap(); +function bind(body: HTMLElement, row: MarketRow, primitives: RowPrimitives) { + const v = views.get(body)!; + v.primitives = primitives; + const content = JSON.stringify({ + ...row, + price: undefined, + priceSegments: undefined, + change: undefined, + accessibilityLabel: undefined, + }); + if (v.content === content) { + setMarketQuoteContent(body, row); + return; + } + let index = 0; + function asset(source: unknown, createAsset: () => HTMLElement | undefined) { + const slot = index++, + key = JSON.stringify([row.key, source]); + let entry = v.assets[slot]; + if (entry?.key !== key) { + if (entry) primitives.dispose(entry.node); + const node = createAsset(); + if (!node) return; + entry = { + key, + node, + defaults: new RowVisualStyle(node), + }; + v.assets[slot] = entry; + } + entry.defaults.restore(); + return entry.node; + } + const next = createMarketRow( + body.ownerDocument, + { + ...primitives, + visual: (source) => asset(source, () => primitives.visual(source)), + thumbnail: (source) => asset(source, () => primitives.thumbnail(source)), + }, + row + ); + v.assets.slice(index).forEach((slot) => primitives.dispose(slot.node)); + v.assets.length = index; + body.replaceChildren(...Array.from(next.childNodes)); + body.className = next.className; + body.style.cssText = next.style.cssText; + body.dataset.variant = row.variant; + body.removeAttribute('data-nl-container-background'); + body.removeAttribute('data-nl-container-border'); + body.setAttribute('aria-label', next.getAttribute('aria-label') ?? ''); + v.content = content; +} +function create(document: Document, row: MarketRow, primitives: RowPrimitives) { + const body = document.createElement('div'); + body.dataset.nlRenderer = 'market'; + views.set(body, { assets: [], content: '', primitives }); + bind(body, row, primitives); + return body; +} +function recycle(body: HTMLElement) { + const v = views.get(body); + if (!v) return; + v.assets.forEach((slot) => v.primitives.dispose(slot.node)); + v.assets = []; + v.content = ''; + body.replaceChildren(); +} +export const marketRowRenderer = { + key: 'market' as const, + create, + bind, + recycle, + measure: (row: MarketRow, _width: number) => { + const style = resolveWebMarketLayoutStyle(row); + return Math.max( + row.variant === 'stock' ? 72 : 68, + style.imageHeight + 2 * style.verticalPadding + ); + }, + measureRendered: (_body: HTMLElement, _row: MarketRow): number | undefined => + undefined, +}; diff --git a/native-views/react-native-native-list/src/web/templates/RowElements.ts b/native-views/react-native-native-list/src/web/templates/RowElements.ts index d744e6134..43470f98e 100644 --- a/native-views/react-native-native-list/src/web/templates/RowElements.ts +++ b/native-views/react-native-native-list/src/web/templates/RowElements.ts @@ -33,3 +33,68 @@ export function markActionAnchorSource( setData(element, 'nativeListAnchorSource', source); if (slot !== undefined) setData(element, 'nativeListAnchorSlot', slot); } + +export function createTextColumn( + document: Document, + title: string, + subtitle?: string, + tertiary?: string, + tertiaryTone?: 'secondary' | 'info', + badges?: readonly Readonly<{ text: string; tone?: string }>[], + slots: Readonly<{ title: string; subtitle: string; tertiary: string }> = { + title: 'title', + subtitle: 'subtitle', + tertiary: 'tertiary', + } +): HTMLElement { + const column = createElement(document, 'span', 'ok-native-list-flex'); + const titleLine = tagSlot( + createElement(document, 'span', 'ok-native-list-title', title), + slots.title + ); + if (badges?.length) { + titleLine.removeAttribute('data-nl-slot'); + titleLine.replaceChildren( + tagSlot(createElement(document, 'span', undefined, title), slots.title) + ); + const badgeLine = createElement(document, 'span', 'ok-native-list-badges'); + badges.forEach((badge) => + badgeLine.appendChild(createBadge(document, badge)) + ); + titleLine.appendChild(badgeLine); + } + column.appendChild(titleLine); + if (subtitle) + column.appendChild( + tagSlot( + createElement(document, 'span', 'ok-native-list-secondary', subtitle), + slots.subtitle + ) + ); + if (tertiary) { + const element = tagSlot( + createElement( + document, + 'span', + tertiaryTone === 'info' + ? 'ok-native-list-secondary ok-native-list-info' + : 'ok-native-list-secondary ok-native-list-tertiary', + tertiary + ), + slots.tertiary + ); + column.appendChild(element); + } + return column; +} +export function createBadge( + document: Document, + badge: Readonly<{ text: string; tone?: string }> +): HTMLElement { + const element = tagSlot( + createElement(document, 'span', 'ok-native-list-badge', badge.text), + 'badge' + ); + setData(element, 'tone', badge.tone); + return element; +} diff --git a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts index 22f5e71ea..73bcb8468 100644 --- a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts +++ b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts @@ -1,3 +1,8 @@ +import type { IdentityRow, SectionHeaderRow } from '../../models'; +import { identityRowRenderer } from './IdentityRowRenderer'; +import { sectionHeaderRowRenderer } from './SectionHeaderRowRenderer'; +import type { MarketRow } from '../../models'; +import { marketRowRenderer } from './MarketRowRenderer'; import type { MetricCardRow } from '../../models'; import { metricCardRowRenderer } from './MetricCardRowRenderer'; import type { DataRow } from '../../models'; @@ -23,7 +28,7 @@ type Renderer = { primitives: RowPrimitives ) => HTMLElement; bind: (body: HTMLElement, row: R, primitives: RowPrimitives) => void; - measure: (row: R, width: number) => number; + measure: (row: R, width: number, layout?: string) => number; measureRendered: (body: HTMLElement, row: R) => number | undefined; recycle: (body: HTMLElement) => void; }; @@ -35,11 +40,17 @@ function registration(renderer: Renderer, row: R) { renderer.create(document, row, primitives), bind: (body: HTMLElement, primitives: RowPrimitives) => renderer.bind(body, row, primitives), - measure: (width: number) => renderer.measure(row, width), + measure: (width: number, layout?: string) => + renderer.measure(row, width, layout), measureRendered: (body: HTMLElement) => renderer.measureRendered(body, row), }; } const factories = { + identity: (row: RowModel) => + registration(identityRowRenderer, row as IdentityRow), + sectionHeader: (row: RowModel) => + registration(sectionHeaderRowRenderer, row as SectionHeaderRow), + market: (row: RowModel) => registration(marketRowRenderer, row as MarketRow), metricCard: (row: RowModel) => registration(metricCardRowRenderer, row as MetricCardRow), dataRow: (row: RowModel) => registration(dataRowRenderer, row as DataRow), @@ -54,6 +65,9 @@ const factories = { registration(mediaTileRowRenderer, row as MediaTileRow), }; const renderers = { + identity: identityRowRenderer, + sectionHeader: sectionHeaderRowRenderer, + market: marketRowRenderer, metricCard: metricCardRowRenderer, dataRow: dataRowRenderer, activity: activityRowRenderer, diff --git a/native-views/react-native-native-list/src/web/templates/RowText.ts b/native-views/react-native-native-list/src/web/templates/RowText.ts new file mode 100644 index 000000000..0ead9942b --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/RowText.ts @@ -0,0 +1,177 @@ +import type { NativeListTextStyle } from '../../models'; +import { createElement } from './RowElements'; +export function applyValueSegments( + element: HTMLElement, + segments: + | readonly Readonly<{ text: string; style?: 'subscript' }>[] + | undefined, + fontSize = 16, + lineHeight = 24, + weight = 500 +) { + if (!segments?.length) return; + element.textContent = ''; + element.style.fontSize = String(fontSize) + 'px'; + element.style.lineHeight = String(lineHeight) + 'px'; + element.style.fontWeight = String(weight); + segments.forEach((segment) => { + const span = createElement( + element.ownerDocument, + 'span', + undefined, + segment.text + ); + if (segment.style === 'subscript') { + span.style.fontSize = String(Math.ceil(fontSize * 0.6)) + 'px'; + span.style.lineHeight = String(fontSize) + 'px'; + } + element.appendChild(span); + }); +} +export function applyTextLayout( + element: HTMLElement, + style: NativeListTextStyle, + defaultLines?: number +): void { + if ( + style.lines === undefined && + style.truncate === undefined && + style.verticalAlignment === undefined && + style.offsetY === undefined && + defaultLines === undefined + ) + return; + let content = element.querySelector( + ':scope > .ok-native-list-text-content' + ); + const computed = element.ownerDocument.defaultView?.getComputedStyle(element); + const inheritedClamp = Number( + element.style.getPropertyValue('-webkit-line-clamp') || + computed?.getPropertyValue('-webkit-line-clamp') + ); + const lines = + style.lines ?? + defaultLines ?? + (Number( + element.style.getPropertyValue('-webkit-line-clamp') || + computed?.getPropertyValue('-webkit-line-clamp') + ) || + 1); + if ( + !content && + (style.verticalAlignment !== undefined || + style.offsetY !== undefined || + element.classList.contains('ok-native-list-market-change')) + ) { + content = element.ownerDocument.createElement('span'); + content.className = 'ok-native-list-text-content'; + content.append(...Array.from(element.childNodes)); + element.appendChild(content); + element.style.display = 'flex'; + element.style.flexDirection = 'column'; + element.style.removeProperty('-webkit-line-clamp'); + element.style.removeProperty('-webkit-box-orient'); + content.style.minWidth = '0'; + content.style.flex = '0 1 auto'; + content.style.width = '100%'; + } + const text = content ?? element; + if (style.verticalAlignment !== undefined) { + element.style.justifyContent = { + top: 'flex-start', + center: 'center', + bottom: 'flex-end', + }[style.verticalAlignment]; + } + if (style.offsetY !== undefined) + text.style.transform = `translateY(${style.offsetY}px)`; + if ( + style.lines === undefined && + style.truncate === undefined && + defaultLines === undefined && + !inheritedClamp + ) { + if (content) { + content.style.whiteSpace = computed?.whiteSpace || 'inherit'; + content.style.textOverflow = computed?.textOverflow || 'inherit'; + content.style.overflow = 'hidden'; + } + return; + } + if (lines === 1) { + const walker = element.ownerDocument.createTreeWalker( + text, + 4 /* SHOW_TEXT */ + ); + let previousCarriageReturn = false; + while (walker.nextNode()) { + const value = walker.currentNode.nodeValue ?? ''; + if (!value) continue; + // A CRLF pair may straddle two styled runs. + const textValue = + previousCarriageReturn && value.startsWith('\n') + ? value.slice(1) + : value; + walker.currentNode.nodeValue = textValue.replace(/\r\n|[\r\n]/g, ' '); + previousCarriageReturn = value.endsWith('\r'); + } + } + text.style.whiteSpace = lines === 1 ? 'nowrap' : 'pre-wrap'; + text.style.overflow = 'hidden'; + text.style.textOverflow = style.truncate === 'clip' ? 'clip' : 'ellipsis'; + text.style.removeProperty('-webkit-line-clamp'); + text.style.removeProperty('-webkit-box-orient'); + text.style.maxHeight = ''; + if (lines > 1 && style.truncate !== 'clip') { + text.style.display = '-webkit-box'; + text.style.setProperty('-webkit-line-clamp', String(lines)); + text.style.setProperty('-webkit-box-orient', 'vertical'); + } else { + text.style.display = 'block'; + if (lines > 1) { + // Rows are styled before mounting, when computed typography is unavailable. + // Resolve omitted line height in CSS after the template rules take effect. + text.style.maxHeight = + style.lineHeight !== undefined + ? `${lines * style.lineHeight}px` + : `${lines}lh`; + } + } +} +export function applyTextStyleToSlot( + element: HTMLElement, + style: NativeListTextStyle, + defaultLines?: number +): void { + if (style.fontSize !== undefined) + element.style.fontSize = String(style.fontSize) + 'px'; + if (style.lineHeight !== undefined) + element.style.lineHeight = String(style.lineHeight) + 'px'; + if (style.fontWeight !== undefined) + element.style.fontWeight = String(fontWeight(style.fontWeight, 400)); + if (style.color !== undefined) element.style.color = style.color; + // Explicit overrides also apply to rich text runs, but never to sibling badges. + element.querySelectorAll('span').forEach((run) => { + if (style.color !== undefined) run.style.color = style.color; + if (style.fontSize !== undefined) + run.style.fontSize = String(style.fontSize) + 'px'; + if (style.fontWeight !== undefined) + run.style.fontWeight = String(fontWeight(style.fontWeight, 400)); + if (style.lineHeight !== undefined) + run.style.lineHeight = String(style.lineHeight) + 'px'; + if (style.lines !== undefined || style.truncate !== undefined) + run.style.whiteSpace = 'inherit'; + }); + if (style.alignment !== undefined) element.style.textAlign = style.alignment; + applyTextLayout(element, style, defaultLines); +} +export function fontWeight( + value: NativeListTextStyle['fontWeight'] | undefined, + fallback: number +): number { + if (value === 'bold') return 700; + if (value === 'semibold') return 600; + if (value === 'medium') return 500; + if (value === 'regular') return 400; + return fallback; +} diff --git a/native-views/react-native-native-list/src/web/templates/RowVisual.ts b/native-views/react-native-native-list/src/web/templates/RowVisual.ts index 427f8d0c5..098846630 100644 --- a/native-views/react-native-native-list/src/web/templates/RowVisual.ts +++ b/native-views/react-native-native-list/src/web/templates/RowVisual.ts @@ -8,12 +8,22 @@ import type { export type RowPrimitives = Readonly<{ background: string; + icon: (element: HTMLElement, name: string) => void; + iconAction: ( + name: string, + actionKey: string | undefined, + disabled?: boolean, + tintColor?: string + ) => HTMLElement; accessory: ( rowKey: string, descriptor: TrailingAccessory, slot: number ) => HTMLElement; - visual: (source: LeadingVisual | undefined) => HTMLElement | undefined; + visual: ( + source: LeadingVisual | undefined, + presentation?: string + ) => HTMLElement | undefined; thumbnail: (source: ImageSource) => HTMLElement | undefined; textStyle: ( element: HTMLElement, @@ -32,6 +42,10 @@ const properties = [ 'object-fit', 'margin-inline-end', 'align-self', + 'border', + 'box-sizing', + 'clip-path', + 'flex', ]; /** Restores factory geometry without touching async loading visibility. */ @@ -47,18 +61,21 @@ export class RowVisualStyle { ); }); } - apply( - image: NativeListImageStyle | undefined, - width: number, - height: number, - margin: number - ) { + restore() { this.defaults.forEach((style, element) => style.forEach((value, key) => { element.style.removeProperty(key); if (value) element.style.setProperty(key, value); }) ); + } + apply( + image: NativeListImageStyle | undefined, + width: number, + height: number, + margin: number + ) { + this.restore(); const leading = this.view; leading.style.width = `${width}px`; leading.style.height = `${height}px`; diff --git a/native-views/react-native-native-list/src/web/templates/SectionHeaderRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/SectionHeaderRowRenderer.ts new file mode 100644 index 000000000..e74195581 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/SectionHeaderRowRenderer.ts @@ -0,0 +1,243 @@ +import type { SectionHeaderRow } from '../../models'; +import { + createElement, + setData, + tagSlot, + markActionAnchorSource, + createTextColumn, +} from './RowElements'; +import { applyValueSegments } from './RowText'; +import type { RowPrimitives } from './RowVisual'; +function createSectionHeader( + document: Document, + primitives: RowPrimitives, + row: SectionHeaderRow +): HTMLElement { + const body = createElement( + document, + 'div', + 'ok-native-list-row ok-native-list-section' + ); + setData(body, 'variant', row.variant); + if (row.titleIcon) { + const titleIcon = primitives.iconAction( + row.titleIcon.name, + row.titleIcon.actionKey, + row.titleIcon.disabled, + row.titleIcon.tintColor + ); + markActionAnchorSource(titleIcon, 'leadingAction'); + body.appendChild(titleIcon); + } + // OneKey patch: section title help has its own measurable action target. + // body.appendChild(createTextColumn(document, row.title, row.subtitle)); + const column = createTextColumn(document, row.title, row.subtitle); + const title = column.firstElementChild as HTMLElement; + title.classList.add('ok-native-list-section-title'); + if (row.titleActionKey) { + setData(title, 'nativeListAction', row.titleActionKey); + markActionAnchorSource(title, 'leadingAction'); + title.setAttribute('role', 'button'); + title.tabIndex = 0; + title.style.alignSelf = 'flex-start'; + title.style.maxWidth = '100%'; + // OneKey patch: explicit network headers reserve a separate 3-point underline area. + if (row.presentation !== 'networkSelector' || row.height === undefined) { + title.style.textDecoration = 'underline dotted'; + title.style.textUnderlineOffset = '6px'; + } + if (row.titleActionOnHover) setData(title, 'nativeListHoverAction', true); + } + if (row.presentation === 'networkSelector' && row.height !== undefined) { + body.style.padding = + row.variant === 'summary' ? '24px 12px 20px' : '0 12px'; + body.style.backgroundColor = 'var(--nl-bg)'; + body.style.gap = row.checkbox ? '12px' : '8px'; + title.style.fontSize = row.variant === 'summary' ? '16px' : '14px'; + title.style.lineHeight = row.variant === 'summary' ? '24px' : '20px'; + title.style.fontWeight = + row.variant === 'summary' || (row.titleActionKey && !row.checkbox) + ? '500' + : '600'; + if (row.titleActionKey) { + const text = createElement( + document, + 'span', + 'ok-native-list-section-title-text', + row.title + ); + text.style.overflow = 'hidden'; + text.style.textOverflow = 'ellipsis'; + text.style.maxWidth = '100%'; + const dotted = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'svg' + ); + dotted.setAttribute('height', '2'); + dotted.style.cssText = + 'display:block;position:absolute;left:0;bottom:0;width:100%;height:2px;color:var(--nl-secondary)'; + const line = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'line' + ); + for (const [key, value] of Object.entries({ + x1: '1', + y1: '1', + x2: '100%', + y2: '1', + stroke: 'currentColor', + 'stroke-width': '1.5', + 'stroke-dasharray': '0,4', + 'stroke-linecap': 'round', + })) + line.setAttribute(key, value); + // OneKey patch: keep both round caps inside the original full-width viewport. + const lineViewport = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'svg' + ); + lineViewport.setAttribute('width', 'calc(100% - 1px)'); + lineViewport.setAttribute('height', '2'); + lineViewport.setAttribute('overflow', 'visible'); + lineViewport.appendChild(line); + dotted.appendChild(lineViewport); + // OneKey patch: SVG intrinsic width must not expand the title action beyond its text. + title.style.display = 'block'; + title.style.position = 'relative'; + title.style.width = 'fit-content'; + title.style.paddingBottom = '3px'; + text.style.display = 'block'; + title.replaceChildren(text, dotted); + } + } + body.appendChild(column); + if (row.value) { + const value = tagSlot( + createElement( + document, + row.valueActionKey ? 'button' : 'span', + row.valueActionKey + ? 'ok-native-list-action-button ok-native-list-section-value' + : 'ok-native-list-value ok-native-list-section-value', + row.value + ), + 'value' + ); + applyValueSegments(value, row.valueSegments); + if (row.presentation === 'networkSelector' && row.height !== undefined) { + value.style.fontFamily = 'inherit'; + value.style.fontSize = '16px'; + value.style.lineHeight = '24px'; + value.style.fontWeight = '500'; + if (row.valueActionKey) { + value.style.color = 'var(--nl-secondary)'; + value.style.padding = '0'; + value.style.flexShrink = '0'; + } + } + if (row.valueActionTestID) setData(value, 'testid', row.valueActionTestID); + if (row.valueActionKey) { + value.setAttribute('type', 'button'); + setData(value, 'nativeListAction', row.valueActionKey); + markActionAnchorSource(value, 'trailingAccessory', 0); + } + body.appendChild(value); + } + if (row.valueIcon) { + const valueIcon = primitives.iconAction( + row.valueIcon.name, + row.valueIcon.actionKey, + row.valueIcon.disabled, + row.valueIcon.tintColor + ); + markActionAnchorSource(valueIcon, 'trailingAccessory', 1); + body.appendChild(valueIcon); + } + if (row.checkbox) + body.appendChild(primitives.accessory(row.key, row.checkbox, 0)); + return body; +} +function bind( + body: HTMLElement, + row: SectionHeaderRow, + primitives: RowPrimitives +) { + const next = createSectionHeader(body.ownerDocument, primitives, row); + body.replaceChildren(...Array.from(next.childNodes)); + body.className = next.className; + body.style.cssText = next.style.cssText; + body.dataset.variant = row.variant ?? ''; + body.removeAttribute('data-nl-container-background'); + body.removeAttribute('data-nl-container-border'); + const style = row.style; + if (!style) return; + if (style.horizontalPadding !== undefined) + body.style.paddingInline = style.horizontalPadding + 'px'; + if (style.verticalPadding !== undefined) + body.style.paddingBlock = style.verticalPadding + 'px'; + if (style.lineGap !== undefined) + body + .querySelectorAll(':scope > .ok-native-list-flex') + .forEach((column) => { + column.style.rowGap = style.lineGap + 'px'; + }); + if (style.trailingGap !== undefined) { + const children = Array.from(body.children) as HTMLElement[]; + const column = children.findIndex((child) => + child.classList.contains('ok-native-list-flex') + ); + children.slice(column + 2).forEach((child) => { + child.style.marginInlineStart = + 'calc(' + + style.trailingGap + + 'px - ' + + (body.style.gap || '12px') + + ')'; + }); + } + for (const slot of ['title', 'subtitle', 'value'] as const) + if (style[slot]) + body + .querySelectorAll('[data-nl-slot="' + slot + '"]') + .forEach((node) => primitives.textStyle(node, style[slot]!)); +} +function create( + document: Document, + row: SectionHeaderRow, + primitives: RowPrimitives +) { + const body = document.createElement('div'); + body.dataset.nlRenderer = 'sectionHeader'; + bind(body, row, primitives); + return body; +} +export const sectionHeaderRowRenderer = { + key: 'sectionHeader' as const, + create, + bind, + recycle: (body: HTMLElement) => body.replaceChildren(), + appliesSizePreset: (row: SectionHeaderRow) => + !['summary', 'gallery'].includes(row.variant ?? ''), + measure: (row: SectionHeaderRow, _width: number, layout = 'linear') => + layout === 'table' + ? 28 + : row.presentation === 'networkSelector' + ? 47 + : row.variant === 'history' || + row.key.startsWith('history-') || + row.sectionKey.startsWith('history-') + ? 16 + : row.variant === 'summary' + ? 68 + : row.variant === 'gallery' + ? 32 + : row.checkbox + ? 56 + : layout === 'linear' + ? 30 + : 36, + measureRendered: ( + _body: HTMLElement, + _row: SectionHeaderRow + ): number | undefined => undefined, +}; From dfcd79ae53b1dc6d285a69a56346ff46c0bf7c14 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 21:48:35 +0800 Subject: [PATCH 49/71] refactor(native-list): register complex Android row hosts --- .../nativelist/NativeListAccessoryStack.kt | 54 +- .../nativelist/NativeListIdentityRenderer.kt | 805 ++++++++++++++ .../nativelist/NativeListLeadingVisual.kt | 32 +- .../nativelist/NativeListMarketRenderer.kt | 997 ++++++++++++++++++ .../com/onekey/nativelist/NativeListModels.kt | 2 +- .../nativelist/NativeListRendererRegistry.kt | 46 +- .../nativelist/NativeListRendererRowView.kt | 30 +- .../onekey/nativelist/NativeListRowView.kt | 6 +- .../NativeListSectionHeaderRenderer.kt | 741 +++++++++++++ .../com/onekey/nativelist/NativeListView.kt | 2 +- 10 files changed, 2678 insertions(+), 37 deletions(-) create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMarketRenderer.kt create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt index a1c1d49a1..376ae057d 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt @@ -41,6 +41,15 @@ internal class NativeListAccessoryStack(context: android.content.Context) : Line private var checkboxIconColor = Color.WHITE private var checkboxBorderColor = Color.LTGRAY private var iconSubduedColor = Color.GRAY + val firstVisibleIcon: View? + get() = trailingIcons.firstOrNull { it.visibility == VISIBLE } + + val hasCheckbox: Boolean + get() = checkbox.visibility == VISIBLE + + val visibleValues: List + get() = trailingViews.filter { it.visibility == VISIBLE } + var endInset: Int? = null var onAction: ((String, View, Int?, NativeSelectionTarget?) -> Unit)? = null private val selectorAccessibilityDelegate = @@ -70,6 +79,7 @@ internal class NativeListAccessoryStack(context: android.content.Context) : Line style: JSONObject, sourceScale: Boolean, checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + horizontal: Boolean = false, ) { selectorUsesSourceScale = sourceScale reset() @@ -79,18 +89,35 @@ internal class NativeListAccessoryStack(context: android.content.Context) : Line checkboxIconColor = checkboxUncheckedColor checkboxBorderColor = Color.argb(0x31, 0, 0, 0) iconSubduedColor = color(theme, "iconSubdued", "#00000072") + checkboxUsesSelectorStyle = item.json.optString("presentation") == "networkSelector" + if (checkboxUsesSelectorStyle) { + checkboxCheckedColor = color(theme, "checkboxBackground", "#202020") + checkboxBorderColor = color(theme, "checkboxBorder", "#00000031") + checkboxIconColor = color(theme, "checkboxIcon", "#FFFFFF") + checkboxUncheckedColor = checkboxIconColor + } + val kinds = + (0 until descriptors.length()).map { descriptors.getJSONObject(it).optString("kind") } + orientation = if (horizontal) HORIZONTAL else VERTICAL + gravity = Gravity.END or Gravity.CENTER_VERTICAL trailingViews.forEach { it.setTextColor(checkboxCheckedColor) } bindAccessories(item, descriptors, theme, checkboxState) - style.optJSONObject("value")?.let { value -> - semanticValueViews.firstOrNull()?.let { applyStyledText(it, value) } + if (horizontal && "checkbox" in kinds) + (checkbox.layoutParams as LayoutParams).marginStart = dp(12) + for ((index, key) in listOf("value", "valueSecondary").withIndex()) { + style.optJSONObject(key)?.let { value -> + semanticValueViews.getOrNull(index)?.let { applyStyledText(it, value) } + } } if (style.has("trailingGap")) { var previous = false for (i in 0 until childCount) { val v = getChildAt(i) if (v.visibility != GONE) { - (v.layoutParams as LayoutParams).topMargin = - if (previous) styleDp(style.optDouble("trailingGap")) else 0 + val params = v.layoutParams as LayoutParams + if (orientation == HORIZONTAL) + params.marginStart = if (previous) styleDp(style.optDouble("trailingGap")) else 0 + else params.topMargin = if (previous) styleDp(style.optDouble("trailingGap")) else 0 previous = true } } @@ -104,10 +131,25 @@ internal class NativeListAccessoryStack(context: android.content.Context) : Line checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, ) { tag = item - boundCheckboxData?.let { bindCheckbox(item, it, checkboxState) } + if (boundCheckboxData != null) { + val descriptors = item.json.optJSONArray("trailing") + val latest = + (0 until (descriptors?.length() ?: 0)) + .mapNotNull { descriptors?.optJSONObject(it) } + .lastOrNull { it.optString("kind") == "checkbox" } ?: item.json.optJSONObject("checkbox") + latest?.let { bindCheckbox(item, it, checkboxState) } + } } - fun anchorInset(view: View): Int = if ((tag as? NativeListItem)?.json?.optString("presentation") == "accountSelector" && view in trailingIcons && (view.layoutParams as MarginLayoutParams).marginStart < 0) dp(7) else 0 + fun anchorInset(view: View): Int = + if ( + (tag as? NativeListItem)?.json?.optString("presentation") == "accountSelector" && + view in trailingIcons && + (view.layoutParams as MarginLayoutParams).marginStart < 0 + ) + dp(7) + else 0 + fun reset() { tag = null endInset = null diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt new file mode 100644 index 000000000..c8cfa594a --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt @@ -0,0 +1,805 @@ +package com.margelo.nitro.nativelist + +import android.graphics.Paint +import android.graphics.drawable.GradientDrawable +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.TextUtils +import android.text.style.AbsoluteSizeSpan +import android.text.style.ForegroundColorSpan +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.widget.TextViewCompat +import com.facebook.react.uimanager.ThemedReactContext +import kotlin.math.roundToInt +import org.json.JSONArray +import org.json.JSONObject + +internal class NativeListIdentityRowView(context: ThemedReactContext) : + NativeListRendererRowView(context) { + private val leadingFrame = NativeListLeadingVisual(context) + private val leadingFallback + get() = leadingFrame.fallbackTextView + + private val leadingActionIcon = OneKeyIconView(context) + private val mainColumn = LinearLayout(context) + private val titleLine = PackedTitleLineLayout(context) + private val title = NativeListTextView(context) + private val subtitle = NativeListTextView(context) + private val tertiary = NativeListTextView(context) + private val badgeLine = NativeListTextView(context) + private val trailingColumn = NativeListAccessoryStack(context) + private val semanticBadgeLabels = mutableListOf() + private val semanticSubtitleLabels = mutableListOf() + private var walletBadgeLine: View? = null + private var currentItem: NativeListItem? = null + private var currentTheme: JSONObject? = null + private val paintDefaults = mutableMapOf() + + override fun backgroundGroupPosition( + item: NativeListItem, + layout: String, + itemIndex: Int?, + selected: Boolean, + ) = + if ( + item.json.optString("presentation") == "walletSidebar" || + item.json.has("height") && + item.json.optString("presentation") in setOf("accountSelector", "networkSelector") + ) + "single" + else item.json.optString("groupPosition") + + override val assetFields = listOf("leading") + + override fun usesSourceScale(item: NativeListItem, provided: Boolean) = + item.usesSelectorSourceScale || provided + + override fun defaultHeight(item: NativeListItem, layout: String) = + when { + item.json.optString("presentation") == "walletSidebar" -> + if ((item.json.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68 + item.json.optString("presentation") == "networkSelector" -> 47 + item.json.optString("tertiary").isNotEmpty() -> 72 + item.json.optString("subtitle").isNotEmpty() -> 60 + else -> 56 + } + + override fun minimumContentHeight(item: NativeListItem, layout: String, sizeDelta: Int) = + dp( + (defaultHeight(item, layout) + + if (item.json.optString("presentation") == "networkSelector") 0 else sizeDelta) + .coerceAtLeast(0) + ) + + override val defaultSeparatorInset = 60 + + init { + mainColumn.orientation = VERTICAL + mainColumn.gravity = Gravity.CENTER_VERTICAL + titleLine.orientation = HORIZONTAL + titleLine.gravity = Gravity.CENTER_VERTICAL + titleLine.addView(title, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + titleLine.addView(badgeLine) + trailingColumn.onAction = { key, view, slot, target -> + emitAction(key, view, "trailingAccessory", slot, target, trailingColumn.anchorInset(view)) + } + } + + private fun color(theme: JSONObject?, key: String, fallback: String) = + parseNativeListColor(theme?.optString(key, fallback) ?: fallback) + + private fun safeColor(value: String?, fallback: Int) = + runCatching { parseNativeListColor(value ?: "") }.getOrDefault(fallback) + + private fun roundedFill(color: Int, radius: Float) = + GradientDrawable().apply { + setColor(color) + cornerRadius = + if (sourceScale) radius * resources.displayMetrics.density + else NativeListScale.dp(resources, radius) + } + + private fun sp(value: Float) = if (sourceScale) value else NativeListScale.font(resources, value) + + private fun wrap() = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + + private fun weighted() = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) + + private fun showText(view: TextView, text: String, lines: Int) { + view.text = text + view.maxLines = lines + view.visibility = if (text.isEmpty()) GONE else VISIBLE + } + + private fun emitAction( + item: NativeListItem, + key: String, + target: NativeSelectionTarget?, + view: View, + source: String, + slot: Int? = null, + ) { + emitAction(key, view, source, slot, target) + } + + private fun JSONArray?.hasAccessory(kind: String) = + this != null && (0 until length()).any { optJSONObject(it)?.optString("kind") == kind } + + override fun bindContent( + item: NativeListItem, + theme: JSONObject?, + layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + currentItem = item + currentTheme = theme + removeAllViews() + mainColumn.removeAllViews() + semanticBadgeLabels.clear() + semanticSubtitleLabels.clear() + walletBadgeLine = null + paintDefaults.forEach { (view, flags) -> view.paintFlags = flags } + paintDefaults.clear() + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding( + dp(if (layout == "table") 16 else 12), + dp(8), + dp(if (layout == "table") 16 else 12), + dp(8), + ) + clipChildren = true + clipToPadding = true + mainColumn.gravity = Gravity.CENTER_VERTICAL + titleLine.gravity = Gravity.CENTER_VERTICAL + titleLine.packsChildrenAtStart = false + titleLine.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + title.layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) + for (view in listOf(title, subtitle, tertiary, badgeLine)) { + view.text = "" + view.visibility = GONE + view.setLineSpacing(0f, 1f) + view.maxLines = 1 + view.ellipsize = null + view.setHorizontallyScrolling(false) + view.opticalOffsetY = 0f + view.gravity = Gravity.START + view.letterSpacing = 0f + view.fontFeatureSettings = "tnum" + view.includeFontPadding = false + view.background = null + view.setPadding(0, 0, 0, 0) + } + title.typeface = + if (item.json.optString("presentation") in setOf("accountSelector", "walletSidebar")) + NativeListFonts.regular(context) + else NativeListFonts.medium(context) + title.textSize = sp(if (item.json.optString("presentation") == "walletSidebar") 12f else 16f) + title.setTextColor(color(theme, "primaryText", "#000000DF")) + subtitle.typeface = NativeListFonts.regular(context) + subtitle.textSize = sp(14f) + subtitle.setTextColor(color(theme, "secondaryText", "#0000009B")) + subtitle.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + tertiary.typeface = NativeListFonts.regular(context) + tertiary.textSize = sp(14f) + tertiary.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + badgeLine.typeface = NativeListFonts.medium(context) + badgeLine.textSize = sp(12f) + mainColumn.addView(titleLine) + mainColumn.addView(subtitle) + mainColumn.addView(tertiary) + trailingColumn.orientation = VERTICAL + trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL + leadingActionIcon.useSourceScale = sourceScale + leadingActionIcon.setOnClickListener(null) + bindIdentity(item, theme, item.json.optBoolean("selected"), checkboxState) + TextViewCompat.setLineHeight( + title, + dp(if (item.json.optString("presentation") == "walletSidebar") 16 else 24), + ) + TextViewCompat.setLineHeight(subtitle, dp(20)) + TextViewCompat.setLineHeight(tertiary, dp(20)) + val style = item.json.optJSONObject("style") ?: JSONObject() + if (style.has("horizontalPadding")) { + val hp = stylePx(style.optDouble("horizontalPadding")) + setPadding(hp, paddingTop, hp, paddingBottom) + } + if (style.has("verticalPadding")) { + val vp = stylePx(style.optDouble("verticalPadding")) + setPadding(paddingLeft, vp, paddingRight, vp) + } + if (style.has("lineGap")) styleGap(mainColumn, stylePx(style.optDouble("lineGap"))) + if (style.has("leadingGap") && leadingFrame.parent === this) { + if (orientation == VERTICAL) + (mainColumn.layoutParams as LayoutParams).topMargin = stylePx(style.optDouble("leadingGap")) + else + (leadingFrame.layoutParams as LayoutParams).marginEnd = + stylePx(style.optDouble("leadingGap")) + } + if (style.has("titleBadgeGap")) { + val gap = stylePx(style.optDouble("titleBadgeGap")) + if (walletBadgeLine != null) (walletBadgeLine!!.layoutParams as LayoutParams).topMargin = gap + else (badgeLine.layoutParams as LayoutParams).marginStart = gap + } + for ((slot, targets) in + listOf( + "title" to listOf(title), + "subtitle" to semanticSubtitleLabels.ifEmpty { listOf(subtitle) }, + "tertiary" to listOf(tertiary), + "badge" to semanticBadgeLabels.ifEmpty { listOf(badgeLine) }, + )) { + style.optJSONObject(slot)?.let { text -> targets.forEach { applyStyledText(it, text) } } + } + val alignment = style.optJSONObject("container")?.optString("contentVerticalAlignment") + if (!alignment.isNullOrEmpty()) + gravity = + (gravity and Gravity.VERTICAL_GRAVITY_MASK.inv()) or + when (alignment) { + "top" -> Gravity.TOP + "bottom" -> Gravity.BOTTOM + else -> Gravity.CENTER_VERTICAL + } + applySelectorTypography(item) + } + + private fun styleGap(column: LinearLayout, gap: Int) { + var previous = false + for (i in 0 until column.childCount) { + val child = column.getChildAt(i) + if (child.visibility == GONE) continue + (child.layoutParams as LayoutParams).topMargin = if (previous) gap else 0 + previous = true + } + } + + private fun applySelectorTypography(item: NativeListItem) { + if ( + item.json.optString("presentation") !in + setOf("accountSelector", "networkSelector", "walletSidebar") + ) + return + fun visit(view: View) { + if (view is TextView) { + view.fontFeatureSettings = "tnum" + if (sourceScale) { + paintDefaults.putIfAbsent(view, view.paintFlags) + view.paintFlags = view.paintFlags or Paint.SUBPIXEL_TEXT_FLAG or Paint.LINEAR_TEXT_FLAG + val line = view.lineHeight + view.setTextSize( + TypedValue.COMPLEX_UNIT_PX, + kotlin.math + .ceil( + (view.textSize * resources.displayMetrics.density / + resources.displayMetrics.scaledDensity) + .toDouble() + ) + .toFloat(), + ) + view.letterSpacing = 0f + if (view.text.isNotEmpty()) { + val text = SpannableStringBuilder(view.text) + text + .getSpans(0, text.length, NativeListLineHeightSpan::class.java) + .forEach(text::removeSpan) + text.setSpan( + NativeListLineHeightSpan(line), + 0, + text.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + view.setLineSpacing(0f, 1f) + view.text = text + } + } + } + if (view is ViewGroup) for (i in 0 until view.childCount) visit(view.getChildAt(i)) + } + visit(this) + } + + private fun addLeading(descriptor: JSONObject?, sizeDp: Int, spacingDp: Int = 12) { + if (descriptor == null) { + leadingFrame.recycle() + return + } + val item = currentItem!! + val style = item.json.optJSONObject("style")?.optJSONObject("image") ?: JSONObject() + val wallet = item.json.optString("presentation") == "walletSidebar" && item.json.has("height") + leadingFrame.glyphSize = + if (wallet && descriptor.optJSONObject("fallbackIcon")?.optString("name") == "LockSolid") 40 + else 18 + leadingFrame.dashedBorderWidth = if (wallet) 1 else 2 + leadingFrame.overlayTextFontSize = if (wallet) 12f else 10f + leadingFrame.overlayTextLineHeight = if (wallet) 16 else null + leadingFrame.bind(descriptor, style, item.key, currentTheme, false, sourceScale) + leadingFallback.typeface = NativeListFonts.bold(context) + leadingFallback.textSize = sp(13f) + leadingFallback.setLineSpacing(0f, 1f) + addView( + leadingFrame, + LayoutParams( + if (style.has("width")) stylePx(style.optDouble("width")) else dp(sizeDp), + if (style.has("height")) stylePx(style.optDouble("height")) else dp(sizeDp), + ) + .apply { marginEnd = dp(spacingDp) }, + ) + } + + override fun bindSelectionContent( + item: NativeListItem, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + currentItem = item + trailingColumn.updateSelection(item, checkboxState) + if (item.json.optString("presentation") == "walletSidebar") { + title.setTextColor( + color( + currentTheme, + if (rowSelected) "primaryText" else "secondaryText", + if (rowSelected) "#FFFFFFED" else "#FFFFFFAF", + ) + ) + item.json + .optJSONObject("style") + ?.optJSONObject("title") + ?.optString("color") + ?.takeIf(String::isNotEmpty) + ?.let { title.setTextColor(safeColor(it, title.currentTextColor)) } + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val item = currentItem ?: return + if ( + item.json.has("height") && + item.json.optString("presentation") == "networkSelector" && + trailingColumn.hasCheckbox && + trailingColumn.parent === this + ) { + val density = resources.displayMetrics.density + val values = trailingColumn.visibleValues + val width = values.sumOf { it.measuredWidth } + (20 + 12 * values.size) * density + val sourceLeft = (measuredWidth - 12 * density - width).roundToInt() + val measuredLeft = measuredWidth - paddingRight - trailingColumn.measuredWidth + mainColumn.measure( + MeasureSpec.makeMeasureSpec( + (mainColumn.measuredWidth + sourceLeft - measuredLeft).coerceAtLeast(0), + MeasureSpec.EXACTLY, + ), + MeasureSpec.makeMeasureSpec(mainColumn.measuredHeight, MeasureSpec.EXACTLY), + ) + } + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + val item = currentItem ?: return + if ( + item.json + .optJSONObject("style") + ?.optJSONObject("container") + ?.has("contentVerticalAlignment") == true + ) + return + if (!item.json.has("height")) return + val accessory = item.json.optJSONArray("trailing")?.optJSONObject(0) + if ( + item.json.optString("presentation") == "accountSelector" && + accessory?.optString("kind") == "icon" && + accessory.optString("name") == "PlusSmallOutline" + ) { + trailingColumn.firstVisibleIcon?.let { + trailingColumn.offsetTopAndBottom(dp(18) - dp(7) - trailingColumn.top - it.top) + } + } + if (item.json.optString("presentation") == "networkSelector") { + for (column in listOf(mainColumn, trailingColumn)) { + if (column.parent !== this || column.visibility == GONE) continue + val margins = column.layoutParams as MarginLayoutParams + val available = + height - paddingTop - paddingBottom - margins.topMargin - margins.bottomMargin + column.offsetTopAndBottom( + paddingTop + margins.topMargin + (available - column.height + 1) / 2 - column.top + ) + } + } + } + + override fun recycleContent() { + leadingFrame.recycle() + trailingColumn.reset() + currentItem = null + } + + override fun disposeContent() { + recycleContent() + leadingFrame.dispose() + } + + private fun bindIdentity( + item: NativeListItem, + theme: JSONObject?, + selected: Boolean, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + if (item.json.optString("presentation") == "walletSidebar") { + orientation = VERTICAL + gravity = Gravity.CENTER + setPadding(dp(4), dp(4), dp(4), dp(4)) + addLeading(item.json.optJSONObject("leading"), 40, spacingDp = 0) + leadingFallback.textSize = sp(28f) + leadingFallback.typeface = NativeListFonts.regular(context) + mainColumn.gravity = Gravity.CENTER + titleLine.gravity = Gravity.CENTER + titleLine.packsChildrenAtStart = false + titleLine.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + // OneKey patch: the original wallet title ellipsizes within the full inner row width. + title.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + title.gravity = Gravity.CENTER + // OneKey patch: this branch returns before the common identity ellipsis setup. + if (item.json.has("height")) title.ellipsize = TextUtils.TruncateAt.END + showText(title, item.json.optString("title"), 1) + title.setTextColor( + color( + theme, + if (selected) "primaryText" else "secondaryText", + if (selected) "#FFFFFFED" else "#FFFFFFAF", + ) + ) + addView( + mainColumn, + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { + // OneKey patch: snap the source 4 + 40 + 4 sequence once instead of rounding each gap. + topMargin = if (item.json.has("height")) dp(48) - dp(44) else dp(4) + }, + ) + // OneKey patch: wallet tags are a centered line below the wallet name. + item.json + .optJSONArray("badges") + ?.takeIf { it.length() > 0 } + ?.let { badges -> + val isSelector = item.json.has("height") + val badgeLineHeight = if (isSelector) 14 else 16 + val badgeHeight = badgeLineHeight + 4 + val line = + LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER + } + for (index in 0 until badges.length()) { + val badge = + NativeListTextView(context).apply { + text = badges.getJSONObject(index).optString("text") + textSize = sp(if (isSelector) 11f else 12f) + typeface = NativeListFonts.regular(context) + includeFontPadding = false + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + val warning = + isSelector && badges.getJSONObject(index).optString("tone") == "warning" + setTextColor( + color( + theme, + if (warning) "caution" else "secondaryText", + if (warning) "#AB6400" else "#0000009B", + ) + ) + background = + roundedFill( + color( + theme, + if (warning) "cautionBackground" + else if (isSelector) "subduedBackground" else "strongBackground", + if (warning) "#FFF4D5" else "#00000006", + ), + 4f, + ) + setPadding(dp(if (isSelector) 6 else 4), dp(2), dp(if (isSelector) 6 else 4), dp(2)) + } + TextViewCompat.setLineHeight(badge, dp(badgeLineHeight)) + semanticBadgeLabels.add(badge) + line.addView( + badge, + LayoutParams(LayoutParams.WRAP_CONTENT, dp(badgeHeight)).apply { + if (index > 0) marginStart = dp(4) + }, + ) + } + walletBadgeLine = line + mainColumn.addView( + line, + LayoutParams(LayoutParams.WRAP_CONTENT, dp(badgeHeight)).apply { topMargin = dp(4) }, + ) + } + return + } + if (item.json.optString("presentation") == "networkSelector") { + // OneKey patch: the explicit 48-point row centers its 32-point network icon. + // setPadding(dp(12), dp(7), dp(12), dp(8)) + setPadding(dp(12), dp(if (item.json.has("height")) 8 else 7), dp(12), dp(8)) + } + val leading = item.json.optJSONObject("leading") + item.json.optJSONObject("leadingAction")?.let { action -> + leadingActionIcon.visibility = VISIBLE + leadingActionIcon.iconName = action.optString("name") + leadingActionIcon.glyphSizeDp = 24 + leadingActionIcon.tintColor = + safeColor(action.optString("tintColor"), color(theme, "icon", "#0000009B")) + leadingActionIcon.isEnabled = !action.optBoolean("disabled", false) + leadingActionIcon.alpha = if (leadingActionIcon.isEnabled) 1f else 0.4f + leadingActionIcon.setOnClickListener { + emitAction(item, action.optString("actionKey"), null, leadingActionIcon, "leadingAction") + } + // ListItem.IconButton is 36dp with 6dp inner padding and m=-7. Keep the + // full button frame but absorb its leading negative margin into the row + // padding and its trailing margin into the following gap. + setPadding(dp(5), paddingTop, paddingRight, paddingBottom) + addView(leadingActionIcon, LayoutParams(dp(36), dp(36)).apply { marginEnd = dp(5) }) + } + addLeading( + leading, + if ( + leading?.optString("kind") == "network" || + item.json.optString("presentation") == "accountSelector" + ) + 32 + else 40, + ) + // OneKey patch: custom network initials retain LetterAvatar typography. + if ( + item.json.optString("presentation") == "networkSelector" && + leading?.optJSONObject("image") == null && + leading?.optJSONObject("fallbackIcon") == null && + !leading?.optString("fallbackText").isNullOrEmpty() + ) { + leadingFallback.textSize = sp(19f) + leadingFallback.typeface = NativeListFonts.semibold(context) + leadingFallback.setTextColor(color(theme, "inverseText", "#FCFCFC")) + TextViewCompat.setLineHeight(leadingFallback, dp(27)) + } + addView(mainColumn, weighted()) + titleLine.packsChildrenAtStart = true + title.ellipsize = TextUtils.TruncateAt.END + showText(title, item.json.optString("title"), item.json.optInt("titleLines", 1)) + if (item.json.optString("presentation") == "accountSelector") { + title.typeface = NativeListFonts.regular(context) + } + showText(subtitle, item.json.optString("subtitle"), item.json.optInt("subtitleLines", 2)) + // OneKey patch: use separate labels for independently truncated balance/address. + item.json + .optJSONArray("subtitleSegments") + ?.takeIf { it.length() > 0 } + ?.let { segments -> + subtitle.visibility = GONE + val line = + SelectorSubtitleLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + for (index in 0 until segments.length()) { + val segment = segments.getJSONObject(index) + if (segment.optBoolean("separatorBefore", false)) { + val dot = + View(context).apply { + background = roundedFill(color(theme, "disabledText", "#00000072"), 2f) + } + line.addView( + dot, + LayoutParams(dp(4), dp(4)).apply { + marginStart = dp(6) + marginEnd = dp(6) + }, + ) + } + val label = + NativeListTextView(context).apply { + text = segment.optString("text") + typeface = NativeListFonts.regular(context) + textSize = sp(14f) + includeFontPadding = false + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + val toneKey = + when (segment.optString("tone")) { + "primary" -> "primaryText" + "disabled" -> "disabledText" + "caution" -> "caution" + "positive" -> "positive" + "negative" -> "negative" + else -> "secondaryText" + } + setTextColor( + color(theme, toneKey, if (toneKey == "caution") "#AB6400" else "#0000009B") + ) + } + TextViewCompat.setLineHeight(label, dp(20)) + applyValueSegments(label, segment.optJSONArray("textSegments"), 14, 20, false) + semanticSubtitleLabels.add(label) + line.addView(label, LayoutParams(LayoutParams.WRAP_CONTENT, dp(20))) + } + mainColumn.addView(line, 2, LayoutParams(LayoutParams.MATCH_PARENT, dp(20))) + } + item.json + .optJSONArray("titleMatch") + ?.takeIf { it.length() > 0 } + ?.let { matches -> + val highlighted = SpannableStringBuilder(title.text) + for (index in 0 until matches.length()) { + val match = matches.getJSONObject(index) + val start = match.optInt("start") + val end = match.optInt("end") + if (start >= 0 && end > start && end <= highlighted.length) + highlighted.setSpan( + ForegroundColorSpan(color(theme, "info", "#0D74CE")), + start, + end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + title.text = highlighted + } + showText(tertiary, item.json.optString("tertiary"), 1) + tertiary.setTextColor( + color( + theme, + if (item.json.optString("tertiaryTone") == "info") "info" else "secondaryText", + if (item.json.optString("tertiaryTone") == "info") "#006DCBF2" else "#0000009B", + ) + ) + val badges = item.json.optJSONArray("badges") + if (badges != null && badges.length() > 0) { + val texts = + (0 until minOf(2, badges.length())).map { badges.getJSONObject(it).optString("text") } + showText(badgeLine, texts.joinToString(" "), 1) + badgeLine.layoutParams = wrap().apply { marginStart = dp(8) } + badgeLine.setTextColor(color(theme, "secondaryText", "#0000009B")) + badgeLine.background = roundedFill(color(theme, "strongBackground", "#0000000F"), 4f) + badgeLine.setPadding(dp(8), dp(2), dp(8), dp(2)) + TextViewCompat.setLineHeight(badgeLine, dp(16)) + } + addView(trailingColumn, wrap()) + val accessories = item.json.optJSONArray("trailing") ?: JSONArray() + if ( + item.json.has("height") && + item.json.optString("presentation") == "networkSelector" && + accessories.hasAccessory("checkbox") + ) { + // OneKey patch: retain ListItem's title-to-accessory gap when measuring truncation. + (mainColumn.layoutParams as LayoutParams).marginEnd = dp(12) + } + trailingColumn.bind( + item, + accessories, + theme, + item.json.optJSONObject("style") ?: JSONObject(), + sourceScale, + checkboxState, + horizontal = + (0 until accessories.length()) + .map { accessories.getJSONObject(it).optString("kind") } + .let { kinds -> + kinds.containsAll(listOf("value", "checkbox")) || + kinds == listOf("valuePair", "menu") || + (kinds == listOf("icon", "icon") && + accessories.getJSONObject(0).optString("name") == "PencilOutline" && + accessories.getJSONObject(1).optString("name") == "DragOutline") + }, + ) + trailingColumn.endInset?.let { setPadding(paddingLeft, paddingTop, it, paddingBottom) } + } + + private fun applyValueSegments( + view: TextView, + segments: JSONArray?, + fontSize: Int = 16, + lineHeight: Int = 24, + medium: Boolean = true, + ) { + if (segments == null || segments.length() == 0) return + val value = SpannableStringBuilder() + for (index in 0 until segments.length()) { + val segment = segments.getJSONObject(index) + val start = value.length + value.append(segment.optString("text")) + if (segment.optString("style") == "subscript") { + val size = kotlin.math.ceil(fontSize * 0.6).toFloat() + val span = + if (sourceScale) + AbsoluteSizeSpan( + kotlin.math.ceil((size * resources.displayMetrics.density).toDouble()).toInt(), + false, + ) + else AbsoluteSizeSpan(sp(size).roundToInt(), true) + value.setSpan(span, start, value.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + view.textSize = sp(fontSize.toFloat()) + view.typeface = + if (medium) NativeListFonts.medium(context) else NativeListFonts.regular(context) + TextViewCompat.setLineHeight(view, dp(lineHeight)) + view.text = value + } + + private fun applyStyledText(view: TextView, style: JSONObject) { + if (style.has("fontSize")) { + view.setTextSize( + TypedValue.COMPLEX_UNIT_PX, + (style.optDouble("fontSize") * resources.displayMetrics.density).toFloat(), + ) + val text = SpannableStringBuilder(view.text) + text.getSpans(0, text.length, AbsoluteSizeSpan::class.java).forEach(text::removeSpan) + view.text = text + } + style.optString("fontWeight").takeIf(String::isNotEmpty)?.let { + view.typeface = + when (it) { + "medium" -> NativeListFonts.medium(context) + "semibold" -> NativeListFonts.semibold(context) + "bold" -> NativeListFonts.bold(context) + else -> NativeListFonts.regular(context) + } + } + style.optString("color").takeIf(String::isNotEmpty)?.let { + val color = safeColor(it, view.currentTextColor) + view.setTextColor(color) + val text = SpannableStringBuilder(view.text) + text.getSpans(0, text.length, ForegroundColorSpan::class.java).forEach(text::removeSpan) + view.text = text + } + if (style.has("lineHeight")) { + val text = SpannableStringBuilder(view.text) + text.getSpans(0, text.length, NativeListLineHeightSpan::class.java).forEach(text::removeSpan) + text.setSpan( + NativeListLineHeightSpan(stylePx(style.optDouble("lineHeight"))), + 0, + text.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + view.setLineSpacing(0f, 1f) + view.text = text + } + if (style.has("lines")) { + view.maxLines = style.optInt("lines").coerceIn(1, 3) + view.ellipsize = TextUtils.TruncateAt.END + } + if (style.has("truncate") || style.has("lines")) { + view.setHorizontallyScrolling(view.maxLines == 1) + view.ellipsize = + if (style.optString("truncate", "tail") == "clip") null else TextUtils.TruncateAt.END + if (view.maxLines == 1) { + val text = SpannableStringBuilder(view.text) + Regex("\\r\\n|[\\r\\n]").findAll(text).toList().asReversed().forEach { match -> + text.replace(match.range.first, match.range.last + 1, " ") + } + view.text = text + } + } + if (style.has("verticalAlignment")) { + view.gravity = + (view.gravity and Gravity.VERTICAL_GRAVITY_MASK.inv()) or + when (style.optString("verticalAlignment")) { + "top" -> Gravity.TOP + "bottom" -> Gravity.BOTTOM + else -> Gravity.CENTER_VERTICAL + } + } + if (style.has("offsetY")) + (view as? NativeListTextView)?.opticalOffsetY = + (style.optDouble("offsetY") * resources.displayMetrics.density).toFloat() + style.optString("alignment").takeIf(String::isNotEmpty)?.let { + view.gravity = + (view.gravity and Gravity.VERTICAL_GRAVITY_MASK) or + when (it) { + "center" -> Gravity.CENTER_HORIZONTAL + "end" -> Gravity.END + else -> Gravity.START + } + } + } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt index 475f348c3..3e7f0799c 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt @@ -31,6 +31,18 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont private var visual = JSONObject() private var style = JSONObject() private var sources = emptyList>() + val fallbackTextView: TextView + get() = fallback + + var dashedBorderWidth = 2 + var overlayTextFontSize = 10f + var overlayTextLineHeight: Int? = null + var bitmapBorderWidth = 0 + + fun layoutNetworkBackdrop(top: Int, height: Int) { + networkBackdrop.layout(networkBackdrop.left, top, networkBackdrop.right, top + height) + } + var glyphSize = 18 var roundedGlyphOrigin = false var iconBorder = true @@ -159,7 +171,7 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont if (kind == "icon" && iconBorder) setStroke(1, color("#0000001F")) if (visual.optString("borderStyle") == "dashed") setStroke( - dp(2), + dp(dashedBorderWidth), color(visual.optString("borderColor", "#00000072")), dp(4).toFloat(), dp(4).toFloat(), @@ -253,7 +265,12 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont text = data.optString("text") gravity = Gravity.CENTER includeFontPadding = false - textSize = NativeListScale.font(resources, 10f) + textSize = + if (sourceScale) overlayTextFontSize + else NativeListScale.font(resources, overlayTextFontSize) + overlayTextLineHeight?.let { + androidx.core.widget.TextViewCompat.setLineHeight(this, dp(it)) + } typeface = NativeListFonts.medium(context) setTextColor(color(data.optString("tintColor", "#0000009B"))) } @@ -316,6 +333,12 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont else GradientDrawable.RECTANGLE cornerRadius = radius } + if (bitmapBorderWidth > 0) { + (background as? GradientDrawable)?.setStroke( + bitmapBorderWidth, + color(visual.optString("borderColor")), + ) + } place(fallback, 0, 0, w, h) val iconSize = if (sources.isNotEmpty() && visual.optJSONObject("fallbackIcon") != null) @@ -346,14 +369,15 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont MeasureSpec.makeMeasureSpec(imageWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY), ) - place(slot.view, x, y, imageWidth, size) + val inset = if (index == 0) bitmapBorderWidth else 0 + place(slot.view, x + inset, y + inset, imageWidth - 2 * inset, size - 2 * inset) val r = if (index == 0 && (sources.size == 1 || tokenPair)) radius else size / 2f slot.view.outlineProvider = object : ViewOutlineProvider() { override fun getOutline(view: View, outline: Outline) { if (index == 0 && style.optString("shape") == "circle" && !style.has("cornerRadius")) outline.setOval(0, 0, view.width, view.height) - else outline.setRoundRect(0, 0, view.width, view.height, r) + else outline.setRoundRect(-inset, -inset, view.width + inset, view.height + inset, r) } } slot.view.clipToOutline = true diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMarketRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMarketRenderer.kt new file mode 100644 index 000000000..4bfd039b7 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMarketRenderer.kt @@ -0,0 +1,997 @@ +package com.margelo.nitro.nativelist + +import android.graphics.Color +import android.graphics.Outline +import android.graphics.Paint +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.os.Handler +import android.os.Looper +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.TextUtils +import android.text.style.AbsoluteSizeSpan +import android.text.style.ForegroundColorSpan +import android.util.TypedValue +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewOutlineProvider +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.widget.TextViewCompat +import androidx.recyclerview.widget.RecyclerView +import com.facebook.react.uimanager.ThemedReactContext +import kotlin.math.roundToInt +import org.json.JSONArray +import org.json.JSONObject + +internal class NativeListMarketRowView(context: ThemedReactContext) : + NativeListRendererRowView(context) { + private val leadingFrame = NativeListLeadingVisual(context) + private val leadingActionIcon = OneKeyIconView(context) + private val mainColumn = LinearLayout(context) + private val titleLine = PackedTitleLineLayout(context) + private val title = NativeListTextView(context) + private val subtitle = NativeListTextView(context) + private val tertiary = NativeListTextView(context) + private val marketSubtitleLine = PackedTitleLineLayout(context) + private val trailingColumn = LinearLayout(context) + private val trailingViews = List(2) { NativeListTextView(context) } + private val marketBadgeViews = List(3) { LinearLayout(context) } + private val marketBadgeLabels = List(3) { TextView(context) } + private val badgeSlots = List(3) { NativeListImageSlot(context) } + private val marketBadgeImages + get() = badgeSlots.map { it.view } + + private val marketBadgeGlyphs = List(3) { OneKeyIconView(context) } + private val marketOriginalPaintFlags = mutableMapOf() + private var imageSignature = "" + private var longPress: Runnable? = null + private val handler = Handler(Looper.getMainLooper()) + private var held = false + private var longPressFired = false + private var startX = 0f + private var startY = 0f + private var touchX = 0f + private var touchY = 0f + override val assetFields = listOf("leading", "badges") + + override fun usesSourceScale(item: NativeListItem, provided: Boolean) = true + + override fun modelHeight(item: NativeListItem, layout: String) = + if (item.json.has("height")) stylePx(item.json.optDouble("height")) else null + + override fun minimumContentHeight(item: NativeListItem, layout: String, sizeDelta: Int): Int { + val style = item.json.optJSONObject("style") + val stock = item.json.optString("variant") == "stock" + return stylePx( + maxOf( + if (stock) 72.0 else 68.0, + (style?.optJSONObject("image")?.optDouble("height", if (stock) 40.0 else 32.0) + ?: if (stock) 40.0 else 32.0) + 2 * (style?.optDouble("verticalPadding", 12.0) ?: 12.0), + ) + ) + } + + init { + sourceScale = true + gravity = Gravity.CENTER_VERTICAL + clipChildren = false + clipToPadding = false + mainColumn.orientation = VERTICAL + mainColumn.gravity = Gravity.CENTER_VERTICAL + titleLine.orientation = HORIZONTAL + titleLine.gravity = Gravity.CENTER_VERTICAL + titleLine.addView(title, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + mainColumn.addView(titleLine) + mainColumn.addView(subtitle) + mainColumn.addView(tertiary) + trailingViews.forEach(trailingColumn::addView) + leadingActionIcon.useSourceScale = true + marketBadgeViews.forEachIndexed { index, badge -> + badge.orientation = HORIZONTAL + badge.gravity = Gravity.CENTER_VERTICAL + marketBadgeGlyphs[index].useSourceScale = true + marketBadgeLabels[index].apply { + includeFontPadding = false + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + } + badge.addView(marketBadgeGlyphs[index], LayoutParams(dp(16), dp(16))) + badge.addView(marketBadgeImages[index], LayoutParams(dp(14), dp(14))) + badge.addView(marketBadgeLabels[index], wrap()) + } + } + + private val circleOutlineProvider = + object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setOval(0, 0, view.width, view.height) + } + } + + private fun color(theme: JSONObject?, key: String, fallback: String) = + parseNativeListColor(theme?.optString(key, fallback) ?: fallback) + + private fun safeColor(value: String?, fallback: Int) = + runCatching { parseNativeListColor(value ?: "") }.getOrDefault(fallback) + + private fun roundedFill(color: Int, radius: Float) = + GradientDrawable().apply { + setColor(color) + cornerRadius = radius * resources.displayMetrics.density + } + + private fun sp(value: Float) = value + + private fun wrap() = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + + private fun weighted() = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) + + private fun showText(view: TextView, text: String, lines: Int) { + view.text = text + view.maxLines = lines + view.visibility = if (text.isEmpty()) GONE else VISIBLE + } + + private fun emitAction( + item: NativeListItem, + key: String, + target: NativeSelectionTarget?, + view: View, + source: String, + slot: Int? = null, + ) { + emitAction(key, view, source, slot, target) + } + + override fun accessibilityText(item: NativeListItem) = + item.json.optString( + "accessibilityLabel", + listOf( + item.json.optString("title"), + item.json.optString("subtitle"), + item.json.optString("price"), + item.json.optJSONObject("change")?.optString("text") ?: "", + ) + .filter(String::isNotEmpty) + .joinToString(", "), + ) + + override fun bindContent( + item: NativeListItem, + theme: JSONObject?, + layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + cancelMarketHold() + longPressFired = false + removeAllViews() + marketOriginalPaintFlags.forEach { (view, flags) -> view.paintFlags = flags } + marketOriginalPaintFlags.clear() + marketSubtitleLine.removeAllViews() + mainColumn.removeAllViews() + marketBadgeViews.forEachIndexed { index, badge -> + (badge.parent as? android.view.ViewGroup)?.removeView(badge) + badge.visibility = GONE + badge.setOnClickListener(null) + marketBadgeGlyphs[index].visibility = GONE + marketBadgeImages[index].visibility = GONE + marketBadgeLabels[index].setLineSpacing(0f, 1f) + marketBadgeLabels[index].layoutParams = wrap() + if (item.json.optJSONArray("badges")?.optJSONObject(index)?.optJSONObject("icon") == null) + badgeSlots[index].recycle() + } + for (view in listOf(title, subtitle, tertiary) + trailingViews) { + view.text = "" + view.visibility = GONE + view.maxLines = 1 + view.ellipsize = TextUtils.TruncateAt.END + view.setLineSpacing(0f, 1f) + view.setHorizontallyScrolling(false) + view.opticalOffsetY = 0f + view.gravity = Gravity.CENTER_VERTICAL or Gravity.START + } + title.layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) + mainColumn.addView(titleLine) + mainColumn.addView(subtitle) + mainColumn.addView(tertiary) + leadingActionIcon.iconName = "" + leadingActionIcon.setOnClickListener(null) + gravity = Gravity.CENTER_VERTICAL + bindMarket(item, theme) + item.json + .optJSONObject("style") + ?.optJSONObject("container") + ?.optString("contentVerticalAlignment") + ?.takeIf(String::isNotEmpty) + ?.let { + gravity = + when (it) { + "top" -> Gravity.TOP + "bottom" -> Gravity.BOTTOM + else -> Gravity.CENTER_VERTICAL + } + } + } + + override fun bindMarketQuote(item: NativeListItem, theme: JSONObject?) { + if (retainBoundItem(item)) bindQuote(item, theme) + } + + private fun cancelMarketHold() { + longPress?.let(handler::removeCallbacks) + longPress = null + } + + override fun onRowTouch(event: MotionEvent) { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + val item = tag as? NativeListItem + if (item?.isRowPressEnabled == true) { + held = true + longPressFired = false + startX = event.x + startY = event.y + touchX = event.x + touchY = event.y + item.json.optString("pressInActionKey").takeIf(String::isNotEmpty)?.let { + emitAction(it, this, "row") + } + item.json.optString("longPressActionKey").takeIf(String::isNotEmpty)?.let { key -> + val epoch = bindingEpoch + val task = Runnable { + val current = tag as? NativeListItem + if (held && current?.key == item.key && bindingEpoch == epoch) { + longPress = null + longPressFired = true + val location = IntArray(2) + getLocationInWindow(location) + onAction?.invoke( + current, + key, + null, + NativeListActionOrigin( + this, + this, + bindingEpoch, + "row", + windowPointPixels = + android.graphics.PointF(location[0] + touchX, location[1] + touchY), + ), + ) + } + } + longPress = task + handler.postDelayed(task, 800) + } + } + } + MotionEvent.ACTION_MOVE -> { + touchX = event.x + touchY = event.y + if ( + event.x < 0 || + event.y < 0 || + event.x >= width || + event.y >= height || + kotlin.math.abs(event.x - startX) > dp(10) || + kotlin.math.abs(event.y - startY) > dp(10) + ) { + cancelMarketHold() + held = false + setTouchPressed(false) + } + } + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> { + cancelMarketHold() + held = false + } + } + } + + override fun consumesRowClick(): Boolean { + val value = longPressFired + longPressFired = false + return value + } + + override fun recycleContent() { + cancelMarketHold() + held = false + longPressFired = false + leadingFrame.recycle() + badgeSlots.forEach { it.recycle() } + imageSignature = "" + } + + override fun disposeContent() { + recycleContent() + leadingFrame.dispose() + badgeSlots.forEach { it.dispose() } + } + + private fun marketTypeface(weight: String, fallback: String): Typeface = + when (weight.ifEmpty { fallback }) { + "regular" -> NativeListFonts.regular(context) + "semibold" -> NativeListFonts.semibold(context) + "bold" -> NativeListFonts.bold(context) + else -> NativeListFonts.medium(context) + } + + private fun applyMarketTextStyle( + view: TextView, + style: JSONObject?, + defaultSize: Float, + defaultLineHeight: Int, + defaultWeight: String, + defaultColor: Int, + defaultAlignment: String, + ) { + view.includeFontPadding = false + view.fontFeatureSettings = "tnum" + view.textSize = + sp(style?.optDouble("fontSize", defaultSize.toDouble())?.toFloat() ?: defaultSize) + view.typeface = marketTypeface(style?.optString("fontWeight").orEmpty(), defaultWeight) + view.setTextColor(safeColor(style?.optString("color"), defaultColor)) + val alignment = style?.optString("alignment", defaultAlignment) ?: defaultAlignment + view.gravity = + Gravity.CENTER_VERTICAL or + when (alignment) { + "center" -> Gravity.CENTER_HORIZONTAL + "end" -> Gravity.END + else -> Gravity.START + } + view.maxLines = style?.optInt("lines", 1)?.coerceIn(1, 3) ?: 1 + view.ellipsize = TextUtils.TruncateAt.END + TextViewCompat.setLineHeight( + view, + dp( + style?.optDouble("lineHeight", defaultLineHeight.toDouble())?.roundToInt() + ?: defaultLineHeight + ), + ) + } + + // OneKey patch: opt-in Market text uses the same pixel rounding and line box as RN. + private fun applyMarketTextMetrics(view: TextView, style: JSONObject?) { + if (style == null || view.visibility != VISIBLE || view.text.isEmpty()) return + marketOriginalPaintFlags.putIfAbsent(view, view.paintFlags) + view.paintFlags = view.paintFlags or Paint.SUBPIXEL_TEXT_FLAG or Paint.LINEAR_TEXT_FLAG + if (style.has("fontSize")) { + val sourceSize = style.optDouble("fontSize").toFloat() * resources.displayMetrics.density + view.setTextSize( + TypedValue.COMPLEX_UNIT_PX, + kotlin.math.ceil(sourceSize.toDouble()).toFloat(), + ) + } + val text = SpannableStringBuilder(view.text) + if (style.has("fontSize")) + text.getSpans(0, text.length, AbsoluteSizeSpan::class.java).forEach(text::removeSpan) + text.getSpans(0, text.length, NativeListLineHeightSpan::class.java).forEach(text::removeSpan) + if (style.has("lineHeight")) { + val lineHeight = stylePx(style.optDouble("lineHeight")) + text.setSpan( + NativeListLineHeightSpan(lineHeight), + 0, + text.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + view.setLineSpacing(0f, 1f) + } + view.text = text + val layout = JSONObject() + listOf("lines", "truncate", "verticalAlignment", "offsetY").forEach { key -> + if (style.has(key)) layout.put(key, style.get(key)) + } + if (layout.length() > 0) { + applyStyledText(view, layout) + } + } + + private fun marketText(value: String, segments: JSONArray?, fontSize: Float): CharSequence { + if (segments == null || segments.length() == 0) return value + val result = SpannableStringBuilder() + for (index in 0 until segments.length()) { + val segment = segments.getJSONObject(index) + val start = result.length + result.append(segment.optString("text")) + if (segment.optString("style") == "subscript") { + result.setSpan( + AbsoluteSizeSpan(sp(kotlin.math.ceil(fontSize * 0.6f)).roundToInt(), true), + start, + result.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + } + return result + } + + private fun applyStyledText(view: TextView, style: JSONObject) { + if (style.has("fontSize")) { + view.setTextSize( + TypedValue.COMPLEX_UNIT_PX, + (style.optDouble("fontSize") * resources.displayMetrics.density).toFloat(), + ) + val text = SpannableStringBuilder(view.text) + text.getSpans(0, text.length, AbsoluteSizeSpan::class.java).forEach(text::removeSpan) + view.text = text + } + style.optString("fontWeight").takeIf(String::isNotEmpty)?.let { + view.typeface = marketTypeface(it, "regular") + } + style.optString("color").takeIf(String::isNotEmpty)?.let { + val color = safeColor(it, view.currentTextColor) + view.setTextColor(color) + val text = SpannableStringBuilder(view.text) + text.getSpans(0, text.length, ForegroundColorSpan::class.java).forEach(text::removeSpan) + view.text = text + } + if (style.has("lineHeight")) { + val text = SpannableStringBuilder(view.text) + text.getSpans(0, text.length, NativeListLineHeightSpan::class.java).forEach(text::removeSpan) + text.setSpan( + NativeListLineHeightSpan(stylePx(style.optDouble("lineHeight"))), + 0, + text.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + view.setLineSpacing(0f, 1f) + view.text = text + } + if (style.has("lines")) { + view.maxLines = style.optInt("lines").coerceIn(1, 3) + view.ellipsize = TextUtils.TruncateAt.END + } + if (style.has("truncate") || style.has("lines")) { + view.setHorizontallyScrolling(view.maxLines == 1) + view.ellipsize = + if (style.optString("truncate", "tail") == "clip") null else TextUtils.TruncateAt.END + if (view.maxLines == 1) { + val text = SpannableStringBuilder(view.text) + Regex("\\r\\n|[\\r\\n]").findAll(text).toList().asReversed().forEach { match -> + text.replace(match.range.first, match.range.last + 1, " ") + } + view.text = text + } + } + if (style.has("verticalAlignment")) { + view.gravity = + (view.gravity and Gravity.VERTICAL_GRAVITY_MASK.inv()) or + when (style.optString("verticalAlignment")) { + "top" -> Gravity.TOP + "bottom" -> Gravity.BOTTOM + else -> Gravity.CENTER_VERTICAL + } + } + if (style.has("offsetY")) + (view as? NativeListTextView)?.opticalOffsetY = + (style.optDouble("offsetY") * resources.displayMetrics.density).toFloat() + style.optString("alignment").takeIf(String::isNotEmpty)?.let { + view.gravity = + (view.gravity and Gravity.VERTICAL_GRAVITY_MASK) or + when (it) { + "center" -> Gravity.CENTER_HORIZONTAL + "end" -> Gravity.END + else -> Gravity.START + } + } + } + + private fun bindMarket(item: NativeListItem, theme: JSONObject?) { + // Network badges extend beyond the leading image's frame. + clipChildren = false + val variant = item.json.optString("variant") + val style = item.json.optJSONObject("style") + val imageStyle = style?.optJSONObject("image") + val imageWidth = + imageStyle?.optDouble("width", if (variant == "stock") 40.0 else 32.0)?.roundToInt() + ?: if (variant == "stock") 40 else 32 + val imageHeight = + imageStyle?.optDouble("height", if (variant == "stock") 40.0 else 32.0)?.roundToInt() + ?: if (variant == "stock") 40 else 32 + val horizontalPadding = + style?.optDouble("horizontalPadding", if (variant == "perp") 16.0 else 20.0)?.roundToInt() + ?: if (variant == "perp") 16 else 20 + val verticalPadding = style?.optDouble("verticalPadding", 12.0)?.roundToInt() ?: 12 + val leadingGap = + style?.optDouble("leadingGap", if (variant == "perp") 8.0 else 14.0)?.roundToInt() + ?: if (variant == "perp") 8 else 14 + setPadding( + dp(horizontalPadding), + dp(verticalPadding), + dp(horizontalPadding), + dp(verticalPadding), + ) + + item.json.optJSONObject("leadingAction")?.let { action -> + leadingActionIcon.visibility = VISIBLE + leadingActionIcon.iconName = action.optString("name") + leadingActionIcon.glyphSizeDp = 24 + leadingActionIcon.tintColor = + safeColor(action.optString("tintColor"), color(theme, "icon", "#0000009B")) + leadingActionIcon.isEnabled = !action.optBoolean("disabled", false) + leadingActionIcon.alpha = if (leadingActionIcon.isEnabled) 1f else 0.4f + leadingActionIcon.setTag( + com.facebook.react.R.id.react_test_id, + action.optString("testID").takeIf(String::isNotEmpty), + ) + leadingActionIcon.contentDescription = action.optString("accessibilityLabel") + leadingActionIcon.setOnClickListener { + emitAction(item, action.optString("actionKey"), null, leadingActionIcon, "leadingAction") + } + addView(leadingActionIcon, LayoutParams(dp(36), dp(36)).apply { marginEnd = dp(5) }) + } + + val leading = JSONObject(item.json.getJSONObject("leading").toString()) + imageStyle?.optString("shape")?.takeIf(String::isNotEmpty)?.let { leading.put("shape", it) } + imageStyle?.optString("contentFit")?.takeIf(String::isNotEmpty)?.let { contentFit -> + leading.optJSONObject("image")?.put("contentFit", contentFit) + } + val shape = + imageStyle?.optString("shape", leading.optString("shape", "circle")) + ?: leading.optString("shape", "circle") + val cornerRadius = + imageStyle?.takeIf { it.has("cornerRadius") }?.optDouble("cornerRadius")?.toFloat() + ?: when (shape) { + "square" -> 0f + "rounded" -> 8f + else -> minOf(imageWidth, imageHeight) / 2f + } + val resolved = JSONObject(imageStyle?.toString() ?: "{}") + resolved.put("cornerRadius", cornerRadius.toDouble()) + leadingFrame.bitmapBorderWidth = if (leading.optString("borderColor").isEmpty()) 0 else dp(1) + leadingFrame.bind(leading, resolved, item.key, theme, false, true) + addView( + leadingFrame, + LayoutParams( + if (imageStyle?.has("width") == true) stylePx(imageStyle.optDouble("width")) + else dp(imageWidth), + if (imageStyle?.has("height") == true) stylePx(imageStyle.optDouble("height")) + else dp(imageHeight), + ) + .apply { + marginEnd = + if (style?.has("leadingGap") == true) stylePx(style.optDouble("leadingGap")) + else dp(leadingGap) + }, + ) + // OneKey patch: retain the source gap without changing other templates. + // addView(mainColumn, weighted()) + addView( + mainColumn, + weighted().apply { + marginEnd = dp(style?.optDouble("contentTrailingGap", 0.0)?.roundToInt() ?: 0) + }, + ) + titleLine.packsChildrenAtStart = true + showText( + title, + item.json.optString("title"), + style?.optJSONObject("title")?.optInt("lines", 1) ?: 1, + ) + applyMarketTextStyle( + title, + style?.optJSONObject("title"), + 16f, + 24, + "medium", + color(theme, "primaryText", "#202020"), + "start", + ) + val badges = item.json.optJSONArray("badges") + val titleBadgeGap = style?.optDouble("titleBadgeGap", 4.0)?.roundToInt() ?: 4 + if (badges != null) { + for (index in 0 until minOf(marketBadgeViews.size, badges.length())) { + val badge = badges.getJSONObject(index) + val badgeView = marketBadgeViews[index] + val badgeLabel = marketBadgeLabels[index] + val badgeStyle = badge.optJSONObject("style") + val hasGlyph = badge.optString("iconName") == "verified" + val remoteIcon = badge.optJSONObject("icon") + val hasIcon = hasGlyph || remoteIcon != null + val text = badge.optString("text") + val toneColor = + when (badge.optString("tone")) { + "success" -> color(theme, "positive", "#218358") + "danger" -> color(theme, "negative", "#CE2C31") + "info" -> color(theme, "info", "#0D74CE") + "warning" -> color(theme, "primaryText", "#202020") + else -> color(theme, "secondaryText", "#646464") + } + val foreground = safeColor(badge.optString("textColor"), toneColor) + badgeView.visibility = VISIBLE + val iconOnly = hasIcon && text.isEmpty() + // OneKey patch: Market callers can request the original badge padding. + val padding = badgeStyle?.optDouble("horizontalPadding", 5.0)?.roundToInt() ?: 5 + val leftPadding = + if (badgeStyle?.has("horizontalPadding") == true) padding else if (hasIcon) 2 else 5 + // badgeView.setPadding(dp(if (iconOnly) 0 else if (hasIcon) 2 else 5), 0, dp(if (iconOnly) + // 0 else 5), 0) + badgeView.setPadding( + dp(if (iconOnly) 0 else leftPadding), + 0, + dp(if (iconOnly) 0 else padding), + 0, + ) + badgeView.background = + roundedFill( + safeColor( + badge.optString("backgroundColor"), + if (hasIcon && text.isEmpty()) Color.TRANSPARENT + else color(theme, "strongBackground", "#0000000F"), + ), + 4f, + ) + .apply { + if (badgeStyle != null) this.cornerRadius = 4f * resources.displayMetrics.density + } + badgeLabel.text = text + // OneKey patch: match SizableText's tabular numerals only for explicit Market metrics. + badgeLabel.fontFeatureSettings = if (badgeStyle != null) "tnum" else null + badgeLabel.textSize = sp(badgeStyle?.optDouble("fontSize", 11.0)?.toFloat() ?: 11f) + badgeLabel.typeface = + marketTypeface(badgeStyle?.optString("fontWeight").orEmpty(), "medium") + if (badgeStyle?.has("lineHeight") == true) { + TextViewCompat.setLineHeight( + badgeLabel, + dp(badgeStyle.optDouble("lineHeight").roundToInt()), + ) + } + badgeLabel.setTextColor(foreground) + badgeLabel.visibility = if (text.isEmpty()) GONE else VISIBLE + if (hasGlyph) { + marketBadgeGlyphs[index].apply { + iconName = "BadgeVerifiedSolid" + tintColor = foreground + visibility = VISIBLE + } + } + remoteIcon?.let { icon -> + marketBadgeImages[index].visibility = VISIBLE + marketBadgeImages[index].outlineProvider = circleOutlineProvider + marketBadgeImages[index].clipToOutline = true + badgeSlots[index].bind(icon, item.key + ":badge:" + index, "generic") + } + val actionKey = badge.optString("actionKey") + badgeView.isClickable = actionKey.isNotEmpty() + if (actionKey.isNotEmpty()) { + badgeView.setOnClickListener { + emitAction(item, actionKey, null, badgeView, "marketBadge", index) + } + } + badgeView.contentDescription = badge.optString("accessibilityLabel", text) + titleLine.addView( + badgeView, + // OneKey patch: preserve 18dp unless the Market row opts into source metrics. + // LayoutParams(LayoutParams.WRAP_CONTENT, dp(18)).apply { marginStart = dp(titleBadgeGap) + // }, + LayoutParams( + LayoutParams.WRAP_CONTENT, + dp(badgeStyle?.optDouble("height", 18.0)?.roundToInt() ?: 18), + ) + .apply { marginStart = dp(titleBadgeGap) }, + ) + } + } + subtitle.layoutParams = + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { + topMargin = dp(style?.optDouble("lineGap", 0.0)?.roundToInt() ?: 0) + } + val subtitleText = item.json.optString("subtitle") + val subtitleSegments = item.json.optJSONArray("subtitleSegments") + if (subtitleText.isNotEmpty() || (subtitleSegments?.length() ?: 0) > 0) { + subtitle.visibility = VISIBLE + applyMarketTextStyle( + subtitle, + style?.optJSONObject("subtitle"), + 14f, + 20, + "regular", + color(theme, "secondaryText", "#646464"), + "start", + ) + subtitle.text = + marketText( + subtitleText, + subtitleSegments, + style?.optJSONObject("subtitle")?.optDouble("fontSize", 14.0)?.toFloat() ?: 14f, + ) + } + // OneKey patch: preserve independent name metrics, truncation, and volume. + val subtitlePrefix = item.json.optJSONObject("subtitlePrefix") + val subtitlePadding = style?.optDouble("subtitleTrailingPadding", 0.0)?.roundToInt() ?: 0 + if (subtitlePrefix != null || subtitlePadding > 0) { + mainColumn.removeView(subtitle) + mainColumn.removeView(tertiary) + marketSubtitleLine.orientation = HORIZONTAL + marketSubtitleLine.gravity = Gravity.CENTER_VERTICAL + marketSubtitleLine.packsChildrenAtStart = true + marketSubtitleLine.leadingTextMaxWidth = + subtitlePrefix + ?.takeIf { it.has("maxWidth") } + ?.optDouble("maxWidth") + ?.roundToInt() + ?.let(::dp) ?: Int.MAX_VALUE + marketSubtitleLine.setPadding(0, 0, dp(subtitlePadding), 0) + showText(tertiary, subtitlePrefix?.optString("text") ?: "", 1) + applyMarketTextStyle( + tertiary, + subtitlePrefix?.optJSONObject("style"), + 12f, + 16, + "regular", + color(theme, "secondaryText", "#646464"), + "start", + ) + marketSubtitleLine.addView(tertiary, wrap()) + marketSubtitleLine.addView( + subtitle, + wrap().apply { + if (tertiary.visibility == VISIBLE && subtitle.visibility == VISIBLE) { + marginStart = dp(subtitlePrefix?.optDouble("gap", 4.0)?.roundToInt() ?: 4) + } + }, + ) + mainColumn.addView( + marketSubtitleLine, + 1, + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { + topMargin = dp(style?.optDouble("lineGap", 0.0)?.roundToInt() ?: 0) + }, + ) + marketSubtitleLine.visibility = + if (tertiary.visibility == VISIBLE || subtitle.visibility == VISIBLE) VISIBLE else GONE + } + trailingColumn.orientation = HORIZONTAL + trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL + addView(trailingColumn, wrap()) + bindQuote(item, theme) + val nextImageSignature = + nativeListCanonical( + JSONObject() + .put("key", item.key) + .put("leading", leading) + .put("fit", resolved.opt("contentFit")) + ) + item.json + .optJSONObject("diagnostics") + ?.optString("imageBindActionKey") + ?.takeIf(String::isNotEmpty) + ?.takeIf { nextImageSignature != imageSignature } + ?.takeIf { + leading.optJSONObject("image") != null || leading.optJSONObject("networkImage") != null + } + ?.let { actionKey -> onAction?.invoke(item, actionKey, null, null) } + imageSignature = nextImageSignature + } + + private fun bindQuote(item: NativeListItem, theme: JSONObject?) { + val style = item.json.optJSONObject("style") + val priceStyle = style?.optJSONObject("price") + val price = trailingViews[0] + price.isClickable = false + price.isLongClickable = false + price.visibility = VISIBLE + price.maxWidth = dp(112) + applyMarketTextStyle( + price, + priceStyle, + 16f, + 24, + "medium", + color(theme, "primaryText", "#202020"), + "end", + ) + price.text = + marketText( + item.json.optString("price"), + item.json.optJSONArray("priceSegments"), + priceStyle?.optDouble("fontSize", 16.0)?.toFloat() ?: 16f, + ) + val trailingGap = style?.optDouble("trailingGap", 8.0)?.roundToInt() ?: 8 + price.layoutParams = + LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + marginEnd = dp(trailingGap) + } + + val changeData = item.json.getJSONObject("change") + val changeStyle = style?.optJSONObject("change") + val change = trailingViews[1] + change.isClickable = false + change.isLongClickable = false + val toneColor = + when (changeData.optString("tone")) { + "positive" -> color(theme, "positive", "#218358") + "negative" -> color(theme, "negative", "#CE2C31") + else -> color(theme, "secondaryText", "#8D8D8D") + } + val textColor = + safeColor(changeData.optString("textColor"), color(theme, "inverseText", "#FFFFFF")) + change.visibility = VISIBLE + applyMarketTextStyle(change, changeStyle, 14f, 20, "medium", textColor, "center") + change.text = + marketText( + changeData.optString("text"), + changeData.optJSONArray("textSegments"), + changeStyle?.optDouble("fontSize", 14.0)?.toFloat() ?: 14f, + ) + change.background = + roundedFill( + safeColor(changeData.optString("backgroundColor"), toneColor), + style?.optDouble("changeCornerRadius", 8.0)?.toFloat() ?: 8f, + ) + change.layoutParams = + LayoutParams( + dp(style?.optDouble("changeWidth", 80.0)?.roundToInt() ?: 80), + dp(style?.optDouble("changeHeight", 32.0)?.roundToInt() ?: 32), + ) + applyMarketTextMetrics(title, style?.optJSONObject("title")) + applyMarketTextMetrics(subtitle, style?.optJSONObject("subtitle")) + applyMarketTextMetrics( + tertiary, + item.json.optJSONObject("subtitlePrefix")?.optJSONObject("style"), + ) + applyMarketTextMetrics(price, priceStyle) + applyMarketTextMetrics(change, changeStyle) + val badges = item.json.optJSONArray("badges") + marketBadgeLabels.forEachIndexed { index, label -> + val badge = badges?.optJSONObject(index) + val badgeStyle = badge?.optJSONObject("style") + applyMarketTextMetrics(label, badgeStyle) + if ( + badge != null && + label.visibility == VISIBLE && + badgeStyle?.has("horizontalPadding") == true && + badge.optJSONObject("icon") == null && + badge.optString("iconName").isEmpty() + ) { + // OneKey patch: match RN's intrinsic text width and one rounded padding pair. + label.layoutParams = + wrap().apply { width = label.paint.measureText(label.text.toString()).roundToInt() } + val padding = badgeStyle.optDouble("horizontalPadding") * resources.displayMetrics.density + marketBadgeViews[index].setPadding( + kotlin.math.floor(padding).toInt(), + 0, + (padding * 2).roundToInt() - kotlin.math.floor(padding).toInt(), + 0, + ) + } + } + contentDescription = + item.json.optString( + "accessibilityLabel", + listOf( + item.json.optString("title"), + item.json.optString("subtitle"), + item.json.optString("price"), + changeData.optString("text"), + ) + .filter(String::isNotEmpty) + .joinToString(", "), + ) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val marketItem = (tag as? NativeListItem)?.takeIf { it.type == "market" } + val marketStyle = marketItem?.json?.optJSONObject("style") + if (marketStyle?.has("horizontalPadding") == true) { + // OneKey patch: Yoga rounds the two absolute edges, not both padding values. + val sourcePadding = + marketStyle.optDouble("horizontalPadding") * resources.displayMetrics.density + val width = MeasureSpec.getSize(widthMeasureSpec) + val rowHeight = marketItem.styledHeight?.let(::stylePx) ?: modelHeight(marketItem, "linear") + val sourceVerticalPadding = + marketStyle + .takeIf { it.has("verticalPadding") && rowHeight != null } + ?.optDouble("verticalPadding") + ?.times(resources.displayMetrics.density) + setPadding( + sourcePadding.roundToInt(), + sourceVerticalPadding?.roundToInt() ?: paddingTop, + width - (width - sourcePadding).roundToInt(), + if (sourceVerticalPadding != null && rowHeight != null) + rowHeight - (rowHeight - sourceVerticalPadding).roundToInt() + else paddingBottom, + ) + } + + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + val item = tag as? NativeListItem ?: return + val hasContainerAlignment = + item.json + .optJSONObject("style") + ?.optJSONObject("container") + ?.has("contentVerticalAlignment") == true + if (item.type == "market" && item.json.optJSONObject("style") != null) { + // OneKey patch: preserve Yoga's half-pixel centering for Market columns and badges. + for (column in listOf(leadingFrame, mainColumn, trailingColumn)) { + if (!hasContainerAlignment && column.parent === this && column.visibility != GONE) { + column.offsetTopAndBottom((height - column.height + 1) / 2 - column.top) + } + } + // OneKey patch: token avatars stay centered in the row when text rounding adds a pixel. + if ( + !hasContainerAlignment && + item.json.optString("variant") != "token" && + leadingFrame.parent === this && + mainColumn.parent === this + ) { + leadingFrame.offsetTopAndBottom( + mainColumn.top + (mainColumn.height - leadingFrame.height + 1) / 2 - leadingFrame.top + ) + } + // OneKey patch: preserve Yoga's absolute-edge rounding for the Market network badge. + if ( + item.json.optJSONObject("leading")?.optJSONObject("networkImage") != null && + item.json.optString("variant") == "token" + ) { + val recycler = parent as? RecyclerView + val adapter = recycler?.adapter as? NativeListAdapter + val position = recycler?.getChildAdapterPosition(this) ?: RecyclerView.NO_POSITION + if ( + recycler != null && + adapter != null && + position != RecyclerView.NO_POSITION && + adapter.itemAt(position)?.key == item.key + ) { + val density = resources.displayMetrics.density.toDouble() + val rowTop = adapter.marketSourceEdgePx(position) + val rowHeight = adapter.marketSourceEdgePx(position + 1) - rowTop + val imageHeight = + item.json.optJSONObject("style")?.optJSONObject("image")?.optDouble("height", 32.0) + ?: 32.0 + val contentTop = (recycler.paddingTop / density).roundToInt() * density + val badgeTop = + contentTop + + rowTop + + (rowHeight - imageHeight * density) / 2 + + (imageHeight - 16) * density + val badgeHeight = (badgeTop + 20 * density).roundToInt() - badgeTop.roundToInt() + val top = ((imageHeight - 16) * density).roundToInt() + leadingFrame.layoutNetworkBackdrop(top, badgeHeight) + } + } + for (badge in marketBadgeViews) { + if (badge.parent === titleLine && badge.visibility != GONE) { + badge.offsetTopAndBottom((titleLine.height - badge.height + 1) / 2 - badge.top) + } + } + // OneKey patch: preserve fractional text advances and gaps until the final edge. + val style = item.json.optJSONObject("style") + for ((line, gap) in + listOf( + titleLine to style.optDouble("titleBadgeGap", 4.0), + marketSubtitleLine to + (item.json.optJSONObject("subtitlePrefix")?.optDouble("gap", 4.0) ?: 0.0), + )) { + var cursor = 0f + var hasPrevious = false + for (index in 0 until line.childCount) { + val child = line.getChildAt(index) + if (child.visibility == GONE) continue + if (hasPrevious) { + cursor += (gap * resources.displayMetrics.density).toFloat() + child.offsetLeftAndRight(cursor.roundToInt() - child.left) + } + val advance = + if (index == 0 && child is TextView) { + val textWidth = + if (line === titleLine) child.paint.measureText(child.text.toString()) + else child.layout?.getLineWidth(0) + textWidth?.coerceAtMost(child.width.toFloat()) ?: child.width.toFloat() + } else child.width.toFloat() + cursor = if (hasPrevious) cursor + advance else child.left + advance + hasPrevious = true + } + } + } + } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index 6a8e0def2..33162ec11 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -71,7 +71,7 @@ private fun updateLengthPrefixed(digest: MessageDigest, value: String) { // Message and Rail own dedicated renderers. Remaining templates still share // a compatible legacy tree; keys and style values never partition the pool. -internal enum class NativeListRendererKey { LEGACY, MESSAGE, RAIL, MEDIA_TILE, ACTION, SYSTEM, ACTIVITY, DATA_ROW, METRIC_CARD } +internal enum class NativeListRendererKey { LEGACY, MESSAGE, RAIL, MEDIA_TILE, ACTION, SYSTEM, ACTIVITY, DATA_ROW, METRIC_CARD, MARKET, IDENTITY, SECTION_HEADER } internal data class NativeListItem( val key: String, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt index d7cdb4b01..184d1835a 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt @@ -3,41 +3,47 @@ package com.margelo.nitro.nativelist import com.facebook.react.uimanager.ThemedReactContext internal object NativeListRendererRegistry { - private val factories: Map NativeListRowHost> = - mapOf( - NativeListRendererKey.LEGACY to ::NativeListRowView, - NativeListRendererKey.MESSAGE to ::NativeListMessageRowView, - NativeListRendererKey.RAIL to ::NativeListRailRowView, - NativeListRendererKey.MEDIA_TILE to ::NativeListMediaTileRowView, + private val factories: Map NativeListRowHost> = + mapOf( + NativeListRendererKey.SECTION_HEADER to ::NativeListSectionHeaderRowView, + NativeListRendererKey.IDENTITY to ::NativeListIdentityRowView, + NativeListRendererKey.MARKET to ::NativeListMarketRowView, + NativeListRendererKey.LEGACY to ::NativeListRowView, + NativeListRendererKey.MESSAGE to ::NativeListMessageRowView, + NativeListRendererKey.RAIL to ::NativeListRailRowView, + NativeListRendererKey.MEDIA_TILE to ::NativeListMediaTileRowView, NativeListRendererKey.METRIC_CARD to ::NativeListMetricCardRowView, NativeListRendererKey.DATA_ROW to ::NativeListDataRowView, NativeListRendererKey.ACTIVITY to ::NativeListActivityRowView, NativeListRendererKey.SYSTEM to ::NativeListSystemRowView, NativeListRendererKey.ACTION to ::NativeListActionRowView, - ) + ) - private val keys = - mapOf( - "message" to NativeListRendererKey.MESSAGE, - "rail" to NativeListRendererKey.RAIL, - "mediaTile" to NativeListRendererKey.MEDIA_TILE, + private val keys = + mapOf( + "sectionHeader" to NativeListRendererKey.SECTION_HEADER, + "identity" to NativeListRendererKey.IDENTITY, + "market" to NativeListRendererKey.MARKET, + "message" to NativeListRendererKey.MESSAGE, + "rail" to NativeListRendererKey.RAIL, + "mediaTile" to NativeListRendererKey.MEDIA_TILE, "metricCard" to NativeListRendererKey.METRIC_CARD, "dataRow" to NativeListRendererKey.DATA_ROW, "activity" to NativeListRendererKey.ACTIVITY, "system" to NativeListRendererKey.SYSTEM, "action" to NativeListRendererKey.ACTION, - ) + ) - fun key(type: String) = keys[type] ?: NativeListRendererKey.LEGACY + fun key(type: String) = keys[type] ?: NativeListRendererKey.LEGACY - fun create(context: ThemedReactContext, viewType: Int): NativeListRowHost = - factories.getValue(NativeListRendererKey.entries[viewType])(context) + fun create(context: ThemedReactContext, viewType: Int): NativeListRowHost = + factories.getValue(NativeListRendererKey.entries[viewType])(context) } // Compatibility belongs to list chrome, never to Message style resolution. // docs/MIGRATION_BASELINE.md records the callers that must migrate before removal. internal fun nativeListLegacyShowsSeparator(item: NativeListItem): Boolean = - item.json.optBoolean("separator") && - !item.key.startsWith("token-") && - !item.key.startsWith("balance-token-") && - item.key != "linear-custom-token" + item.json.optBoolean("separator") && + !item.key.startsWith("token-") && + !item.key.startsWith("balance-token-") && + item.key != "linear-custom-token" diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt index 0b52d37bc..ffb5ec4ff 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt @@ -37,6 +37,26 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th protected abstract fun disposeContent() + protected open fun usesSourceScale(item: NativeListItem, provided: Boolean) = provided + + protected open fun onRowTouch(event: MotionEvent) {} + + protected open fun consumesRowClick() = false + + protected fun setTouchPressed(value: Boolean) { + touchPressed = value + } + + protected fun retainBoundItem(item: NativeListItem): Boolean { + if (current?.key != item.key) return false + current = item + tag = item + return true + } + + protected open fun accessibilityText(item: NativeListItem) = + item.json.optString("accessibilityLabel", item.json.optString("title")) + protected open val assetFields: List = emptyList() private fun update(old: NativeListItem?, next: NativeListItem): NativeListRendererUpdate { @@ -69,6 +89,7 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th protected open fun horizontalWidth(item: NativeListItem) = dp(280) protected open val defaultCornerRadius = 0 + protected open val defaultSeparatorInset = 12 protected open val pressChangesBackground = true protected open fun pressContent(pressed: Boolean) {} @@ -96,6 +117,9 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th ) = item.json.optString("groupPosition") private var selected = false + protected val rowSelected + get() = selected + protected var sourceScale = false private var inputSignature = "" private var explicitHeight: Int? = null @@ -115,6 +139,7 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th init { orientation = HORIZONTAL setOnClickListener { + if (consumesRowClick()) return@setOnClickListener current ?.takeIf { it.isRowPressEnabled } ?.let { onRowPress?.invoke(it, NativeListActionOrigin(this, this, bindingEpoch, "row")) } @@ -125,6 +150,7 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> touchPressed = false } + onRowTouch(event) appearance() false } @@ -150,7 +176,7 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th .put("sourceScale", useSourceScale) .put("listStyle", listStyle) ) - sourceScale = useSourceScale + sourceScale = usesSourceScale(item, useSourceScale) if (update != NativeListRendererUpdate.UNCHANGED || signature != inputSignature) { if (current != null) onBindingInvalidated?.invoke(this, bindingEpoch) bindingEpoch++ @@ -187,7 +213,7 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th else LayoutParams.MATCH_PARENT height = LayoutParams.WRAP_CONTENT } - contentDescription = item.json.optString("accessibilityLabel", item.json.optString("title")) + contentDescription = accessibilityText(item) setTag( com.facebook.react.R.id.react_test_id, item.json.optString("testID").takeIf { it.isNotEmpty() }, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index 6c9cb996e..93460f304 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -113,7 +113,7 @@ internal object NativeListFonts { internal data class NativeSelectionTarget(val scope: String, val key: String?) -private class DottedUnderlineTextView(context: android.content.Context) : NativeListTextView(context) { +internal class DottedUnderlineTextView(context: android.content.Context) : NativeListTextView(context) { var useSourceScale = false private fun scaledDp(value: Float) = if (useSourceScale) value * resources.displayMetrics.density else NativeListScale.dp(resources, value) var showsDottedUnderline = false @@ -136,7 +136,7 @@ private class DottedUnderlineTextView(context: android.content.Context) : Native } } -private class PackedTitleLineLayout(context: android.content.Context) : LinearLayout(context) { +internal class PackedTitleLineLayout(context: android.content.Context) : LinearLayout(context) { var packsChildrenAtStart = false // OneKey patch: optional cap for the Market subtitle's localized name. var leadingTextMaxWidth = Int.MAX_VALUE @@ -181,7 +181,7 @@ private class PackedTitleLineLayout(context: android.content.Context) : LinearLa } // OneKey patch: fit subtitle segments at intrinsic width, shrinking text only when necessary. -private class SelectorSubtitleLayout(context: android.content.Context) : LinearLayout(context) { +internal class SelectorSubtitleLayout(context: android.content.Context) : LinearLayout(context) { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val available = MeasureSpec.getSize(widthMeasureSpec) val labels = mutableListOf>() diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt new file mode 100644 index 000000000..e78026075 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt @@ -0,0 +1,741 @@ +package com.margelo.nitro.nativelist + +import android.graphics.Paint +import android.graphics.drawable.GradientDrawable +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.TextUtils +import android.text.style.AbsoluteSizeSpan +import android.text.style.ForegroundColorSpan +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.widget.TextViewCompat +import com.facebook.react.uimanager.ThemedReactContext +import kotlin.math.roundToInt +import org.json.JSONArray +import org.json.JSONObject + +internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : + NativeListRendererRowView(context) { + private val mainColumn = LinearLayout(context) + private val titleLine = PackedTitleLineLayout(context) + private val title = DottedUnderlineTextView(context) + private val subtitle = NativeListTextView(context) + private val trailingColumn = LinearLayout(context) + private val trailingViews = listOf(NativeListTextView(context)) + private val checkboxControl = NativeListAccessoryStack(context) + private val headerTitleIcon = OneKeyIconView(context) + private val headerValueIcon = OneKeyIconView(context) + private var currentItem: NativeListItem? = null + private var currentTheme: JSONObject? = null + private var currentLayout = "linear" + private val selectorTextMetrics = mutableMapOf>() + private val paintDefaults = mutableMapOf() + private val selectorAccessibilityDelegate = + object : View.AccessibilityDelegate() { + override fun onInitializeAccessibilityNodeInfo( + host: View, + info: android.view.accessibility.AccessibilityNodeInfo, + ) { + super.onInitializeAccessibilityNodeInfo(host, info) + info.viewIdResourceName = host.getTag(com.facebook.react.R.id.react_test_id) as? String + } + } + + override fun usesSourceScale(item: NativeListItem, provided: Boolean) = + item.usesSelectorSourceScale || provided + + override fun backgroundGroupPosition( + item: NativeListItem, + layout: String, + itemIndex: Int?, + selected: Boolean, + ) = + if (layout == "sectioned" && !item.json.optBoolean("selected")) "" + else item.json.optString("groupPosition") + + override fun defaultHeight(item: NativeListItem, layout: String) = + when { + item.json.optString("presentation") == "networkSelector" -> 44 + layout == "table" -> 28 + item.json.optString("variant") == "summary" -> 80 + item.json.optString("variant") == "gallery" -> 32 + item.json.optString("variant") == "history" || + item.sectionKey?.startsWith("history-") == true -> 16 + item.sectionKey in setOf("linear-tokens", "action-tokens") -> 30 + item.json.optString("value").isNotEmpty() && item.json.optJSONObject("checkbox") != null -> 40 + else -> 36 + } + + override fun minimumContentHeight(item: NativeListItem, layout: String, sizeDelta: Int) = + dp( + (defaultHeight(item, layout) + + if (item.json.optString("variant") in setOf("summary", "gallery")) 0 else sizeDelta) + .coerceAtLeast(0) + ) + + override fun modelHeight(item: NativeListItem, layout: String): Int? { + if (!item.json.has("height")) return null + if (item.json.optString("heightRounding") in setOf("floor", "nearest")) + return super.modelHeight(item, layout) + if ( + item.json.optString("presentation") == "networkSelector" && + item.json.optString("variant") != "summary" && + item.json.optJSONObject("checkbox") == null + ) { + val px = item.json.optDouble("height") * resources.displayMetrics.density + return if (item.json.optString("titleActionKey").isEmpty()) px.toInt() + else + kotlin.math + .ceil(px * (if (sourceScale) 1.0 else NativeListScale.factor(resources).toDouble())) + .toInt() + } + return super.modelHeight(item, layout) + } + + init { + mainColumn.orientation = VERTICAL + mainColumn.gravity = Gravity.CENTER_VERTICAL + titleLine.orientation = HORIZONTAL + titleLine.gravity = Gravity.CENTER_VERTICAL + titleLine.addView(title, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + mainColumn.addView(titleLine) + mainColumn.addView(subtitle) + trailingColumn.addView(trailingViews[0]) + trailingColumn.addView(checkboxControl) + checkboxControl.onAction = { key, view, slot, target -> + emitAction(key, view, "trailingAccessory", slot, target) + } + } + + private fun color(theme: JSONObject?, key: String, fallback: String) = + parseNativeListColor(theme?.optString(key, fallback) ?: fallback) + + private fun safeColor(value: String?, fallback: Int) = + runCatching { parseNativeListColor(value ?: "") }.getOrDefault(fallback) + + private fun roundedFill(color: Int, radius: Float) = + GradientDrawable().apply { + setColor(color) + cornerRadius = + if (sourceScale) radius * resources.displayMetrics.density + else NativeListScale.dp(resources, radius) + } + + private fun sp(value: Float) = if (sourceScale) value else NativeListScale.font(resources, value) + + private fun wrap() = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + + private fun weighted() = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) + + private fun showText(view: TextView, text: String, lines: Int) { + view.text = text + view.maxLines = lines + view.visibility = if (text.isEmpty()) GONE else VISIBLE + } + + private fun emitAction( + item: NativeListItem, + key: String, + target: NativeSelectionTarget?, + view: View, + source: String, + slot: Int? = null, + ) { + emitAction(key, view, source, slot, target) + } + + override fun bindContent( + item: NativeListItem, + theme: JSONObject?, + layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + selectorTextMetrics.clear() + currentItem = item + currentTheme = theme + currentLayout = layout + removeAllViews() + titleLine.removeView(headerTitleIcon) + trailingColumn.removeView(headerValueIcon) + paintDefaults.forEach { (view, flags) -> view.paintFlags = flags } + paintDefaults.clear() + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding( + dp(if (layout == "table") 16 else 12), + dp(8), + dp(if (layout == "table") 16 else 12), + dp(8), + ) + titleLine.packsChildrenAtStart = false + titleLine.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + title.layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) + for (view in listOf(title, subtitle) + trailingViews) { + view.text = "" + view.visibility = GONE + view.setLineSpacing(0f, 1f) + view.maxLines = 1 + view.ellipsize = null + view.setHorizontallyScrolling(false) + view.opticalOffsetY = 0f + view.gravity = Gravity.START + view.letterSpacing = 0f + view.fontFeatureSettings = "tnum" + view.includeFontPadding = false + view.background = null + view.setPadding(0, 0, 0, 0) + view.setOnClickListener(null) + view.isClickable = false + } + title.useSourceScale = sourceScale + title.showsDottedUnderline = false + subtitle.textSize = sp(14f) + subtitle.typeface = NativeListFonts.regular(context) + subtitle.setTextColor(color(theme, "secondaryText", "#0000009B")) + subtitle.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + trailingViews[0].apply { + typeface = NativeListFonts.medium(context) + textSize = sp(16f) + gravity = Gravity.END or Gravity.CENTER_VERTICAL + setTextColor(color(theme, "primaryText", "#000000DF")) + setTag(com.facebook.react.R.id.react_test_id, null) + layoutParams = wrap() + } + trailingColumn.orientation = VERTICAL + trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL + checkboxControl.reset() + checkboxControl.visibility = GONE + checkboxControl.layoutParams = wrap() + headerTitleIcon.useSourceScale = sourceScale + headerValueIcon.useSourceScale = sourceScale + bindSectionHeader(item, theme, checkboxState) + val variant = item.json.optString("variant") + val network = item.json.optString("presentation") == "networkSelector" + title.textSize = + sp( + when { + network && variant != "summary" -> 14f + variant == "gallery" -> 18f + variant == "summary" -> 16f + layout == "table" -> 11f + variant == "history" || item.sectionKey?.startsWith("history-") == true -> 12f + else -> 14f + } + ) + title.typeface = + when { + network && + item.json.has("height") && + variant != "summary" && + (item.json.optJSONObject("checkbox") != null || + item.json.optString("titleActionKey").isEmpty()) -> NativeListFonts.semibold(context) + network -> NativeListFonts.medium(context) + layout == "table" -> NativeListFonts.regular(context) + variant == "summary" -> NativeListFonts.medium(context) + item.sectionKey in setOf("linear-tokens", "action-tokens") -> + NativeListFonts.regular(context) + else -> NativeListFonts.semibold(context) + } + TextViewCompat.setLineHeight( + title, + dp( + when { + network && variant != "summary" -> 20 + variant in setOf("gallery", "summary") -> 24 + layout == "table" -> 14 + variant == "history" || item.sectionKey?.startsWith("history-") == true -> 16 + else -> 20 + } + ), + ) + if (layout == "table") TextViewCompat.setLineHeight(trailingViews[0], dp(14)) + if ( + network && + item.json.has("height") && + variant != "summary" && + item.json.optString("titleActionKey").isEmpty() && + item.json.optJSONObject("checkbox") == null + ) { + val inset = (20 * resources.displayMetrics.density).toInt() - dp(8) + setPadding(inset, 0, inset, 0) + } + val style = item.json.optJSONObject("style") ?: JSONObject() + if (style.has("horizontalPadding")) { + val hp = stylePx(style.optDouble("horizontalPadding")) + setPadding(hp, paddingTop, hp, paddingBottom) + } + if (style.has("verticalPadding")) { + val vp = stylePx(style.optDouble("verticalPadding")) + setPadding(paddingLeft, vp, paddingRight, vp) + } + if (style.has("lineGap")) + (subtitle.layoutParams as LayoutParams).topMargin = stylePx(style.optDouble("lineGap")) + if (style.has("trailingGap")) { + var previous = false + for (i in 0 until trailingColumn.childCount) { + val child = trailingColumn.getChildAt(i) + if (child.visibility == GONE) continue + val params = child.layoutParams as LayoutParams + if (trailingColumn.orientation == HORIZONTAL) + params.marginStart = if (previous) stylePx(style.optDouble("trailingGap")) else 0 + else params.topMargin = if (previous) stylePx(style.optDouble("trailingGap")) else 0 + previous = true + } + } + applyTextStyle(item) + style + .optJSONObject("container") + ?.optString("contentVerticalAlignment") + ?.takeIf(String::isNotEmpty) + ?.let { + gravity = + when (it) { + "top" -> Gravity.TOP + "bottom" -> Gravity.BOTTOM + else -> Gravity.CENTER_VERTICAL + } + } + applySelectorTypography(item) + } + + private fun applyTextStyle(item: NativeListItem) { + val style = item.json.optJSONObject("style") ?: return + for ((slot, view) in + listOf("title" to title, "subtitle" to subtitle, "value" to trailingViews[0])) style + .optJSONObject(slot) + ?.let { applyStyledText(view, it) } + } + + private fun bindCheckbox( + item: NativeListItem, + data: JSONObject, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + checkboxControl.bind( + item, + JSONArray().put(data), + currentTheme, + JSONObject(), + sourceScale, + checkboxState, + ) + } + + override fun bindSelectionContent( + item: NativeListItem, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + currentItem = item + checkboxControl.updateSelection(item, checkboxState) + } + + override fun bindStableSummary(item: NativeListItem) { + if (item.json.optString("variant") != "summary" || !retainBoundItem(item)) return + currentItem = item + contentDescription = accessibilityText(item) + title.text = item.json.optString("title") + trailingViews[0].text = item.json.optString("value") + applyValueSegments(trailingViews[0], item.json.optJSONArray("valueSegments")) + applyTextStyle(item) + applySelectorTypography(item) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val item = currentItem ?: return + if ( + item.json.has("height") && + item.json.optString("presentation") == "networkSelector" && + checkboxControl.visibility == VISIBLE && + trailingColumn.parent === this + ) { + val density = resources.displayMetrics.density + val values = trailingViews.filter { it.visibility == VISIBLE } + val trailing = values.sumOf { it.measuredWidth } + (20 + 12 * values.size) * density + val sourceLeft = (measuredWidth - 12 * density - trailing).roundToInt() + val measuredLeft = measuredWidth - paddingRight - trailingColumn.measuredWidth + mainColumn.measure( + MeasureSpec.makeMeasureSpec( + (mainColumn.measuredWidth + sourceLeft - measuredLeft).coerceAtLeast(0), + MeasureSpec.EXACTLY, + ), + MeasureSpec.makeMeasureSpec(mainColumn.measuredHeight, MeasureSpec.EXACTLY), + ) + } + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + val item = currentItem ?: return + if ( + item.json.has("height") && + item.json.optString("presentation") == "networkSelector" && + item.json.optString("variant") == "summary" && + item.json + .optJSONObject("style") + ?.optJSONObject("container") + ?.has("contentVerticalAlignment") != true + ) { + val margins = trailingColumn.layoutParams as MarginLayoutParams + val available = height - paddingTop - paddingBottom - margins.topMargin - margins.bottomMargin + trailingColumn.offsetTopAndBottom( + paddingTop + margins.topMargin + (available - trailingColumn.height + 1) / 2 - + trailingColumn.top + ) + } + } + + override fun recycleContent() { + checkboxControl.reset() + currentItem = null + } + + override fun disposeContent() { + recycleContent() + } + + private fun bindSectionHeader( + item: NativeListItem, + theme: JSONObject?, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + val variant = item.json.optString("variant") + val isSummary = variant == "summary" + val isGallery = variant == "gallery" + val isTable = currentLayout == "table" + // OneKey patch: title help emits its own frame instead of toggling the section. + item.json + .optString("titleActionKey") + .takeIf { it.isNotEmpty() } + ?.let { key -> + title.setOnClickListener { emitAction(item, key, null, title, "leadingAction") } + } + val isNetworkSelector = item.json.optString("presentation") == "networkSelector" + val isHistory = variant == "history" || item.sectionKey?.startsWith("history-") == true + val isTokenManager = item.sectionKey in setOf("linear-tokens", "action-tokens") + val isExplicitNetworkHeader = isNetworkSelector && item.json.has("height") + val hasDottedTitle = + isSummary || + (isNetworkSelector && + (!isExplicitNetworkHeader || item.json.optString("titleActionKey").isNotEmpty())) || + (item.json.optString("value").isNotEmpty() && item.json.optJSONObject("checkbox") != null) + // Linear/sectioned snapshots reserve the ListItem mx=8 at RecyclerView + // level, so header-local insets below are source px minus that outer inset. + val headerHorizontalInset = 12 + addView(mainColumn, weighted()) + showText(title, item.json.optString("title"), 1) + if (hasDottedTitle) { + title.showsDottedUnderline = true + title.dottedUnderlineColor = color(theme, "secondaryText", "#0000009B") + title.setPadding(0, 0, 0, dp(3)) + } + title.setTextColor( + color( + theme, + if (isSummary || isGallery) "primaryText" else "secondaryText", + if (isSummary || isGallery) "#000000DF" else "#0000009B", + ) + ) + if (isNetworkSelector) { + val verticalInset = + if (isExplicitNetworkHeader && item.json.optString("titleActionKey").isEmpty()) 8 else 12 + setPadding( + dp(headerHorizontalInset), + dp(verticalInset), + dp(headerHorizontalInset), + dp(verticalInset), + ) + title.textSize = sp(14f) + title.typeface = NativeListFonts.medium(context) + TextViewCompat.setLineHeight(title, dp(20)) + } else if (isHistory) { + setPadding(0, 0, 0, 0) + title.text = item.json.optString("title").uppercase() + title.textSize = sp(12f) + title.typeface = NativeListFonts.semibold(context) + title.letterSpacing = 0.8f / sp(12f) + TextViewCompat.setLineHeight(title, dp(16)) + } else if (isTokenManager) { + setPadding(dp(headerHorizontalInset), dp(10), dp(headerHorizontalInset), 0) + title.textSize = sp(14f) + title.typeface = NativeListFonts.regular(context) + TextViewCompat.setLineHeight(title, dp(20)) + } else if (!isTable && !isGallery && !isSummary) { + // SectionList.SectionHeader: h=36, px=20, headingSm 14/20 semibold. + setPadding(dp(headerHorizontalInset), dp(8), dp(headerHorizontalInset), dp(8)) + TextViewCompat.setLineHeight(title, dp(20)) + } else if (isTable) { + setPadding(dp(16), dp(12), dp(16), dp(2)) + title.includeFontPadding = false + title.layoutParams = wrap() + titleLine.layoutParams = wrap() + item.json.optJSONObject("titleIcon")?.let { icon -> + headerTitleIcon.visibility = VISIBLE + headerTitleIcon.iconName = icon.optString("name") + headerTitleIcon.tintColor = + safeColor(icon.optString("tintColor"), color(theme, "iconSubdued", "#00000072")) + titleLine.addView( + headerTitleIcon, + LayoutParams(dp(12), dp(12)).apply { marginStart = dp(4) }, + ) + if (icon.optString("name") != "ChevronGrabberVerOutline") { + title.setTextColor(color(theme, "primaryText", "#000000DF")) + } + } + } + showText(subtitle, item.json.optString("subtitle"), 1) + if (isGallery) { + setPadding(dp(12), 0, dp(12), dp(8)) + } else if (isSummary) { + addView(trailingColumn, wrap()) + // NetworkListHeader: outer mt=16/pb=12, inner XStack px=20/py=8. + setPadding(dp(headerHorizontalInset), dp(24), dp(headerHorizontalInset), dp(20)) + showTrailing( + 0, + item.json.optString("value"), + true, + item.json.optString("valueActionKey"), + color(theme, "secondaryText", "#0000009B"), + ) + trailingViews[0].setTag( + com.facebook.react.R.id.react_test_id, + item.json.optString("valueActionTestID").takeIf { it.isNotEmpty() }, + ) + trailingViews[0].accessibilityDelegate = selectorAccessibilityDelegate + trailingViews[0].textSize = sp(16f) + trailingViews[0].typeface = NativeListFonts.medium(context) + TextViewCompat.setLineHeight(trailingViews[0], dp(24)) + // OneKey patch: the migrated media button shares the original 24-point text box. + if (isExplicitNetworkHeader) trailingViews[0].setPadding(0, 0, 0, 0) + else trailingViews[0].setPadding(dp(14), dp(6), dp(14), dp(6)) + trailingViews[0].layoutParams = wrap() + } else if (!isGallery && !isHistory && !isTokenManager) { + val value = item.json.optString("value") + val checkboxData = item.json.optJSONObject("checkbox") + if (isExplicitNetworkHeader && checkboxData != null) { + // OneKey patch: the asset section title reserves its original 8dp trailing margin. + (mainColumn.layoutParams as LayoutParams).marginEnd = dp(8) + } + if (checkboxData != null && value.isNotEmpty()) { + // Value and checkbox share the trailing edge as one compound accessory. + trailingColumn.orientation = HORIZONTAL + trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL + trailingViews[0].layoutParams = wrap() + checkboxControl.layoutParams = LayoutParams(dp(20), dp(20)).apply { marginStart = dp(12) } + } + addView(trailingColumn, wrap()) + showTrailing(0, value, true) + if (isTable) { + trailingColumn.orientation = HORIZONTAL + trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL + trailingViews[0].includeFontPadding = false + trailingViews[0].textSize = sp(11f) + trailingViews[0].typeface = NativeListFonts.regular(context) + trailingViews[0].setTextColor(color(theme, "secondaryText", "#0000009B")) + item.json.optJSONObject("valueIcon")?.let { icon -> + headerValueIcon.visibility = VISIBLE + headerValueIcon.iconName = icon.optString("name") + headerValueIcon.tintColor = + safeColor(icon.optString("tintColor"), color(theme, "iconSubdued", "#00000072")) + trailingColumn.addView( + headerValueIcon, + LayoutParams(dp(12), dp(12)).apply { marginStart = dp(4) }, + ) + if (icon.optString("name") != "ChevronGrabberVerOutline") { + trailingViews[0].setTextColor(color(theme, "primaryText", "#000000DF")) + } + } + } + checkboxData?.let { bindCheckbox(item, it, checkboxState) } + } + applyValueSegments(trailingViews[0], item.json.optJSONArray("valueSegments")) + } + + private fun showTrailing( + index: Int, + value: String, + primary: Boolean, + actionKey: String? = null, + textColor: Int? = null, + ) { + if (index !in trailingViews.indices || value.isEmpty()) return + val view = trailingViews[index] + view.maxLines = 1 + view.text = value + view.textSize = sp(if (primary) 16f else 14f) + view.typeface = + if (primary) NativeListFonts.medium(context) else NativeListFonts.regular(context) + view.includeFontPadding = false + view.fontFeatureSettings = "tnum" + TextViewCompat.setLineHeight(view, dp(if (primary) 24 else 20)) + textColor?.let(view::setTextColor) + view.visibility = VISIBLE + if (!actionKey.isNullOrEmpty()) { + view.setOnClickListener { + currentItem + ?.takeUnless { item -> item.json.optBoolean("disabled", false) } + ?.let { item -> emitAction(item, actionKey, null, view, "trailingAccessory", index) } + } + } + } + + private fun applyValueSegments( + view: TextView, + segments: JSONArray?, + fontSize: Int = 16, + lineHeight: Int = 24, + medium: Boolean = true, + ) { + if (segments == null || segments.length() == 0) return + val value = SpannableStringBuilder() + for (index in 0 until segments.length()) { + val segment = segments.getJSONObject(index) + val start = value.length + value.append(segment.optString("text")) + if (segment.optString("style") == "subscript") { + val size = kotlin.math.ceil(fontSize * 0.6).toFloat() + val span = + if (sourceScale) + AbsoluteSizeSpan( + kotlin.math.ceil((size * resources.displayMetrics.density).toDouble()).toInt(), + false, + ) + else AbsoluteSizeSpan(sp(size).roundToInt(), true) + value.setSpan(span, start, value.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + view.textSize = sp(fontSize.toFloat()) + view.typeface = + if (medium) NativeListFonts.medium(context) else NativeListFonts.regular(context) + TextViewCompat.setLineHeight(view, dp(lineHeight)) + view.text = value + } + + private fun applyStyledText(view: TextView, style: JSONObject) { + if (style.has("fontSize")) { + view.setTextSize( + TypedValue.COMPLEX_UNIT_PX, + (style.optDouble("fontSize") * resources.displayMetrics.density).toFloat(), + ) + val text = SpannableStringBuilder(view.text) + text.getSpans(0, text.length, AbsoluteSizeSpan::class.java).forEach(text::removeSpan) + view.text = text + } + style.optString("fontWeight").takeIf(String::isNotEmpty)?.let { + view.typeface = + when (it) { + "medium" -> NativeListFonts.medium(context) + "semibold" -> NativeListFonts.semibold(context) + "bold" -> NativeListFonts.bold(context) + else -> NativeListFonts.regular(context) + } + } + style.optString("color").takeIf(String::isNotEmpty)?.let { + val color = safeColor(it, view.currentTextColor) + view.setTextColor(color) + val text = SpannableStringBuilder(view.text) + text.getSpans(0, text.length, ForegroundColorSpan::class.java).forEach(text::removeSpan) + view.text = text + } + if (style.has("lineHeight")) { + val text = SpannableStringBuilder(view.text) + text.getSpans(0, text.length, NativeListLineHeightSpan::class.java).forEach(text::removeSpan) + text.setSpan( + NativeListLineHeightSpan(stylePx(style.optDouble("lineHeight"))), + 0, + text.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + view.setLineSpacing(0f, 1f) + view.text = text + } + if (style.has("lines")) { + view.maxLines = style.optInt("lines").coerceIn(1, 3) + view.ellipsize = TextUtils.TruncateAt.END + } + if (style.has("truncate") || style.has("lines")) { + view.setHorizontallyScrolling(view.maxLines == 1) + view.ellipsize = + if (style.optString("truncate", "tail") == "clip") null else TextUtils.TruncateAt.END + if (view.maxLines == 1) { + val text = SpannableStringBuilder(view.text) + Regex("\\r\\n|[\\r\\n]").findAll(text).toList().asReversed().forEach { match -> + text.replace(match.range.first, match.range.last + 1, " ") + } + view.text = text + } + } + if (style.has("verticalAlignment")) { + view.gravity = + (view.gravity and Gravity.VERTICAL_GRAVITY_MASK.inv()) or + when (style.optString("verticalAlignment")) { + "top" -> Gravity.TOP + "bottom" -> Gravity.BOTTOM + else -> Gravity.CENTER_VERTICAL + } + } + if (style.has("offsetY")) + (view as? NativeListTextView)?.opticalOffsetY = + (style.optDouble("offsetY") * resources.displayMetrics.density).toFloat() + style.optString("alignment").takeIf(String::isNotEmpty)?.let { + view.gravity = + (view.gravity and Gravity.VERTICAL_GRAVITY_MASK) or + when (it) { + "center" -> Gravity.CENTER_HORIZONTAL + "end" -> Gravity.END + else -> Gravity.START + } + } + } + + private fun applySelectorTypography(item: NativeListItem) { + if ( + item.json.optString("presentation") !in + setOf("accountSelector", "networkSelector", "walletSidebar") + ) + return + fun visit(view: View) { + if (view is TextView) { + view.fontFeatureSettings = "tnum" + if (sourceScale) { + paintDefaults.putIfAbsent(view, view.paintFlags) + view.paintFlags = view.paintFlags or Paint.SUBPIXEL_TEXT_FLAG or Paint.LINEAR_TEXT_FLAG + val (fontSize, line) = + selectorTextMetrics.getOrPut(view) { view.textSize to view.lineHeight } + view.setTextSize( + TypedValue.COMPLEX_UNIT_PX, + kotlin.math + .ceil( + (fontSize * resources.displayMetrics.density / + resources.displayMetrics.scaledDensity) + .toDouble() + ) + .toFloat(), + ) + view.letterSpacing = 0f + if (view.text.isNotEmpty()) { + val text = SpannableStringBuilder(view.text) + text + .getSpans(0, text.length, NativeListLineHeightSpan::class.java) + .forEach(text::removeSpan) + text.setSpan( + NativeListLineHeightSpan(line), + 0, + text.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + view.setLineSpacing(0f, 1f) + view.text = text + } + } + } + if (view is ViewGroup) for (i in 0 until view.childCount) visit(view.getChildAt(i)) + } + visit(this) + } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt index dc9003790..6161f76ad 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt @@ -232,7 +232,7 @@ class NativeListView( private val sectionIndexPreview = TextView(context) private var config: NativeListConfig? = null private var usesSelectorSourceScale = false - private val stickyHeaderView = NativeListRowView(reactContext) + private val stickyHeaderView = NativeListRendererRegistry.create(reactContext, NativeListRendererKey.SECTION_HEADER.ordinal) private var stickyHeaderSignature: String? = null private var stickyHeaderConfig: NativeListConfig? = null private val stickyHeaderHost = object : FrameLayout(reactContext) { From a3eeec53851671b66a42e40895bc66ab21fa8cce Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 21:48:35 +0800 Subject: [PATCH 50/71] refactor(native-list): register complex iOS row hosts --- .../ios/NativeListAccessoryStack.swift | 29 +- .../ios/NativeListCell.swift | 2 +- .../ios/NativeListIdentityRenderer.swift | 687 +++++++++++++++++ .../ios/NativeListLeadingVisual.swift | 57 +- .../ios/NativeListMarketRenderer.swift | 699 ++++++++++++++++++ .../ios/NativeListModels.swift | 2 +- .../ios/NativeListRendererCell.swift | 28 +- .../ios/NativeListRendererRegistry.swift | 5 + .../ios/NativeListSectionHeaderRenderer.swift | 654 ++++++++++++++++ .../ios/RNCNativeListView.swift | 75 +- 10 files changed, 2154 insertions(+), 84 deletions(-) create mode 100644 native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift create mode 100644 native-views/react-native-native-list/ios/NativeListMarketRenderer.swift create mode 100644 native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift diff --git a/native-views/react-native-native-list/ios/NativeListAccessoryStack.swift b/native-views/react-native-native-list/ios/NativeListAccessoryStack.swift index 1e0fd3c84..5c805cca8 100644 --- a/native-views/react-native-native-list/ios/NativeListAccessoryStack.swift +++ b/native-views/react-native-native-list/ios/NativeListAccessoryStack.swift @@ -18,6 +18,7 @@ final class NativeListAccessoryStack: UIStackView { private var checkboxUncheckedColor = UIColor.white private var checkboxIconColor = UIColor.white private var checkboxBorderColor = UIColor.lightGray + var firstVisibleControl: UIView? { accessoryButtons.first { !$0.isHidden } } var endInset: CGFloat? var contentGap: CGFloat? var onAction: ((String, UIView, Int?, NativeSelectionTarget?) -> Void)? @@ -50,22 +51,31 @@ final class NativeListAccessoryStack: UIStackView { func bind( _ item: NativeListItem, descriptors: [[String: Any]], theme: [String: Any]?, - style: [String: Any], checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + style: [String: Any], defaultSpacing: CGFloat = 2, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String ) { reset() currentItem = item currentTheme = theme - spacing = CGFloat(style.double("trailingGap", default: 2)) + spacing = CGFloat(style.double("trailingGap", default: Double(defaultSpacing))) checkboxCheckedColor = nativeListColor(theme, "primaryText", "#202020") checkboxUncheckedColor = nativeListColor(theme, "inverseText", "#FCFCFC") checkboxIconColor = checkboxUncheckedColor checkboxBorderColor = UIColor(nativeListHex: "#00000031", fallback: .lightGray) + if item.data.string("presentation") == "networkSelector" { + checkboxCheckedColor = nativeListColor(theme, "checkboxBackground", "#202020") + checkboxBorderColor = nativeListColor(theme, "checkboxBorder", "#00000031") + checkboxIconColor = nativeListColor(theme, "checkboxIcon", "#FFFFFF") + checkboxUncheckedColor = checkboxIconColor + } accessoryButtons.forEach { $0.setTitleColor(nativeListColor(theme, "primaryText", "#202020"), for: .normal) } bindAccessories(item, descriptors, theme, checkboxState) - if let value = style.dictionary("value"), let button = semanticValueButtons.first { - applyStyledButton(button, value) + for (index, key) in ["value", "valueSecondary"].enumerated() { + if let value = style.dictionary(key), semanticValueButtons.indices.contains(index) { + applyStyledButton(semanticValueButtons[index], value) + } } isHidden = arrangedSubviews.allSatisfy { $0.isHidden } } @@ -74,10 +84,16 @@ final class NativeListAccessoryStack: UIStackView { checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String ) { currentItem = item - if let data = boundCheckboxData { bindCheckbox(item, data, checkboxState) } + if boundCheckboxData != nil, + let data = item.data.dictionaries("trailing").last(where: { $0.string("kind") == "checkbox" }) + ?? item.data.dictionary("checkbox") + { + bindCheckbox(item, data, checkboxState) + } } func anchorInset(for view: UIView) -> CGFloat { - currentItem?.data.string("presentation") == "accountSelector" && accessoryButtons.contains { $0 === view } && view.bounds.width == 38 ? 7 : 0 + currentItem?.data.string("presentation") == "accountSelector" + && accessoryButtons.contains { $0 === view } && view.bounds.width == 38 ? 7 : 0 } func reset() { NSLayoutConstraint.deactivate(accessorySizeConstraints) @@ -102,6 +118,7 @@ final class NativeListAccessoryStack: UIStackView { button.contentHorizontalAlignment = .center button.contentVerticalAlignment = .center button.rowTextOffsetY = 0 + button.transform = .identity button.marketLineHeight = nil button.selectorSummaryLineHeight = nil button.backgroundColor = .clear diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index 126168cde..1b4eca50e 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -106,7 +106,7 @@ final class NativeListAccessoryButton: UIButton { } } -private final class NativeListDottedUnderlineLabel: NativeListTextLabel { +final class NativeListDottedUnderlineLabel: NativeListTextLabel { var showsDottedUnderline = false { didSet { setNeedsLayout() } } diff --git a/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift b/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift new file mode 100644 index 000000000..a4e523683 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift @@ -0,0 +1,687 @@ +import UIKit + +final class NativeListIdentityCell: NativeListRendererCell { + override class func appliesSizePreset(_ item: NativeListItem) -> Bool { + !["walletSidebar", "networkSelector"].contains(item.data.string("presentation")) + } + override class func measure( + _ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String + ) -> CGFloat? { + if item.data.string("presentation") == "walletSidebar" { + return item.data.dictionaries("badges").isEmpty ? 68 : 92 + } + if item.data.string("presentation") == "networkSelector" { return 47 } + return !item.data.string("tertiary").isEmpty + ? 72 : !item.data.string("subtitle").isEmpty ? 60 : 56 + } + private let visual = NativeListLeadingVisual(frame: .zero) + private var fallbackLabel: UILabel { visual.fallbackTextView } + private let mainStack = UIStackView() + private let titleRowStack = UIStackView() + private let titleLabel = NativeListTextLabel() + private let subtitleLabel = NativeListTextLabel() + private let tertiaryLabel = NativeListTextLabel() + private let badgeLabel = NativeListInsetLabel() + private let trailingStack = NativeListAccessoryStack(frame: .zero) + private let leadingActionButton = UIButton(type: .custom) + private var leadingWidth: NSLayoutConstraint! + private var leadingHeight: NSLayoutConstraint! + private var leftInset: CGFloat = 12 + private var rightInset: CGFloat = -12 + private var topInset: CGFloat = 8 + private var bottomInset: CGFloat = -8 + private var selectorConstraints: [NSLayoutConstraint] = [] + private var selectorViews: [UIView] = [] + private var selectorTypographyRestorers: [() -> Void] = [] + private var semanticBadgeLabels: [UILabel] = [] + private var semanticSubtitleLabels: [UILabel] = [] + private weak var walletBadgeLine: UIStackView? + private var currentItem: NativeListItem? + private var currentTheme: [String: Any]? + private var leadingActionKey: String? + override var assetFields: [String] { ["leading"] } + override var defaultSeparatorInset: CGFloat { 60 } + override var defaultCornerRadius: CGFloat { + guard let item = currentItem else { return 0 } + let presentation = item.data.string("presentation") + if presentation == "walletSidebar", isHighlighted || item.data["height"] != nil { return 20 } + if isHighlighted + || (item.data["height"] != nil + && ["accountSelector", "networkSelector"].contains(presentation)) + { + return 12 + } + return 0 + } + override var defaultCornerCurve: CALayerCornerCurve { + currentItem?.data.string("presentation") == "walletSidebar" ? .continuous : .circular + } + override var defaultVerticalAlignment: String? { + currentItem?.data.string("presentation") == "walletSidebar" ? "center" : nil + } + override init(frame: CGRect) { + super.init(frame: frame) + root.axis = .horizontal + root.alignment = .center + mainStack.axis = .vertical + mainStack.alignment = .fill + mainStack.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal) + mainStack.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + titleRowStack.axis = .horizontal + titleRowStack.alignment = .center + titleRowStack.addArrangedSubview(titleLabel) + titleRowStack.addArrangedSubview(badgeLabel) + [titleRowStack, subtitleLabel, tertiaryLabel].forEach(mainStack.addArrangedSubview) + visual.translatesAutoresizingMaskIntoConstraints = false + leadingWidth = visual.widthAnchor.constraint(equalToConstant: 40) + leadingHeight = visual.heightAnchor.constraint(equalToConstant: 40) + leadingActionButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + leadingWidth, leadingHeight, leadingActionButton.widthAnchor.constraint(equalToConstant: 36), + leadingActionButton.heightAnchor.constraint(equalToConstant: 36), + ]) + leadingActionButton.adjustsImageWhenDisabled = false + leadingActionButton.tintAdjustmentMode = .normal + leadingActionButton.addTarget(self, action: #selector(leadingPressed), for: .touchUpInside) + trailingStack.onAction = { [weak self] key, view, slot, target in + self?.emitAction( + key, from: view, source: "trailingAccessory", slot: slot, target: target, + anchorInset: self?.trailingStack.anchorInset(for: view) ?? 0) + } + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func bindContent( + _ item: NativeListItem, theme: [String: Any]?, layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + selectorTypographyRestorers.reversed().forEach { $0() } + selectorTypographyRestorers = [] + currentItem = item + currentTheme = theme + NSLayoutConstraint.deactivate(selectorConstraints) + selectorConstraints = [] + selectorViews.forEach { + if let stack = $0.superview as? UIStackView { stack.removeArrangedSubview($0) } + $0.removeFromSuperview() + } + selectorViews = [] + walletBadgeLine = nil + semanticBadgeLabels = [] + semanticSubtitleLabels = [] + root.arrangedSubviews.forEach { + root.removeArrangedSubview($0) + $0.removeFromSuperview() + } + root.axis = .horizontal + root.alignment = .center + root.spacing = 12 + leftInset = layout == "table" ? 16 : 12 + rightInset = -leftInset + topInset = 8 + bottomInset = -8 + mainStack.alignment = .fill + mainStack.spacing = 2 + mainStack.arrangedSubviews.forEach { + mainStack.setCustomSpacing(UIStackView.spacingUseDefault, after: $0) + } + titleRowStack.arrangedSubviews.forEach { + titleRowStack.setCustomSpacing(UIStackView.spacingUseDefault, after: $0) + } + titleRowStack.spacing = 8 + titleRowStack.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleRowStack.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + for label in [titleLabel, subtitleLabel, tertiaryLabel, badgeLabel] { + label.attributedText = nil + label.text = nil + label.isHidden = true + label.numberOfLines = 1 + label.textAlignment = .natural + label.lineBreakMode = .byTruncatingTail + label.transform = .identity + label.setContentHuggingPriority(.defaultLow, for: .horizontal) + label.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + if let text = label as? NativeListTextLabel { + text.rowOffsetY = 0 + text.rowVerticalAlignment = nil + } + } + titleLabel.font = nativeListFont(ofSize: 16, weight: .medium) + titleLabel.textColor = nativeListColor(theme, "primaryText", "#202020") + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + subtitleLabel.font = nativeListFont(ofSize: 14) + subtitleLabel.textColor = nativeListColor(theme, "secondaryText", "#646464") + tertiaryLabel.font = nativeListFont(ofSize: 14) + tertiaryLabel.textColor = subtitleLabel.textColor + badgeLabel.font = nativeListFont(ofSize: 12, weight: .medium) + badgeLabel.horizontalInset = 0 + badgeLabel.topInset = 0 + badgeLabel.bottomInset = 0 + badgeLabel.setContentHuggingPriority(.required, for: .horizontal) + leadingWidth.constant = 40 + leadingHeight.constant = 40 + visual.glyphSize = 18 + fallbackLabel.attributedText = nil + fallbackLabel.font = nativeListFont(ofSize: 13, weight: .bold) + trailingStack.reset() + trailingStack.axis = .vertical + trailingStack.alignment = .trailing + trailingStack.spacing = 2 + leadingActionKey = nil + leadingActionButton.setImage(nil, for: .normal) + bindIdentity(item, theme: theme, selected: item.data.bool("selected"), checkboxState) + let style = item.data.dictionary("style") ?? [:] + if style["horizontalPadding"] != nil { + leftInset = CGFloat(style.double("horizontalPadding")) + rightInset = -leftInset + } + if style["verticalPadding"] != nil { + topInset = CGFloat(style.double("verticalPadding")) + bottomInset = -topInset + } + if style["lineGap"] != nil { + mainStack.spacing = CGFloat(style.double("lineGap")) + mainStack.arrangedSubviews.forEach { + mainStack.setCustomSpacing(mainStack.spacing, after: $0) + } + } + if style["leadingGap"] != nil, root.arrangedSubviews.contains(visual) { + root.setCustomSpacing(CGFloat(style.double("leadingGap")), after: visual) + } + if style["titleBadgeGap"] != nil { + if walletBadgeLine != nil { + mainStack.setCustomSpacing(CGFloat(style.double("titleBadgeGap")), after: titleRowStack) + } else { + titleRowStack.setCustomSpacing(CGFloat(style.double("titleBadgeGap")), after: titleLabel) + } + } + if style["trailingGap"] != nil { trailingStack.spacing = CGFloat(style.double("trailingGap")) } + for (slot, labels) in [ + ("title", [titleLabel]), + ("subtitle", semanticSubtitleLabels.isEmpty ? [subtitleLabel] : semanticSubtitleLabels), + ("tertiary", [tertiaryLabel]), + ("badge", semanticBadgeLabels.isEmpty ? [badgeLabel] : semanticBadgeLabels), + ] { + if let text = style.dictionary(slot) { + labels.forEach { NativeListTextStyles.applyStyledText($0, text) } + } + } + contentInsets = UIEdgeInsets( + top: topInset, left: leftInset, bottom: -bottomInset, right: -rightInset) + if root.axis == .horizontal { + root.alignment = + style.dictionary("container")?.string("contentVerticalAlignment") == "top" + ? .top + : style.dictionary("container")?.string("contentVerticalAlignment") == "bottom" + ? .bottom : .center + } + applySelectorTypography(item) + } + override func bindSelectionContent( + _ item: NativeListItem, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + currentItem = item + trailingStack.updateSelection(item, checkboxState: checkboxState) + if item.data.string("presentation") == "walletSidebar" { + let color = nativeListColor( + currentTheme, selectedState ? "primaryText" : "secondaryText", + selectedState ? "#FFFFFFED" : "#FFFFFFAF") + titleLabel.textColor = color + if let original = titleLabel.attributedText { + let text = NSMutableAttributedString(attributedString: original) + text.addAttribute( + .foregroundColor, value: color, range: NSRange(location: 0, length: text.length)) + titleLabel.attributedText = text + } + if let style = item.data.dictionary("style")?.dictionary("title") { + NativeListTextStyles.applyStyledText(titleLabel, style) + } + } + } + override func layoutSubviews() { + super.layoutSubviews() + if currentItem?.data["height"] != nil, + currentItem?.data.string("presentation") == "accountSelector", + currentItem?.data.dictionary("style")?.dictionary("container")?["contentVerticalAlignment"] + == nil, let descriptor = currentItem?.data.dictionaries("trailing").first, + descriptor.string("kind") == "icon", descriptor.string("name") == "PlusSmallOutline", + let control = trailingStack.firstVisibleControl + { + control.transform = .identity + let origin = control.convert(control.bounds, to: contentView).minY + control.transform = CGAffineTransform(translationX: 0, y: 11 - origin) + } + } + override func recycleContent() { + visual.recycle() + trailingStack.reset() + currentItem = nil + } + @objc private func leadingPressed() { + if let key = leadingActionKey { + emitAction(key, from: leadingActionButton, source: "leadingAction") + } + } + private func show(_ label: UILabel, _ value: String, lines: Int) { + label.text = value + label.numberOfLines = lines + label.isHidden = value.isEmpty + } + private func addLeading(_ descriptor: [String: Any]?, key: String) { + root.addArrangedSubview(visual) + guard let descriptor else { + visual.recycle() + return + } + let image = currentItem?.data.dictionary("style")?.dictionary("image") ?? [:] + if image["width"] != nil { leadingWidth.constant = CGFloat(image.double("width")) } + if image["height"] != nil { leadingHeight.constant = CGFloat(image.double("height")) } + let wallet = + currentItem?.data.string("presentation") == "walletSidebar" + && currentItem?.data["height"] != nil + visual.singleOuterMask = + currentItem?.data.string("presentation") == "networkSelector" + && currentItem?.data["height"] != nil + visual.dashedBorderWidth = wallet ? 1 : 2 + visual.overlayTextFontSize = wallet ? 12 : 10 + visual.overlayTextLineHeight = wallet ? 16 : nil + visual.overlayTextHorizontalInset = wallet ? 2 : nil + if let fallback = descriptor.dictionary("fallbackIcon") { + visual.glyphSize = + wallet && fallback.string("name") == "LockSolid" + ? 40 + : descriptor.dictionary("image") != nil + ? min(leadingWidth.constant, leadingHeight.constant) + * (fallback.string("name") == "GlobusOutline" ? 1.2 : 1) : 18 + } + visual.bind(descriptor, style: image, key: key, theme: currentTheme, isUnread: false) + } + private func bindIdentity( + _ item: NativeListItem, + theme: [String: Any]?, + selected: Bool, + _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + if item.data.string("presentation") == "walletSidebar" { + root.axis = .vertical + root.alignment = .center + root.spacing = 4 + leftInset = 4 + rightInset = -4 + topInset = 4 + bottomInset = -4 + mainStack.alignment = .center + mainStack.spacing = 0 + titleRowStack.setContentHuggingPriority(.required, for: .horizontal) + titleLabel.setContentHuggingPriority(.required, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + titleLabel.font = nativeListFont(ofSize: 12) + titleLabel.textAlignment = .center + fallbackLabel.font = nativeListFont(ofSize: 28) + addLeading(item.data.dictionary("leading"), key: item.key) + root.addArrangedSubview(mainStack) + // OneKey patch: activate width constraints only after both stacks share an ancestor. + if item.data["height"] != nil { + titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + titleRowStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + let width = mainStack.widthAnchor.constraint(equalTo: root.widthAnchor) + width.isActive = true + selectorConstraints.append(width) + let titleWidth = titleRowStack.widthAnchor.constraint( + lessThanOrEqualTo: mainStack.widthAnchor) + titleWidth.isActive = true + selectorConstraints.append(titleWidth) + } + show(titleLabel, item.data.string("title"), lines: 1) + setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 16) + titleLabel.textColor = nativeListColor( + theme, + selected ? "primaryText" : "secondaryText", + selected ? "#FFFFFFED" : "#FFFFFFAF" + ) + // OneKey patch: wallet tags belong below the centered name. + let badges = item.data.dictionaries("badges") + if !badges.isEmpty { + let line = UIStackView() + line.axis = .horizontal + line.spacing = 4 + line.alignment = .center + for badge in badges { + let label = NativeListInsetLabel() + let isSelector = item.data["height"] != nil + let isWarning = badge.string("tone") == "warning" + label.font = nativeListFont(ofSize: isSelector ? 11 : 12) + label.textColor = nativeListColor( + theme, isSelector && isWarning ? "caution" : "secondaryText", + isSelector && isWarning ? "#AB6400" : "#646464") + label.backgroundColor = nativeListColor( + theme, + isSelector + ? (isWarning ? "cautionBackground" : "subduedBackground") : "strongBackground", + isSelector && isWarning ? "#FFF8C5" : "#F0F0F0") + label.horizontalInset = isSelector ? 6 : 4 + label.topInset = 2 + label.bottomInset = 2 + label.layer.cornerRadius = 4 + label.clipsToBounds = true + setLineHeight(label, text: badge.string("text"), lineHeight: isSelector ? 14 : 16) + semanticBadgeLabels.append(label) + line.addArrangedSubview(label) + } + walletBadgeLine = line + mainStack.spacing = 4 + mainStack.addArrangedSubview(line) + selectorViews.append(line) + } + return + } + if item.data.string("presentation") == "accountSelector" { + leadingWidth.constant = 32 + leadingHeight.constant = 32 + titleLabel.font = nativeListFont(ofSize: 16) + } + if item.data.dictionary("leading")?.string("kind") == "network" { + leadingWidth.constant = 32 + leadingHeight.constant = 32 + } + if let leadingAction = item.data.dictionary("leadingAction") { + leadingActionButton.isHidden = false + let tintColor = UIColor( + nativeListHex: leadingAction.string("tintColor", default: "#646464"), + fallback: .darkGray + ) + leadingActionButton.tintColor = tintColor + if let image = nativeListIcon(named: leadingAction.string("name")) { + leadingActionButton.setImage(image, for: .normal) + leadingActionButton.setImage( + image.withTintColor(tintColor, renderingMode: .alwaysOriginal), + for: .disabled + ) + } + leadingActionButton.isEnabled = !leadingAction.bool("disabled") + leadingActionButton.alpha = leadingActionButton.isEnabled ? 1 : 0.4 + leadingActionKey = leadingAction.string("actionKey") + root.addArrangedSubview(leadingActionButton) + // ListItem.IconButton is a medium tertiary button: its 36-point frame + // carries m=-7. The collection already contributes ListItem's outer + // mx=8, so move the frame 7 points into that inset and reduce only the + // following gap by 7. This preserves the source button, avatar and text + // positions without shrinking the 24-point SVG glyph or its hit target. + leftInset = 5 + root.setCustomSpacing(5, after: leadingActionButton) + } + addLeading(item.data.dictionary("leading"), key: item.key) + // OneKey patch: custom network initials match LetterAvatar size 32. + if item.data.string("presentation") == "networkSelector", + let leading = item.data.dictionary("leading"), leading.dictionary("image") == nil, + leading.dictionary("fallbackIcon") == nil, !leading.string("fallbackText").isEmpty + { + fallbackLabel.font = nativeListFont(ofSize: 19, weight: .semibold) + fallbackLabel.textColor = nativeListColor(theme, "inverseText", "#FCFCFC") + setLineHeight(fallbackLabel, text: leading.string("fallbackText"), lineHeight: 27) + } + root.addArrangedSubview(mainStack) + show(titleLabel, item.data.string("title"), lines: item.data.int("titleLines", default: 1)) + show( + subtitleLabel, item.data.string("subtitle"), lines: item.data.int("subtitleLines", default: 1) + ) + show(tertiaryLabel, item.data.string("tertiary"), lines: 1) + if !item.data.string("subtitle").isEmpty, item.data.string("tertiary").isEmpty { + mainStack.spacing = 0 + setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) + setLineHeight(subtitleLabel, text: item.data.string("subtitle"), lineHeight: 20) + } + // OneKey patch: preserve independent balance/address truncation and warning tones. + let segments = item.data.dictionaries("subtitleSegments") + if !segments.isEmpty { + subtitleLabel.isHidden = true + mainStack.spacing = 0 + setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) + let line = UIStackView() + line.axis = .horizontal + line.alignment = .center + line.spacing = 0 + for segment in segments { + if segment.bool("separatorBefore") { + let gap = UIView() + gap.translatesAutoresizingMaskIntoConstraints = false + let dot = UIView() + dot.translatesAutoresizingMaskIntoConstraints = false + dot.backgroundColor = nativeListColor(theme, "disabledText", "#8D8D8D") + dot.layer.cornerRadius = 2 + gap.addSubview(dot) + NSLayoutConstraint.activate([ + gap.widthAnchor.constraint(equalToConstant: 16), + gap.heightAnchor.constraint(equalToConstant: 20), + dot.widthAnchor.constraint(equalToConstant: 4), + dot.heightAnchor.constraint(equalToConstant: 4), + dot.centerXAnchor.constraint(equalTo: gap.centerXAnchor), + dot.centerYAnchor.constraint(equalTo: gap.centerYAnchor), + ]) + line.addArrangedSubview(gap) + } + let label = NativeListTextLabel() + label.font = nativeListFont(ofSize: 14) + label.lineBreakMode = .byTruncatingTail + // Match V1: the balance may shrink, but keep the shortened address intact. + label.setContentCompressionResistancePriority( + item.data.string("presentation") == "accountSelector" && segment.bool("separatorBefore") + ? .defaultHigh + : .defaultLow, + for: .horizontal + ) + label.textColor = dataTextColor(segment.string("tone", default: "secondary"), theme: theme) + setLineHeight(label, text: segment.string("text"), lineHeight: 20) + let runs = segment.dictionaries("textSegments") + if !runs.isEmpty { + let value = NSMutableAttributedString(string: "") + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = 20 + paragraph.maximumLineHeight = 20 + for run in runs { + value.append( + NSAttributedString( + string: run.string("text"), + attributes: [ + .font: nativeListFont(ofSize: run.string("style") == "subscript" ? 9 : 14), + .foregroundColor: label.textColor as Any, + .paragraphStyle: paragraph, + .baselineOffset: max(0, (20 - nativeListFont(ofSize: 14).lineHeight) / 2), + ])) + } + label.attributedText = value + } + semanticSubtitleLabels.append(label) + line.addArrangedSubview(label) + } + let filler = UIView() + filler.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal) + line.addArrangedSubview(filler) + mainStack.insertArrangedSubview(line, at: 2) + selectorViews.append(line) + } + let matches = item.data.dictionaries("titleMatch") + if !matches.isEmpty { + let text = NSMutableAttributedString( + attributedString: titleLabel.attributedText + ?? NSAttributedString(string: item.data.string("title"))) + for match in matches { + let start = match.int("start") + let end = match.int("end") + if start >= 0 && end > start && end <= text.length { + text.addAttribute( + .foregroundColor, value: nativeListColor(theme, "info", "#0D74CE"), + range: NSRange(location: start, length: end - start)) + } + } + titleLabel.attributedText = text + } + tertiaryLabel.textColor = nativeListColor( + theme, + item.data.string("tertiaryTone") == "info" ? "info" : "secondaryText", + item.data.string("tertiaryTone") == "info" ? "#0D74CE" : "#646464" + ) + let badges = item.data.dictionaries("badges").prefix(2).map { $0.string("text") } + if !badges.isEmpty { + let badgeText = badges.joined(separator: " · ") + show(badgeLabel, badgeText, lines: 1) + badgeLabel.horizontalInset = 8 + badgeLabel.topInset = 2 + badgeLabel.bottomInset = 2 + setLineHeight(badgeLabel, text: badgeText, lineHeight: 16) + badgeLabel.backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0") + badgeLabel.textColor = nativeListColor(theme, "secondaryText", "#646464") + badgeLabel.layer.cornerRadius = 4 + badgeLabel.clipsToBounds = true + } + root.addArrangedSubview(trailingStack) + let accessories = item.data.dictionaries("trailing") + if accessories.contains(where: { $0.string("kind") == "checkbox" }) + && accessories.contains(where: { $0.string("kind") == "value" }) + { + trailingStack.axis = .horizontal + trailingStack.alignment = .center + trailingStack.spacing = 12 + } else if accessories.count == 2, + accessories[0].string("kind") == "valuePair", + accessories[1].string("kind") == "menu" + { + trailingStack.axis = .horizontal + trailingStack.alignment = .center + trailingStack.spacing = 8 + } else if accessories.count == 2, + accessories[0].string("kind") == "icon", + accessories[0].string("name") == "PencilOutline", + accessories[1].string("kind") == "icon", + accessories[1].string("name") == "DragOutline" + { + trailingStack.axis = .horizontal + trailingStack.alignment = .center + // Each source IconButton has a 36-point frame and m=-7. XStack gap=$6 + // therefore places the physical frames 10 points apart (46-point + // center distance), not 24 points apart. + trailingStack.spacing = 10 + } + trailingStack.bind( + item, descriptors: accessories, theme: theme, style: item.data.dictionary("style") ?? [:], + defaultSpacing: trailingStack.spacing, checkboxState: checkboxState) + if let gap = trailingStack.contentGap { root.setCustomSpacing(gap, after: mainStack) } + if let inset = trailingStack.endInset { rightInset = -inset } + if item.data.string("presentation") == "accountSelector", + accessories.count == 1, + let accessory = trailingStack.firstVisibleControl + { + // The vertical trailing stack has no stable intrinsic width, so keep its + // flexible space in the account title and subtitle column. + let width = trailingStack.widthAnchor.constraint(equalTo: accessory.widthAnchor) + width.isActive = true + selectorConstraints.append(width) + } + } + private func dataTextColor(_ tone: String, theme: [String: Any]?) -> UIColor { + switch tone.isEmpty ? "primary" : tone { + // OneKey patch: account warnings and hidden balances use existing theme tokens. + case "disabled": return nativeListColor(theme, "disabledText", "#8D8D8D") + case "caution": return nativeListColor(theme, "caution", "#AB6400") + case "secondary": return nativeListColor(theme, "secondaryText", "#646464") + case "positive": return nativeListColor(theme, "positive", "#218358") + case "negative": return nativeListColor(theme, "negative", "#CE2C31") + default: return nativeListColor(theme, "primaryText", "#202020") + } + } + private func selectorTabularFont(_ font: UIFont) -> UIFont { + var settings = + font.fontDescriptor.fontAttributes[.featureSettings] as? [[UIFontDescriptor.FeatureKey: Int]] + ?? [] + settings.removeAll { $0[.type] == kNumberSpacingType } + settings.append([.type: kNumberSpacingType, .selector: kMonospacedNumbersSelector]) + return UIFont( + descriptor: font.fontDescriptor.addingAttributes([.featureSettings: settings]), + size: font.pointSize) + } + private func selectorTabularText(_ original: NSAttributedString) -> NSAttributedString { + let result = NSMutableAttributedString(attributedString: original) + // OneKey patch: body typography explicitly supplies letterSpacing=0 in Tamagui. + result.addAttribute(.kern, value: 0, range: NSRange(location: 0, length: result.length)) + original.enumerateAttribute(.font, in: NSRange(location: 0, length: original.length)) { + value, range, _ in + if let font = value as? UIFont { + result.addAttribute(.font, value: self.selectorTabularFont(font), range: range) + } + } + return result + } + private func applySelectorTypography(_ item: NativeListItem) { + guard + ["accountSelector", "networkSelector", "walletSidebar"].contains( + item.data.string("presentation")) + else { return } + func visit(_ view: UIView) { + if let button = view as? UIButton { + if let original = button.attributedTitle(for: .normal) { + selectorTypographyRestorers.append { button.setAttributedTitle(original, for: .normal) } + button.setAttributedTitle(selectorTabularText(original), for: .normal) + } + } else if let label = view as? UILabel, let font = label.font { + let original = label.attributedText + selectorTypographyRestorers.append { + label.font = font + label.attributedText = original + } + label.font = selectorTabularFont(font) + if let original { label.attributedText = selectorTabularText(original) } + } + for child in view.subviews { visit(child) } + } + visit(contentView) + } + private func setLineHeight( + _ label: UILabel, + text: String, + lineHeight: CGFloat, + letterSpacing: CGFloat = 0 + ) { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.minimumLineHeight = lineHeight + paragraphStyle.maximumLineHeight = lineHeight + paragraphStyle.alignment = label.textAlignment + if currentItem?.type == "market" || currentItem?.data.string("presentation") == "walletSidebar" + { + // Attributed paragraphs must preserve the tail ellipsis. + paragraphStyle.lineBreakMode = label.lineBreakMode + } + var attributes: [NSAttributedString.Key: Any] = [ + .font: label.font as Any, + .foregroundColor: label.textColor as Any, + .paragraphStyle: paragraphStyle, + ] + let isNetworkFallback = + label === fallbackLabel && currentItem?.data.string("presentation") == "networkSelector" + if currentItem?.type == "market" + || (currentItem?.data["height"] != nil + && (["accountSelector", "walletSidebar"].contains( + currentItem?.data.string("presentation") ?? "") + || currentItem?.type == "sectionHeader" + && currentItem?.data.string("presentation") == "networkSelector")) + || isNetworkFallback + { + // OneKey patch: React Native centers font metrics inside explicit line heights. + let baselineOffset = max(0, (lineHeight - label.font.lineHeight) / 2) + // OneKey patch: TextKit's 14/20 headings align their baseline to the upper physical pixel. + let isSelectorHeading = + lineHeight == 20 + && (currentItem?.type == "sectionHeader" + && currentItem?.data.string("presentation") == "networkSelector" + || currentItem?.type == "market" && label.font.pointSize == 14) + let scale = window?.screen.scale ?? traitCollection.displayScale + attributes[.baselineOffset] = + isSelectorHeading && scale > 0 ? ceil(baselineOffset * scale) / scale : baselineOffset + } + if letterSpacing != 0 || currentItem?.type == "market" + || currentItem?.data.string("presentation") == "market" + { + attributes[.kern] = letterSpacing + } + label.attributedText = NSAttributedString(string: text, attributes: attributes) + } +} diff --git a/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift b/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift index e3a099d32..bad8e6b6d 100644 --- a/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift +++ b/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift @@ -4,7 +4,14 @@ import UIKit // visual descriptors and request lifetimes, never row types or business keys. final class NativeListLeadingVisual: UIView { var glyphSize: CGFloat = 18 - private let fallback = UILabel() + var bitmapBorderWidth: CGFloat = 0 + private let fallback = NativeListTextLabel() + var fallbackTextView: UILabel { fallback } + var singleOuterMask = false + var dashedBorderWidth: CGFloat = 2 + var overlayTextFontSize: CGFloat = 10 + var overlayTextLineHeight: CGFloat? + var overlayTextHorizontalInset: CGFloat? private let icon = UIImageView() private let unread = UIView() private let networkBackdrop = UIView() @@ -97,6 +104,11 @@ final class NativeListLeadingVisual: UIView { sourceFallback ? UIColor(nativeListHex: placeholder, fallback: .clear) : visualBackground layer.borderWidth = kind == "icon" ? 1 / UIScreen.main.scale : 0 layer.borderColor = UIColor(nativeListHex: "#0000001F", fallback: .clear).cgColor + if bitmapBorderWidth > 0 { + layer.borderWidth = bitmapBorderWidth + layer.borderColor = + UIColor(nativeListHex: visual.string("borderColor"), fallback: .clear).cgColor + } for (index, slot) in slots.enumerated() { guard index < sources.count else { slot.recycle() @@ -162,7 +174,16 @@ final class NativeListLeadingVisual: UIView { let label = frame.subviews.first as? UILabel ?? UILabel() label.text = data.string("text") label.textAlignment = .center - label.font = nativeListFont(ofSize: 10, weight: .medium) + label.font = nativeListFont(ofSize: overlayTextFontSize, weight: .medium) + if let line = overlayTextLineHeight { + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = line + paragraph.maximumLineHeight = line + paragraph.alignment = .center + label.attributedText = NSAttributedString( + string: data.string("text"), + attributes: [.font: label.font as Any, .paragraphStyle: paragraph]) + } label.textColor = UIColor( nativeListHex: data.string("tintColor", default: "#646464"), fallback: .darkGray) child = label @@ -239,13 +260,27 @@ final class NativeListLeadingVisual: UIView { x: CGFloat(index) * (bounds.width - side) / CGFloat(max(1, sources.count - 1)), y: (bounds.height - side) / 2, width: side, height: side) } - slot.view.frame = logical(frame) - slot.view.clipsToBounds = true + slot.view.frame = logical( + index == 0 ? frame.insetBy(dx: bitmapBorderWidth, dy: bitmapBorderWidth) : frame) + slot.view.clipsToBounds = + !(singleOuterMask && sources.count == 1 && overlays.isEmpty + && visual.dictionary("cornerIcon") == nil) slot.view.layer.cornerRadius = - index == 0 && (sources.count == 1 || tokenPair) ? radius : frame.height / 2 + index == 0 && singleOuterMask && sources.count == 1 && overlays.isEmpty + && visual.dictionary("cornerIcon") == nil + ? 0 : index == 0 && (sources.count == 1 || tokenPair) ? radius : frame.height / 2 } for (index, slot) in slots.enumerated() { - if ellipse && index == 0 { + if index == 0 && bitmapBorderWidth > 0 { + let mask = slot.view.layer.mask as? CAShapeLayer ?? CAShapeLayer() + mask.path = + UIBezierPath( + roundedRect: slot.view.bounds.insetBy(dx: -bitmapBorderWidth, dy: -bitmapBorderWidth), + cornerRadius: radius + ).cgPath + slot.view.layer.mask = mask + slot.view.layer.cornerRadius = 0 + } else if ellipse && index == 0 { let mask = slot.view.layer.mask as? CAShapeLayer ?? CAShapeLayer() mask.path = UIBezierPath(ovalIn: slot.view.bounds).cgPath slot.view.layer.mask = mask @@ -273,17 +308,19 @@ final class NativeListLeadingVisual: UIView { frame.layer.cornerRadius = min(width, height) / 2 let inset = CGFloat(data.double("padding", default: 0)) frame.subviews.first?.frame = frame.bounds.insetBy( - dx: inset, - dy: inset) + dx: data["text"] != nil ? overlayTextHorizontalInset ?? inset : inset, + dy: data["text"] != nil && overlayTextHorizontalInset != nil ? 0 : inset) } dashedBorder.isHidden = visual.string("borderStyle") != "dashed" dashedBorder.strokeColor = UIColor(nativeListHex: visual.string("borderColor", default: "#8D8D8D"), fallback: .gray) .cgColor dashedBorder.fillColor = UIColor.clear.cgColor - dashedBorder.lineWidth = 2 + dashedBorder.lineWidth = dashedBorderWidth dashedBorder.lineDashPattern = [4, 4] - dashedBorder.path = UIBezierPath(ovalIn: bounds.insetBy(dx: 1, dy: 1)).cgPath + dashedBorder.path = + UIBezierPath(ovalIn: bounds.insetBy(dx: dashedBorderWidth / 2, dy: dashedBorderWidth / 2)) + .cgPath } func recycle() { slots.forEach { $0.recycle() } diff --git a/native-views/react-native-native-list/ios/NativeListMarketRenderer.swift b/native-views/react-native-native-list/ios/NativeListMarketRenderer.swift new file mode 100644 index 000000000..4e09f323d --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListMarketRenderer.swift @@ -0,0 +1,699 @@ +import UIKit + +final class NativeListMarketCell: NativeListRendererCell { + override class func measure( + _ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String + ) -> CGFloat? { + let style = item.data.dictionary("style") + let stock = item.data.string("variant") == "stock" + return max( + stock ? 72 : 68, + CGFloat( + style?.dictionary("image")?.double("height", default: stock ? 40 : 32) ?? (stock ? 40 : 32)) + + 2 * CGFloat(style?.double("verticalPadding", default: 12) ?? 12)) + } + private let visual = NativeListLeadingVisual(frame: .zero) + private let mainStack = UIStackView() + private let titleRowStack = UIStackView() + private let titleLabel = NativeListTextLabel() + private let subtitleLabel = NativeListTextLabel() + private let tertiaryLabel = NativeListTextLabel() + private let marketSubtitleStack = UIStackView() + private let marketSubtitleSpacer = UIView() + private let trailingStack = UIStackView() + private let accessoryButtons = (0..<2).map { _ in NativeListAccessoryButton(type: .custom) } + private let marketBadgeButtons = (0..<3).map { _ in NativeListAccessoryButton(type: .custom) } + private let badgeSlots = (0..<3).map { _ in NativeListImageSlot() } + private var marketBadgeImages: [UIView] { badgeSlots.map { $0.view } } + private let leadingActionButton = UIButton(type: .custom) + private var leadingWidth: NSLayoutConstraint! + private var leadingHeight: NSLayoutConstraint! + private var selectorConstraints: [NSLayoutConstraint] = [] + private var accessorySizeConstraints: [NSLayoutConstraint] = [] + private var leadingActionKey: String? + private var marketBadgeActionKeys: [String?] = [] + private var imageSignature = "" + override var defaultCornerRadius: CGFloat { isHighlighted ? 12 : 0 } + override var assetFields: [String] { ["leading", "badges"] } + override init(frame: CGRect) { + super.init(frame: frame) + root.axis = .horizontal + root.alignment = .center + mainStack.axis = .vertical + mainStack.alignment = .fill + mainStack.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal) + mainStack.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + titleRowStack.axis = .horizontal + titleRowStack.alignment = .center + titleRowStack.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + titleRowStack.addArrangedSubview(titleLabel) + for (index, button) in marketBadgeButtons.enumerated() { + button.tag = index + button.layer.cornerRadius = 4 + button.clipsToBounds = true + button.addTarget(self, action: #selector(badgePressed(_:)), for: .touchUpInside) + let image = badgeSlots[index].view + image.isUserInteractionEnabled = false + image.translatesAutoresizingMaskIntoConstraints = false + button.addSubview(image) + NSLayoutConstraint.activate([ + image.centerYAnchor.constraint(equalTo: button.centerYAnchor), + image.widthAnchor.constraint(equalToConstant: 14), + image.heightAnchor.constraint(equalToConstant: 14), + ]) + titleRowStack.addArrangedSubview(button) + } + trailingStack.setContentHuggingPriority(.required, for: .horizontal) + trailingStack.setContentCompressionResistancePriority(.required, for: .horizontal) + accessoryButtons.forEach(trailingStack.addArrangedSubview) + visual.translatesAutoresizingMaskIntoConstraints = false + leadingWidth = visual.widthAnchor.constraint(equalToConstant: 32) + leadingHeight = visual.heightAnchor.constraint(equalToConstant: 32) + leadingActionButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + leadingWidth, leadingHeight, leadingActionButton.widthAnchor.constraint(equalToConstant: 36), + leadingActionButton.heightAnchor.constraint(equalToConstant: 36), + ]) + leadingActionButton.adjustsImageWhenDisabled = false + leadingActionButton.tintAdjustmentMode = .normal + leadingActionButton.addTarget(self, action: #selector(leadingPressed), for: .touchUpInside) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func bindContent( + _ item: NativeListItem, theme: [String: Any]?, layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + NSLayoutConstraint.deactivate(selectorConstraints + accessorySizeConstraints) + selectorConstraints = [] + accessorySizeConstraints = [] + root.arrangedSubviews.forEach { + root.removeArrangedSubview($0) + $0.removeFromSuperview() + } + mainStack.arrangedSubviews.forEach { + mainStack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + marketSubtitleStack.arrangedSubviews.forEach { + marketSubtitleStack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + mainStack.alignment = .fill + titleRowStack.setContentHuggingPriority(.defaultLow, for: .horizontal) + for label in [titleLabel, subtitleLabel, tertiaryLabel] { + label.attributedText = nil + label.text = nil + label.isHidden = true + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + label.textAlignment = .natural + label.rowOffsetY = 0 + label.rowVerticalAlignment = nil + label.setContentHuggingPriority(.defaultLow, for: .horizontal) + label.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + } + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + [titleRowStack, subtitleLabel, tertiaryLabel].forEach(mainStack.addArrangedSubview) + leadingActionKey = nil + leadingActionButton.setImage(nil, for: .normal) + for (index, button) in marketBadgeButtons.enumerated() { + resetButton(button) + button.isHidden = true + marketBadgeImages[index].isHidden = true + if index >= item.data.dictionaries("badges").count + || item.data.dictionaries("badges")[index].dictionary("icon") == nil + { + badgeSlots[index].recycle() + } + } + accessoryButtons.forEach(resetButton) + bindMarket(item, theme: theme) + let style = item.data.dictionary("style") + for (label, descriptor) in [ + (titleLabel, style?.dictionary("title")), (subtitleLabel, style?.dictionary("subtitle")), + (tertiaryLabel, item.data.dictionary("subtitlePrefix")?.dictionary("style")), + ] { + if let descriptor { NativeListTextStyles.applyStyledText(label, textLayoutStyle(descriptor)) } + } + root.alignment = + style?.dictionary("container")?.string("contentVerticalAlignment") == "top" + ? .top + : style?.dictionary("container")?.string("contentVerticalAlignment") == "bottom" + ? .bottom : .center + } + private func resetButton(_ button: NativeListAccessoryButton) { + button.setTitle(nil, for: .normal) + button.setAttributedTitle(nil, for: .normal) + button.setImage(nil, for: .normal) + button.marketLineHeight = nil + button.selectorSummaryLineHeight = nil + button.rowTextOffsetY = 0 + button.contentVerticalAlignment = .center + button.contentHorizontalAlignment = .center + button.titleLabel?.numberOfLines = 1 + button.titleLabel?.lineBreakMode = .byTruncatingTail + button.titleLabel?.textAlignment = .natural + button.contentEdgeInsets = .zero + button.imageEdgeInsets = .zero + button.titleEdgeInsets = .zero + } + private func textLayoutStyle(_ style: [String: Any]) -> [String: Any] { + style.filter { ["lines", "truncate", "verticalAlignment", "offsetY"].contains($0.key) } + } + override func accessibilityText(_ item: NativeListItem) -> String { + item.data.string( + "accessibilityLabel", + default: [ + item.data.string("title"), item.data.string("subtitle"), item.data.string("price"), + item.data.dictionary("change")?.string("text") ?? "", + ].filter { !$0.isEmpty }.joined(separator: ", ")) + } + override func updateMarketQuote(_ item: NativeListItem, theme: [String: Any]?) { + guard retainBoundItem(item) else { return } + bindQuote(item, theme: theme) + } + override func recycleContent() { + visual.recycle() + badgeSlots.forEach { $0.recycle() } + imageSignature = "" + } + @objc private func leadingPressed() { + if let key = leadingActionKey { + emitAction(key, from: leadingActionButton, source: "leadingAction") + } + } + @objc private func badgePressed(_ button: UIButton) { + if marketBadgeActionKeys.indices.contains(button.tag), + let key = marketBadgeActionKeys[button.tag] + { + emitAction(key, from: button, source: "marketBadge", slot: button.tag) + } + } + private func show(_ label: UILabel, _ value: String, lines: Int) { + label.text = value + label.numberOfLines = lines + label.isHidden = value.isEmpty + } + private func setLineHeight(_ label: UILabel, text: String, lineHeight: CGFloat) { + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = lineHeight + paragraph.maximumLineHeight = lineHeight + paragraph.alignment = label.textAlignment + paragraph.lineBreakMode = label.lineBreakMode + let baseline = max(0, (lineHeight - label.font.lineHeight) / 2) + let scale = window?.screen.scale ?? traitCollection.displayScale + label.attributedText = NSAttributedString( + string: text, + attributes: [ + .font: label.font as Any, .foregroundColor: label.textColor as Any, + .paragraphStyle: paragraph, .kern: 0, + .baselineOffset: lineHeight == 20 && label.font.pointSize == 14 && scale > 0 + ? ceil(baseline * scale) / scale : baseline, + ]) + } + private func setButtonLine( + _ button: UIButton, text: String, font: UIFont, color: UIColor, lineHeight: CGFloat + ) { + guard !text.isEmpty else { return } + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = lineHeight + paragraph.maximumLineHeight = lineHeight + paragraph.alignment = .natural + (button as? NativeListAccessoryButton)?.marketLineHeight = lineHeight + let baseline = max(0, (lineHeight - font.lineHeight) / 2) + let scale = window?.screen.scale ?? traitCollection.displayScale + button.titleLabel?.font = font + button.setAttributedTitle( + NSAttributedString( + string: text, + attributes: [ + .font: font, .foregroundColor: color, .paragraphStyle: paragraph, .kern: 0, + .baselineOffset: lineHeight == 20 && font.pointSize == 14 && scale > 0 + ? ceil(baseline * scale) / scale : baseline, + ]), for: .normal) + } + private func marketFontWeight(_ value: String, fallback: NativeListFontWeight) + -> NativeListFontWeight + { + switch value { + case "regular": return .regular + case "semibold": return .semibold + case "bold": return .bold + case "medium": return .medium + default: return fallback + } + } + private func marketTextAlignment(_ value: String) -> NSTextAlignment { + switch value { + case "center": return .center + case "start": return effectiveUserInterfaceLayoutDirection == .rightToLeft ? .right : .left + case "end": return effectiveUserInterfaceLayoutDirection == .rightToLeft ? .left : .right + default: return .natural + } + } + private func applyMarketTextStyle( + _ label: UILabel, + data: [String: Any]?, + theme: [String: Any]?, + defaultSize: CGFloat, + defaultLineHeight: CGFloat, + defaultWeight: NativeListFontWeight, + defaultColor: UIColor, + defaultAlignment: NSTextAlignment = .natural + ) { + let size = CGFloat( + data?.double("fontSize", default: Double(defaultSize)) ?? Double(defaultSize)) + let lineHeight = CGFloat( + data?.double("lineHeight", default: Double(defaultLineHeight)) ?? Double(defaultLineHeight)) + label.font = nativeListTabularFont( + ofSize: size, + weight: marketFontWeight(data?.string("fontWeight") ?? "", fallback: defaultWeight)) + label.textColor = + data?["color"].flatMap { $0 as? String }.map { + UIColor(nativeListHex: $0, fallback: defaultColor) + } ?? defaultColor + label.textAlignment = + data?["alignment"] == nil + ? defaultAlignment + : marketTextAlignment(data?.string("alignment") ?? "") + label.numberOfLines = min(3, max(1, data?.int("lines", default: 1) ?? 1)) + setLineHeight(label, text: label.text ?? "", lineHeight: lineHeight) + } + private func applyMarketButtonStyle( + _ button: UIButton, + data: [String: Any]?, + defaultSize: CGFloat, + defaultLineHeight: CGFloat, + defaultWeight: NativeListFontWeight, + color: UIColor, + defaultAlignment: UIControl.ContentHorizontalAlignment + ) { + let size = CGFloat( + data?.double("fontSize", default: Double(defaultSize)) ?? Double(defaultSize)) + let lineHeight = CGFloat( + data?.double("lineHeight", default: Double(defaultLineHeight)) ?? Double(defaultLineHeight)) + setButtonLine( + button, + text: button.title(for: .normal) ?? "", + font: nativeListTabularFont( + ofSize: size, + weight: marketFontWeight(data?.string("fontWeight") ?? "", fallback: defaultWeight)), + color: data?["color"].flatMap { $0 as? String }.map { + UIColor(nativeListHex: $0, fallback: color) + } ?? color, + lineHeight: lineHeight + ) + if data?["alignment"] == nil { + button.contentHorizontalAlignment = defaultAlignment + } else { + button.contentHorizontalAlignment = + data?.string("alignment") == "start" + ? .leading + : data?.string("alignment") == "end" ? .trailing : .center + } + button.titleLabel?.numberOfLines = min(3, max(1, data?.int("lines", default: 1) ?? 1)) + } + private func marketAttributedText( + _ text: String, + segments: [[String: Any]], + style: [String: Any]?, + defaultSize: CGFloat, + defaultLineHeight: CGFloat, + defaultWeight: NativeListFontWeight, + color: UIColor, + defaultAlignment: NSTextAlignment + ) -> NSAttributedString { + let size = CGFloat( + style?.double("fontSize", default: Double(defaultSize)) ?? Double(defaultSize)) + let lineHeight = CGFloat( + style?.double("lineHeight", default: Double(defaultLineHeight)) ?? Double(defaultLineHeight)) + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = lineHeight + paragraph.maximumLineHeight = lineHeight + paragraph.alignment = + style?["alignment"] == nil + ? defaultAlignment + : marketTextAlignment(style?.string("alignment") ?? "") + let weight = marketFontWeight(style?.string("fontWeight") ?? "", fallback: defaultWeight) + let resolvedColor = + style?["color"].flatMap { $0 as? String }.map { UIColor(nativeListHex: $0, fallback: color) } + ?? color + let result = NSMutableAttributedString(string: "") + let source = segments.isEmpty ? [["text": text]] : segments + for segment in source { + let segmentSize = + style?["fontSize"] == nil && segment.string("style") == "subscript" + ? ceil(size * 0.6) : size + result.append( + NSAttributedString( + string: segment.string("text"), + attributes: [ + .font: nativeListTabularFont(ofSize: segmentSize, weight: weight), + .foregroundColor: resolvedColor, + .kern: 0, + .paragraphStyle: paragraph, + .baselineOffset: max( + 0, (lineHeight - nativeListTabularFont(ofSize: size, weight: weight).lineHeight) / 2), + ])) + } + return result + } + private func marketLeading(_ item: NativeListItem, style: [String: Any]?) -> [String: Any]? { + guard var visual = item.data.dictionary("leading") else { return nil } + guard let imageStyle = style?.dictionary("image") else { return visual } + if let shape = imageStyle["shape"] as? String { visual["shape"] = shape } + if let contentFit = imageStyle["contentFit"] as? String, var image = visual.dictionary("image") + { + image["contentFit"] = contentFit + visual["image"] = image + } + return visual + } + private func bindMarket(_ item: NativeListItem, theme: [String: Any]?) { + let variant = item.data.string("variant") + let style = item.data.dictionary("style") + let imageStyle = style?.dictionary("image") + let imageWidth = CGFloat( + imageStyle?.double("width", default: variant == "stock" ? 40 : 32) + ?? (variant == "stock" ? 40 : 32)) + let imageHeight = CGFloat( + imageStyle?.double("height", default: variant == "stock" ? 40 : 32) + ?? (variant == "stock" ? 40 : 32)) + let horizontalPadding = CGFloat( + style?.double("horizontalPadding", default: variant == "perp" ? 16 : 20) + ?? (variant == "perp" ? 16 : 20)) + let verticalPadding = CGFloat(style?.double("verticalPadding", default: 12) ?? 12) + contentInsets = UIEdgeInsets( + top: verticalPadding, left: horizontalPadding, bottom: verticalPadding, + right: horizontalPadding) + root.spacing = CGFloat( + style?.double("leadingGap", default: variant == "perp" ? 8 : 14) + ?? (variant == "perp" ? 8 : 14)) + if let leadingAction = item.data.dictionary("leadingAction") { + leadingActionButton.isHidden = false + let tintColor = UIColor( + nativeListHex: leadingAction.string("tintColor", default: "#646464"), + fallback: .darkGray + ) + leadingActionButton.tintColor = tintColor + if let image = nativeListIcon(named: leadingAction.string("name")) { + leadingActionButton.setImage(image, for: .normal) + leadingActionButton.setImage( + image.withTintColor(tintColor, renderingMode: .alwaysOriginal), + for: .disabled + ) + } + leadingActionButton.isEnabled = !leadingAction.bool("disabled") + leadingActionButton.alpha = leadingActionButton.isEnabled ? 1 : 0.4 + leadingActionButton.accessibilityIdentifier = leadingAction["testID"] as? String + leadingActionButton.accessibilityLabel = leadingAction["accessibilityLabel"] as? String + leadingActionKey = leadingAction.string("actionKey") + root.addArrangedSubview(leadingActionButton) + root.setCustomSpacing(5, after: leadingActionButton) + } + leadingWidth.constant = imageWidth + leadingHeight.constant = imageHeight + if let descriptor = marketLeading(item, style: style) { + var resolved = imageStyle ?? [:] + resolved["cornerRadius"] = + imageStyle?.double( + "cornerRadius", + default: imageStyle?.string("shape") == "square" + ? 0 + : imageStyle?.string("shape") == "rounded" + ? 8 : Double(min(imageWidth, imageHeight) / 2)) + ?? Double(min(imageWidth, imageHeight) / 2) + visual.bitmapBorderWidth = descriptor.string("borderColor").isEmpty ? 0 : 1 + visual.bind(descriptor, style: resolved, key: item.key, theme: theme, isUnread: false) + root.addArrangedSubview(visual) + let signature = NativeListImageSlot.signature([ + "key": item.key, "visual": descriptor, "fit": resolved["contentFit"] ?? NSNull(), + ]) + if signature != imageSignature, + let diagnostic = item.data.dictionary("diagnostics")?.string("imageBindActionKey"), + !diagnostic.isEmpty, + descriptor.dictionary("image") != nil || descriptor.dictionary("networkImage") != nil + { + onAction?(item, diagnostic, nil, nil) + } + imageSignature = signature + } else { + visual.recycle() + imageSignature = "" + } + root.addArrangedSubview(mainStack) + // OneKey patch: opt in to the source Market row's content gap. + // root.setCustomSpacing(0, after: mainStack) + root.setCustomSpacing( + CGFloat(style?.double("contentTrailingGap", default: 0) ?? 0), after: mainStack) + mainStack.spacing = CGFloat(style?.double("lineGap", default: 0) ?? 0) + titleRowStack.spacing = CGFloat(style?.double("titleBadgeGap", default: 4) ?? 4) + // OneKey patch: opt in without changing the other row templates' filled layout. + if style?.string("titleBadgeLayout") == "inline" { + mainStack.alignment = .leading + titleRowStack.setContentHuggingPriority(.required, for: .horizontal) + } + show( + titleLabel, item.data.string("title"), + lines: style?.dictionary("title")?.int("lines", default: 1) ?? 1) + applyMarketTextStyle( + titleLabel, data: style?.dictionary("title"), theme: theme, defaultSize: 16, + defaultLineHeight: 24, defaultWeight: .medium, + defaultColor: nativeListColor(theme, "primaryText", "#202020")) + let badges = Array(item.data.dictionaries("badges").prefix(marketBadgeButtons.count)) + marketBadgeActionKeys = badges.map { $0["actionKey"] as? String } + for (index, badge) in badges.enumerated() { + let button = marketBadgeButtons[index] + let badgeStyle = badge.dictionary("style") + let badgeFontSize = CGFloat(badgeStyle?.double("fontSize", default: 11) ?? 11) + let badgeFontWeight = marketFontWeight( + badgeStyle?.string("fontWeight") ?? "", fallback: .medium) + // OneKey patch: SizableText supplies tabular numerals for explicit Market metrics. + let badgeFont = + badgeStyle == nil + ? nativeListFont(ofSize: badgeFontSize, weight: badgeFontWeight) + : nativeListTabularFont(ofSize: badgeFontSize, weight: badgeFontWeight) + let hasBuiltInIcon = badge.string("iconName") == "verified" + let hasRemoteIcon = badge.dictionary("icon") != nil + let hasIcon = hasBuiltInIcon || hasRemoteIcon + let text = badge.string("text") + let toneColor: UIColor + switch badge.string("tone") { + case "success": toneColor = nativeListColor(theme, "positive", "#218358") + case "danger": toneColor = nativeListColor(theme, "negative", "#CE2C31") + case "info": toneColor = nativeListColor(theme, "info", "#0D74CE") + case "warning": toneColor = nativeListColor(theme, "primaryText", "#202020") + default: toneColor = nativeListColor(theme, "secondaryText", "#646464") + } + let foreground = UIColor( + nativeListHex: badge.string("textColor", default: ""), + fallback: toneColor + ) + button.isHidden = false + button.isEnabled = true + button.isUserInteractionEnabled = !badge.string("actionKey").isEmpty + button.accessibilityTraits = button.isUserInteractionEnabled ? .button : .staticText + button.setTitle(text, for: .normal) + button.setTitleColor(foreground, for: .normal) + button.titleLabel?.font = badgeFont + if let lineHeight = badgeStyle?["lineHeight"] as? Double { + setButtonLine( + button, text: text, font: badgeFont, color: foreground, lineHeight: CGFloat(lineHeight)) + // The explicit text-only line box must not cover an adjacent icon. + if hasIcon { button.marketLineHeight = nil } + } + button.tintColor = foreground + button.backgroundColor = UIColor( + nativeListHex: badge.string("backgroundColor", default: ""), + fallback: hasIcon && text.isEmpty + ? .clear + : nativeListColor(theme, "strongBackground", "#0000000F") + ) + let iconOnly = hasIcon && text.isEmpty + let iconSize: CGFloat = hasBuiltInIcon ? 16 : 14 + // OneKey patch: preserve the native defaults unless the caller supplies padding. + let padding = CGFloat(badgeStyle?.double("horizontalPadding", default: 5) ?? 5) + let hasCustomPadding = badgeStyle?["horizontalPadding"] != nil + let leftPadding = + hasCustomPadding + ? padding + (hasRemoteIcon ? iconSize + 2 : 0) : hasRemoteIcon ? 20 : hasIcon ? 3 : 5 + // button.contentEdgeInsets = UIEdgeInsets(top: 0, left: iconOnly ? 0 : hasRemoteIcon ? 20 : hasIcon ? 3 : 5, bottom: 0, right: iconOnly ? 0 : 5) + button.contentEdgeInsets = UIEdgeInsets( + top: 0, left: iconOnly ? 0 : leftPadding, bottom: 0, right: iconOnly ? 0 : padding) + button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: text.isEmpty ? 0 : 3) + button.titleEdgeInsets = .zero + button.imageView?.contentMode = .scaleAspectFit + if hasBuiltInIcon { + button.setImage( + nativeListIcon( + named: "BadgeVerifiedSolid", size: CGSize(width: iconSize, height: iconSize)), + for: .normal + ) + } + // OneKey patch: match source badge metrics at physical-pixel precision. + // let height = button.heightAnchor.constraint(equalToConstant: 18) + let height = button.heightAnchor.constraint( + equalToConstant: CGFloat(badgeStyle?.double("height", default: 18) ?? 18)) + let textWidth = (text as NSString).size(withAttributes: [.font: badgeFont]).width + let scale = max(1, traitCollection.displayScale) + let roundedTextWidth = badgeStyle == nil ? ceil(textWidth) : ceil(textWidth * scale) / scale + let extraWidth = + hasCustomPadding ? padding * 2 + (hasIcon ? iconSize + 2 : 0) : hasIcon ? iconSize + 11 : 10 + // let width = button.widthAnchor.constraint(equalToConstant: iconOnly ? iconSize : ceil(textWidth) + (hasIcon ? iconSize + 11 : 10)) + let width = button.widthAnchor.constraint( + equalToConstant: iconOnly ? iconSize : roundedTextWidth + extraWidth) + NSLayoutConstraint.activate([width, height]) + selectorConstraints.append(contentsOf: [width, height]) + if let icon = badge.dictionary("icon") { + marketBadgeImages[index].isHidden = false + marketBadgeImages[index].layer.cornerRadius = 7 + marketBadgeImages[index].clipsToBounds = true + let imageLeading = marketBadgeImages[index].leadingAnchor.constraint( + equalTo: button.leadingAnchor, constant: text.isEmpty ? 0 : 4) + imageLeading.isActive = true + selectorConstraints.append(imageLeading) + badgeSlots[index].bind(icon, key: "\(item.key):badge:\(index)") + } + button.accessibilityLabel = badge.string("accessibilityLabel", default: badge.string("text")) + } + if !item.data.string("subtitle").isEmpty || !item.data.dictionaries("subtitleSegments").isEmpty + { + show( + subtitleLabel, item.data.string("subtitle"), + lines: style?.dictionary("subtitle")?.int("lines", default: 1) ?? 1) + applyMarketTextStyle( + subtitleLabel, data: style?.dictionary("subtitle"), theme: theme, defaultSize: 14, + defaultLineHeight: 20, defaultWeight: .regular, + defaultColor: nativeListColor(theme, "secondaryText", "#646464")) + if !item.data.dictionaries("subtitleSegments").isEmpty { + subtitleLabel.attributedText = marketAttributedText( + item.data.string("subtitle"), segments: item.data.dictionaries("subtitleSegments"), + style: style?.dictionary("subtitle"), defaultSize: 14, defaultLineHeight: 20, + defaultWeight: .regular, color: nativeListColor(theme, "secondaryText", "#646464"), + defaultAlignment: .natural) + } + } + // OneKey patch: preserve volume width while the localized name truncates. + let subtitlePrefix = item.data.dictionary("subtitlePrefix") + let subtitlePadding = CGFloat(style?.double("subtitleTrailingPadding", default: 0) ?? 0) + if subtitlePrefix != nil || subtitlePadding > 0 { + mainStack.removeArrangedSubview(subtitleLabel) + subtitleLabel.removeFromSuperview() + mainStack.removeArrangedSubview(tertiaryLabel) + tertiaryLabel.removeFromSuperview() + marketSubtitleStack.axis = .horizontal + marketSubtitleStack.alignment = .center + marketSubtitleStack.spacing = 0 + marketSubtitleStack.clipsToBounds = true + show(tertiaryLabel, subtitlePrefix?.string("text") ?? "", lines: 1) + applyMarketTextStyle( + tertiaryLabel, data: subtitlePrefix?.dictionary("style"), theme: theme, defaultSize: 12, + defaultLineHeight: 16, defaultWeight: .regular, + defaultColor: nativeListColor(theme, "secondaryText", "#646464")) + tertiaryLabel.setContentHuggingPriority(.required, for: .horizontal) + tertiaryLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + subtitleLabel.setContentHuggingPriority(.required, for: .horizontal) + subtitleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + marketSubtitleStack.addArrangedSubview(tertiaryLabel) + marketSubtitleStack.addArrangedSubview(subtitleLabel) + marketSubtitleStack.addArrangedSubview(marketSubtitleSpacer) + if !tertiaryLabel.isHidden && !subtitleLabel.isHidden { + marketSubtitleStack.setCustomSpacing( + CGFloat(subtitlePrefix?.double("gap", default: 4) ?? 4), after: tertiaryLabel) + } + mainStack.insertArrangedSubview(marketSubtitleStack, at: 1) + let width = marketSubtitleStack.widthAnchor.constraint( + equalTo: mainStack.widthAnchor, constant: -subtitlePadding) + width.isActive = true + selectorConstraints.append(width) + if let maxWidth = subtitlePrefix?["maxWidth"] as? Double { + let limit = tertiaryLabel.widthAnchor.constraint( + lessThanOrEqualToConstant: CGFloat(maxWidth)) + limit.isActive = true + selectorConstraints.append(limit) + } + marketSubtitleStack.isHidden = tertiaryLabel.isHidden && subtitleLabel.isHidden + } + root.addArrangedSubview(trailingStack) + trailingStack.axis = .horizontal + trailingStack.alignment = .center + trailingStack.spacing = CGFloat(style?.double("trailingGap", default: 8) ?? 8) + bindQuote(item, theme: theme) + } + private func bindQuote(_ item: NativeListItem, theme: [String: Any]?) { + NSLayoutConstraint.deactivate(accessorySizeConstraints) + accessorySizeConstraints.removeAll() + let style = item.data.dictionary("style") + let price = accessoryButtons[0] + price.isUserInteractionEnabled = false + price.isHidden = false + price.setAttributedTitle(nil, for: .normal) + price.setTitle(item.data.string("price"), for: .normal) + applyMarketButtonStyle( + price, data: style?.dictionary("price"), defaultSize: 16, defaultLineHeight: 24, + defaultWeight: .medium, color: nativeListColor(theme, "primaryText", "#202020"), + defaultAlignment: .trailing) + if !item.data.dictionaries("priceSegments").isEmpty { + price.setAttributedTitle( + marketAttributedText( + item.data.string("price"), segments: item.data.dictionaries("priceSegments"), + style: style?.dictionary("price"), defaultSize: 16, defaultLineHeight: 24, + defaultWeight: .medium, color: nativeListColor(theme, "primaryText", "#202020"), + defaultAlignment: marketTextAlignment("end")), for: .normal) + } + let changeData = item.data.dictionary("change") ?? [:] + let change = accessoryButtons[1] + change.isUserInteractionEnabled = false + change.isHidden = false + change.setAttributedTitle(nil, for: .normal) + change.setTitle(changeData.string("text"), for: .normal) + let defaultChangeColor = nativeListColor(theme, "inverseText", "#FFFFFF") + applyMarketButtonStyle( + change, data: style?.dictionary("change"), defaultSize: 14, defaultLineHeight: 20, + defaultWeight: .medium, + color: UIColor( + nativeListHex: changeData.string("textColor", default: "#FFFFFF"), + fallback: defaultChangeColor), defaultAlignment: .center) + if !changeData.dictionaries("textSegments").isEmpty { + let changeTextColor = UIColor( + nativeListHex: changeData.string("textColor", default: ""), fallback: defaultChangeColor) + change.setAttributedTitle( + marketAttributedText( + changeData.string("text"), segments: changeData.dictionaries("textSegments"), + style: style?.dictionary("change"), defaultSize: 14, defaultLineHeight: 20, + defaultWeight: .medium, color: changeTextColor, defaultAlignment: .center), for: .normal) + } + let toneKey = + changeData.string("tone") == "positive" + ? "positive" : changeData.string("tone") == "negative" ? "negative" : "secondaryText" + change.backgroundColor = UIColor( + nativeListHex: changeData.string("backgroundColor", default: ""), + fallback: nativeListColor( + theme, toneKey, + changeData.string("tone") == "positive" + ? "#218358" : changeData.string("tone") == "negative" ? "#CE2C31" : "#8D8D8D")) + change.layer.cornerRadius = CGFloat(style?.double("changeCornerRadius", default: 8) ?? 8) + change.clipsToBounds = true + let width = change.widthAnchor.constraint( + equalToConstant: CGFloat(style?.double("changeWidth", default: 80) ?? 80)) + let height = change.heightAnchor.constraint( + equalToConstant: CGFloat(style?.double("changeHeight", default: 32) ?? 32)) + width.isActive = true + height.isActive = true + accessorySizeConstraints.append(contentsOf: [width, height]) + if let priceStyle = style?.dictionary("price"), !textLayoutStyle(priceStyle).isEmpty { + NativeListTextStyles.applyStyledButton(price, textLayoutStyle(priceStyle)) + } + if let changeStyle = style?.dictionary("change"), !textLayoutStyle(changeStyle).isEmpty { + NativeListTextStyles.applyStyledButton(change, textLayoutStyle(changeStyle)) + } + accessibilityLabel = item.data.string( + "accessibilityLabel", + default: [ + item.data.string("title"), item.data.string("subtitle"), item.data.string("price"), + changeData.string("text"), + ].filter { !$0.isEmpty }.joined(separator: ", ")) + } +} diff --git a/native-views/react-native-native-list/ios/NativeListModels.swift b/native-views/react-native-native-list/ios/NativeListModels.swift index c91c2f102..9ce7397d3 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -4,7 +4,7 @@ import UIKit // Message is the first migrated renderer. Other templates still use the // compatible legacy tree; style, content and row keys do not affect reuse. enum NativeListRendererKey: String, CaseIterable { - case legacy, message, rail, mediaTile, action, system, activity, dataRow, metricCard + case legacy, message, rail, mediaTile, action, system, activity, dataRow, metricCard, market, identity, sectionHeader } struct NativeListItem { diff --git a/native-views/react-native-native-list/ios/NativeListRendererCell.swift b/native-views/react-native-native-list/ios/NativeListRendererCell.swift index ada33c240..7020f65ba 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererCell.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererCell.swift @@ -7,8 +7,10 @@ class NativeListRendererCell: NativeListRowHost { let root = UIStackView() var contentInsets = UIEdgeInsets.zero var defaultCornerRadius: CGFloat { 0 } + var defaultCornerCurve: CALayerCornerCurve { .circular } var pressChangesBackground: Bool { true } var showsSelection: Bool { true } + var defaultVerticalAlignment: String? { nil } var defaultSeparatorInset: CGFloat { 12 } func bindContent( _ item: NativeListItem, theme: [String: Any]?, layout: String, @@ -41,8 +43,10 @@ class NativeListRendererCell: NativeListRowHost { private var theme: [String: Any]? private var layout = "linear" private var itemIndex: Int? - func unselectedBackground(_ item: NativeListItem, theme: [String: Any]?, layout: String, itemIndex: Int?) -> UIColor { nativeListColor(theme, "rowBackground", "#FFFFFF") } - private var selectedState = false + func unselectedBackground( + _ item: NativeListItem, theme: [String: Any]?, layout: String, itemIndex: Int? + ) -> UIColor { nativeListColor(theme, "rowBackground", "#FFFFFF") } + private(set) var selectedState = false private var inputSignature = "" override init(frame: CGRect) { @@ -60,6 +64,15 @@ class NativeListRendererCell: NativeListRowHost { } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + func accessibilityText(_ item: NativeListItem) -> String { + item.data.string("accessibilityLabel", default: item.data.string("title")) + } + @discardableResult func retainBoundItem(_ item: NativeListItem) -> Bool { + guard self.item?.key == item.key else { return false } + self.item = item + return true + } + override func bind( item: NativeListItem, theme: [String: Any]?, layout: String, itemIndex: Int? = nil, selected: Bool, checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String @@ -86,7 +99,7 @@ class NativeListRendererCell: NativeListRowHost { if root.axis == .vertical, let alignment = item.data.dictionary("style")?.dictionary("container")?[ "contentVerticalAlignment"] as? String - ?? (item.styledHeight != nil ? "top" : nil) + ?? (item.styledHeight != nil ? (defaultVerticalAlignment ?? "top") : nil) { NSLayoutConstraint.deactivate(Array(rootConstraints.suffix(2))) contentPosition = @@ -108,7 +121,7 @@ class NativeListRendererCell: NativeListRowHost { self.itemIndex = itemIndex self.selectedState = selected bindSelectionContent(item, checkboxState: checkboxState) - accessibilityLabel = item.data.string("accessibilityLabel", default: item.data.string("title")) + accessibilityLabel = accessibilityText(item) accessibilityIdentifier = item.data["testID"] as? String isUserInteractionEnabled = !item.data.bool("disabled") if !isUserInteractionEnabled { isHighlighted = false } @@ -145,7 +158,10 @@ class NativeListRendererCell: NativeListRowHost { let container = item.data.dictionary("style")?.dictionary("container") ?? [:] let showSelection = showsSelection && selectedState && (layout != "sectioned" || item.data.bool("selected")) - var background = showSelection ? nativeListColor(theme, "rowSelectedBackground", "#F0F0F0") : unselectedBackground(item, theme: theme, layout: layout, itemIndex: itemIndex) + var background = + showSelection + ? nativeListColor(theme, "rowSelectedBackground", "#F0F0F0") + : unselectedBackground(item, theme: theme, layout: layout, itemIndex: itemIndex) if let color = (container["backgroundColor"] ?? item.data["backgroundColor"]) as? String { background = UIColor(nativeListHex: color, fallback: background) } @@ -168,6 +184,8 @@ class NativeListRendererCell: NativeListRowHost { layer.maskedCorners = container["cornerRadius"] != nil || position == "single" || defaultCornerRadius > 0 ? top.union(bottom) : position == "first" ? top : position == "last" ? bottom : [] + layer.cornerCurve = defaultCornerCurve + contentView.layer.cornerCurve = defaultCornerCurve layer.cornerRadius = radius layer.masksToBounds = radius > 0 contentView.layer.cornerRadius = radius diff --git a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift index 84922ece2..994ca02d9 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift @@ -5,6 +5,11 @@ import UIKit enum NativeListRendererRegistry { private static let hosts: [NativeListRendererKey: (type: NativeListRowHost.Type, create: () -> NativeListRowHost)] = [ + .sectionHeader: ( + NativeListSectionHeaderCell.self, { NativeListSectionHeaderCell(frame: .zero) } + ), + .identity: (NativeListIdentityCell.self, { NativeListIdentityCell(frame: .zero) }), + .market: (NativeListMarketCell.self, { NativeListMarketCell(frame: .zero) }), .legacy: (NativeListCell.self, { NativeListCell(frame: .zero) }), .message: (NativeListMessageCell.self, { NativeListMessageCell(frame: .zero) }), .rail: (NativeListRailCell.self, { NativeListRailCell(frame: .zero) }), diff --git a/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift b/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift new file mode 100644 index 000000000..d9d4ed5ec --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift @@ -0,0 +1,654 @@ +import UIKit + +final class NativeListSectionHeaderCell: NativeListRendererCell { + override class func appliesSizePreset(_ item: NativeListItem) -> Bool { + !["summary", "gallery"].contains(item.data.string("variant")) + } + override class func measure( + _ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String + ) -> CGFloat? { + if layout == "table" { return 28 } + if item.data.string("presentation") == "networkSelector" { return 47 } + if item.data.string("variant") == "history" || item.key.hasPrefix("history-") + || item.sectionKey?.hasPrefix("history-") == true + { + return 16 + } + if item.data.string("variant") == "summary" { return 68 } + if item.data.string("variant") == "gallery" { return 32 } + if item.data.dictionary("checkbox") != nil { return 56 } + return layout == "linear" ? 30 : 36 + } + override var defaultCornerRadius: CGFloat { isHighlighted ? 12 : 0 } + private let mainStack = UIStackView() + private let titleRowStack = UIStackView() + private let titleLabel = NativeListDottedUnderlineLabel() + private let subtitleLabel = NativeListTextLabel() + private let trailingStack = UIStackView() + private let accessoryButtons = [NativeListAccessoryButton(type: .system)] + private let checkboxControl = NativeListAccessoryStack(frame: .zero) + private let headerTitleIconImageView = UIImageView() + private let headerValueIconImageView = UIImageView() + private var accessoryActions: [(String, NativeSelectionTarget?)] = [] + private var currentItem: NativeListItem? + private var currentTheme: [String: Any]? + private var leftInset: CGFloat = 12 + private var rightInset: CGFloat = -12 + private var topInset: CGFloat = 8 + private var bottomInset: CGFloat = -8 + private var selectorConstraints: [NSLayoutConstraint] = [] + private var selectorTypographyRestorers: [() -> Void] = [] + private lazy var selectorTitleTap = UITapGestureRecognizer( + target: self, action: #selector(titlePressed)) + override init(frame: CGRect) { + super.init(frame: frame) + root.axis = .horizontal + root.alignment = .center + root.spacing = 12 + mainStack.axis = .vertical + mainStack.alignment = .fill + mainStack.spacing = 2 + mainStack.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal) + mainStack.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + titleRowStack.axis = .horizontal + titleRowStack.alignment = .center + titleRowStack.spacing = 8 + titleRowStack.addArrangedSubview(titleLabel) + mainStack.addArrangedSubview(titleRowStack) + mainStack.addArrangedSubview(subtitleLabel) + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + trailingStack.axis = .vertical + trailingStack.alignment = .trailing + trailingStack.spacing = 2 + trailingStack.setContentHuggingPriority(.required, for: .horizontal) + trailingStack.setContentCompressionResistancePriority(.required, for: .horizontal) + trailingStack.addArrangedSubview(accessoryButtons[0]) + trailingStack.addArrangedSubview(checkboxControl) + accessoryButtons[0].addTarget(self, action: #selector(valuePressed), for: .touchUpInside) + for icon in [headerTitleIconImageView, headerValueIconImageView] { + icon.translatesAutoresizingMaskIntoConstraints = false + icon.contentMode = .scaleAspectFit + NSLayoutConstraint.activate([ + icon.widthAnchor.constraint(equalToConstant: 12), + icon.heightAnchor.constraint(equalToConstant: 12), + ]) + } + checkboxControl.onAction = { [weak self] key, view, slot, target in + self?.emitAction(key, from: view, source: "trailingAccessory", slot: slot, target: target) + } + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func bindContent( + _ item: NativeListItem, theme: [String: Any]?, layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + selectorTypographyRestorers.reversed().forEach { $0() } + selectorTypographyRestorers = [] + currentItem = item + currentTheme = theme + NSLayoutConstraint.deactivate(selectorConstraints) + selectorConstraints = [] + root.arrangedSubviews.forEach { + root.removeArrangedSubview($0) + $0.removeFromSuperview() + } + for icon in [headerTitleIconImageView, headerValueIconImageView] { + if let parent = icon.superview as? UIStackView { parent.removeArrangedSubview(icon) } + icon.removeFromSuperview() + icon.image = nil + icon.isHidden = true + } + titleLabel.removeGestureRecognizer(selectorTitleTap) + titleLabel.isUserInteractionEnabled = false + titleLabel.showsDottedUnderline = false + titleLabel.reservesDottedUnderlineSpace = false + titleLabel.dottedUnderlineVerticalOffset = 0 + for label in [titleLabel, subtitleLabel] { + label.attributedText = nil + label.text = nil + label.isHidden = true + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + label.textAlignment = .natural + label.rowOffsetY = 0 + label.rowVerticalAlignment = nil + } + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + subtitleLabel.font = nativeListFont(ofSize: 14) + subtitleLabel.textColor = nativeListColor(theme, "secondaryText", "#646464") + mainStack.alignment = .fill + mainStack.spacing = 2 + titleRowStack.spacing = 8 + trailingStack.axis = .vertical + trailingStack.alignment = .trailing + trailingStack.spacing = 2 + let value = accessoryButtons[0] + value.setTitle(nil, for: .normal) + value.setAttributedTitle(nil, for: .normal) + value.titleLabel?.font = nativeListFont(ofSize: 16, weight: .medium) + value.titleLabel?.numberOfLines = 1 + value.titleLabel?.lineBreakMode = .byTruncatingTail + value.contentHorizontalAlignment = .center + value.contentVerticalAlignment = .center + value.rowTextOffsetY = 0 + value.marketLineHeight = nil + value.selectorSummaryLineHeight = nil + value.isHidden = true + value.accessibilityIdentifier = nil + value.setTitleColor(nativeListColor(theme, "primaryText", "#202020"), for: .normal) + accessoryActions = [] + checkboxControl.reset() + checkboxControl.isHidden = true + leftInset = layout == "table" ? 16 : 12 + rightInset = -leftInset + topInset = 8 + bottomInset = -8 + bindSectionHeader(item, theme: theme, layout: layout, checkboxState) + let style = item.data.dictionary("style") ?? [:] + if style["horizontalPadding"] != nil { + leftInset = CGFloat(style.double("horizontalPadding")) + rightInset = -leftInset + } + if style["verticalPadding"] != nil { + topInset = CGFloat(style.double("verticalPadding")) + bottomInset = -topInset + } + if style["lineGap"] != nil { mainStack.spacing = CGFloat(style.double("lineGap")) } + if style["trailingGap"] != nil { trailingStack.spacing = CGFloat(style.double("trailingGap")) } + applyTextStyle(item) + if item.data.string("presentation") == "networkSelector", item.data["height"] != nil, + item.data.dictionary("checkbox") != nil, !item.data.string("value").isEmpty + { + let width = trailingStack.widthAnchor.constraint( + equalToConstant: accessoryButtons[0].intrinsicContentSize.width + 12 + 20) + width.isActive = true + selectorConstraints.append(width) + } + contentInsets = UIEdgeInsets( + top: topInset, left: leftInset, bottom: -bottomInset, right: -rightInset) + root.alignment = + style.dictionary("container")?.string("contentVerticalAlignment") == "top" + ? .top + : style.dictionary("container")?.string("contentVerticalAlignment") == "bottom" + ? .bottom : .center + applySelectorTypography(item) + } + private func applyTextStyle(_ item: NativeListItem) { + let style = item.data.dictionary("style") ?? [:] + if let title = style.dictionary("title") { + NativeListTextStyles.applyStyledText(titleLabel, title) + } + if let subtitle = style.dictionary("subtitle") { + NativeListTextStyles.applyStyledText(subtitleLabel, subtitle) + } + if let value = style.dictionary("value") { + NativeListTextStyles.applyStyledButton(accessoryButtons[0], value) + } + } + override func bindSelectionContent( + _ item: NativeListItem, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + currentItem = item + checkboxControl.updateSelection(item, checkboxState: checkboxState) + if item.data.string("variant") == "summary" { + selectorTypographyRestorers.reversed().forEach { $0() } + selectorTypographyRestorers = [] + updateSummaryText(item) + applyValueSegments( + item.data.dictionaries("valueSegments"), to: accessoryButtons[0], theme: currentTheme) + applyTextStyle(item) + applySelectorTypography(item) + } + } + private func bindCheckbox( + _ item: NativeListItem, _ descriptor: [String: Any], + _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + checkboxControl.bind( + item, descriptors: [descriptor], theme: currentTheme, style: [:], checkboxState: checkboxState + ) + } + override func recycleContent() { + checkboxControl.reset() + currentItem = nil + } + @objc private func titlePressed() { + if let item = currentItem { + emitAction(item.data.string("titleActionKey"), from: titleLabel, source: "leadingAction") + } + } + @objc private func valuePressed() { + if let action = accessoryActions.first { + emitAction( + action.0, from: accessoryButtons[0], source: "trailingAccessory", slot: 0, target: action.1) + } + } + private func show(_ label: UILabel, _ text: String, lines: Int) { + label.text = text + label.numberOfLines = lines + label.isHidden = text.isEmpty + } + private func bindSectionHeader( + _ item: NativeListItem, + theme: [String: Any]?, + layout: String, + _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + root.addArrangedSubview(mainStack) + let variant = item.data.string("variant") + // OneKey patch: title help is a separate target from checkbox and value actions. + if !item.data.string("titleActionKey").isEmpty { + titleLabel.isUserInteractionEnabled = true + titleLabel.addGestureRecognizer(selectorTitleTap) + titleLabel.setContentHuggingPriority(.required, for: .horizontal) + } + let isSummary = variant == "summary" + let isGallery = variant == "gallery" + let isTable = layout == "table" + let isNetworkSelector = item.data.string("presentation") == "networkSelector" + let isExplicitNetworkHeader = isNetworkSelector && item.data["height"] != nil + if isExplicitNetworkHeader { + // OneKey patch: the flexible title consumes spare space before trailing totals. + trailingStack.setContentHuggingPriority(.required, for: .horizontal) + trailingStack.setContentCompressionResistancePriority(.required, for: .horizontal) + } + titleLabel.reservesDottedUnderlineSpace = + isExplicitNetworkHeader && !item.data.string("titleActionKey").isEmpty + let isHistory = + variant == "history" || item.key.hasPrefix("history-") + || (item.sectionKey?.hasPrefix("history-") ?? false) + let headerWeight: NativeListFontWeight = + isSummary + ? .medium + : isExplicitNetworkHeader + && (item.data.dictionary("checkbox") != nil || item.data.string("titleActionKey").isEmpty) + ? .semibold + : isNetworkSelector + ? .medium + : isGallery || layout == "sectioned" ? .semibold : .regular + titleLabel.font = nativeListFont( + ofSize: isGallery ? 18 : isSummary ? 16 : isTable ? 11 : isHistory ? 12 : 14, + weight: isHistory ? .semibold : headerWeight + ) + titleLabel.textColor = nativeListColor( + theme, + isSummary || isGallery ? "primaryText" : "secondaryText", + isSummary || isGallery ? "#202020" : "#646464" + ) + show(titleLabel, item.data.string("title"), lines: 1) + if isNetworkSelector { + titleLabel.showsDottedUnderline = + !isExplicitNetworkHeader || !item.data.string("titleActionKey").isEmpty + titleLabel.dottedUnderlineVerticalOffset = isExplicitNetworkHeader ? 1 : 2 + titleLabel.dottedUnderlineColor = nativeListColor( + theme, + "secondaryText", + "#646464" + ) + leftInset = 12 + rightInset = -12 + let isAlphabet = isExplicitNetworkHeader && item.data.string("titleActionKey").isEmpty + topInset = isAlphabet ? 8 : 12 + bottomInset = isAlphabet ? -8 : isExplicitNetworkHeader ? -12 : -15 + setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20) + } else if isHistory { + leftInset = 0 + rightInset = 0 + topInset = 0 + bottomInset = 0 + setLineHeight( + titleLabel, + text: item.data.string("title").uppercased(), + lineHeight: 16, + letterSpacing: 0.8 + ) + } else if !isTable && !isGallery && !isSummary { + setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20) + if layout == "linear" { + // TokenManager's section title is a plain body label with mt=10, + // unlike the reusable 36-point SectionHeader used by sectioned lists. + titleLabel.font = nativeListFont(ofSize: 14) + topInset = 10 + bottomInset = 0 + } + } + if isTable { + mainStack.alignment = .leading + titleRowStack.spacing = 4 + titleLabel.setContentHuggingPriority(.required, for: .horizontal) + setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 14) + topInset = 12 + bottomInset = -2 + if let icon = item.data.dictionary("titleIcon") { + headerTitleIconImageView.image = nativeListIcon(named: icon.string("name")) + headerTitleIconImageView.tintColor = UIColor( + nativeListHex: icon.string("tintColor", default: "#646464"), + fallback: .darkGray + ) + headerTitleIconImageView.isHidden = false + titleRowStack.insertArrangedSubview(headerTitleIconImageView, at: 1) + if icon.string("name") != "ChevronGrabberVerOutline" { + titleLabel.textColor = nativeListColor(theme, "primaryText", "#202020") + setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 14) + } + } + } + if isGallery { + setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) + topInset = 0 + bottomInset = -8 + } + show(subtitleLabel, item.data.string("subtitle"), lines: 1) + if isSummary { + titleLabel.showsDottedUnderline = true + titleLabel.dottedUnderlineColor = nativeListColor( + theme, + "secondaryText", + "#646464" + ) + root.addArrangedSubview(trailingStack) + // The surrounding Stack contributes mt=16/pb=12 while its XStack uses + // px=20/py=8. The collection's 8-point inset supplies the outer 8 points + // of horizontal padding, so the cell supplies the remaining 12. + leftInset = 12 + rightInset = -12 + topInset = 24 + bottomInset = -20 + setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) + accessoryButtons[0].accessibilityIdentifier = item.data["valueActionTestID"] as? String + let valueActionKey = item.data.string("valueActionKey") + let action: (String, NativeSelectionTarget?)? = + valueActionKey.isEmpty + ? nil + : (valueActionKey, nil) + showAccessory( + 0, + item.data.string("value"), + action: action, + color: nativeListColor(theme, "secondaryText", "#646464") + ) + accessoryButtons[0].titleLabel?.font = nativeListFont( + ofSize: 16, weight: isExplicitNetworkHeader ? .medium : .regular) + setButtonLine( + accessoryButtons[0], + text: item.data.string("value"), + font: nativeListFont(ofSize: 16, weight: isExplicitNetworkHeader ? .medium : .regular), + color: nativeListColor(theme, "secondaryText", "#646464"), + lineHeight: 24 + ) + } else if !isGallery && !isHistory { + root.addArrangedSubview(trailingStack) + showAccessory(0, item.data.string("value")) + if isTable { + trailingStack.axis = .horizontal + trailingStack.alignment = .center + trailingStack.spacing = 4 + accessoryButtons[0].titleLabel?.font = nativeListFont(ofSize: 11) + accessoryButtons[0].setTitleColor( + nativeListColor(theme, "secondaryText", "#646464"), + for: .normal + ) + if let icon = item.data.dictionary("valueIcon") { + headerValueIconImageView.image = nativeListIcon(named: icon.string("name")) + headerValueIconImageView.tintColor = UIColor( + nativeListHex: icon.string("tintColor", default: "#646464"), + fallback: .darkGray + ) + headerValueIconImageView.isHidden = false + trailingStack.addArrangedSubview(headerValueIconImageView) + if icon.string("name") != "ChevronGrabberVerOutline" { + accessoryButtons[0].setTitleColor( + nativeListColor(theme, "primaryText", "#202020"), + for: .normal + ) + } + } + } + if let checkbox = item.data.dictionary("checkbox") { + bindCheckbox(item, checkbox, checkboxState) + } + if item.data.dictionary("checkbox") != nil && !item.data.string("value").isEmpty { + titleLabel.showsDottedUnderline = true + titleLabel.dottedUnderlineColor = nativeListColor( + theme, + "secondaryText", + "#646464" + ) + trailingStack.axis = .horizontal + trailingStack.alignment = .center + trailingStack.spacing = 12 + topInset = 8 + bottomInset = -8 + accessoryButtons[0].titleLabel?.font = nativeListTabularFont( + ofSize: 16, + weight: .medium + ) + setButtonLine( + accessoryButtons[0], + text: item.data.string("value"), + font: nativeListTabularFont(ofSize: 16, weight: .medium), + color: nativeListColor(theme, "primaryText", "#202020"), + lineHeight: 24 + ) + } + } + applyValueSegments( + item.data.dictionaries("valueSegments"), to: accessoryButtons[0], theme: theme) + } + private func setLineHeight( + _ label: UILabel, + text: String, + lineHeight: CGFloat, + letterSpacing: CGFloat = 0 + ) { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.minimumLineHeight = lineHeight + paragraphStyle.maximumLineHeight = lineHeight + paragraphStyle.alignment = label.textAlignment + if currentItem?.type == "market" || currentItem?.data.string("presentation") == "walletSidebar" + { + // Attributed paragraphs must preserve the tail ellipsis. + paragraphStyle.lineBreakMode = label.lineBreakMode + } + var attributes: [NSAttributedString.Key: Any] = [ + .font: label.font as Any, + .foregroundColor: label.textColor as Any, + .paragraphStyle: paragraphStyle, + ] + let isNetworkFallback = false + if currentItem?.type == "market" + || (currentItem?.data["height"] != nil + && (["accountSelector", "walletSidebar"].contains( + currentItem?.data.string("presentation") ?? "") + || currentItem?.type == "sectionHeader" + && currentItem?.data.string("presentation") == "networkSelector")) + || isNetworkFallback + { + // OneKey patch: React Native centers font metrics inside explicit line heights. + let baselineOffset = max(0, (lineHeight - label.font.lineHeight) / 2) + // OneKey patch: TextKit's 14/20 headings align their baseline to the upper physical pixel. + let isSelectorHeading = + lineHeight == 20 + && (currentItem?.type == "sectionHeader" + && currentItem?.data.string("presentation") == "networkSelector" + || currentItem?.type == "market" && label.font.pointSize == 14) + let scale = window?.screen.scale ?? traitCollection.displayScale + attributes[.baselineOffset] = + isSelectorHeading && scale > 0 ? ceil(baselineOffset * scale) / scale : baselineOffset + } + if letterSpacing != 0 || currentItem?.type == "market" + || currentItem?.data.string("presentation") == "market" + { + attributes[.kern] = letterSpacing + } + label.attributedText = NSAttributedString(string: text, attributes: attributes) + } + private func setButtonLine( + _ button: UIButton, + text: String, + font: UIFont, + color: UIColor, + lineHeight: CGFloat + ) { + guard !text.isEmpty else { return } + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.minimumLineHeight = lineHeight + paragraphStyle.maximumLineHeight = lineHeight + let isMarketText = currentItem?.type == "market" + let isSelectorValue = + currentItem?.data.string("presentation") == "networkSelector" + && currentItem?.data["height"] != nil && currentItem?.data.string("variant") != "summary" + let isSelectorSummary = + currentItem?.type == "sectionHeader" + && currentItem?.data.string("presentation") == "networkSelector" + && currentItem?.data["height"] != nil && currentItem?.data.string("variant") == "summary" + (button as? NativeListAccessoryButton)?.selectorSummaryLineHeight = + isSelectorSummary ? lineHeight : nil + (button as? NativeListAccessoryButton)?.marketLineHeight = isMarketText ? lineHeight : nil + // OneKey patch: summary text uses its source line box; currency retains trailing alignment. + // Market's line box already handles alignment; source text starts at its origin. + paragraphStyle.alignment = + isSelectorValue ? .right : isSelectorSummary || isMarketText ? .natural : .center + if isSelectorSummary { + button.contentHorizontalAlignment = .leading + button.titleLabel?.textAlignment = .natural + } + if isSelectorValue { + button.contentHorizontalAlignment = .trailing + button.titleLabel?.textAlignment = .right + } + let baselineOffset: CGFloat = + isMarketText + || currentItem?.type == "sectionHeader" + && currentItem?.data.string("presentation") == "networkSelector" + && currentItem?.data["height"] != nil + ? max(0, (lineHeight - font.lineHeight) / 2) : 0 + var attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraphStyle, + .baselineOffset: isMarketText && lineHeight == 20 && font.pointSize == 14 + ? ceil(baselineOffset * max(1, traitCollection.displayScale)) + / max(1, traitCollection.displayScale) : baselineOffset, + ] + if isMarketText { attributes[.kern] = 0 } + button.setAttributedTitle( + NSAttributedString(string: text, attributes: attributes), + for: .normal + ) + } + private func applyValueSegments( + _ segments: [[String: Any]], to button: UIButton, theme: [String: Any]? + ) { + guard !segments.isEmpty else { return } + let value = NSMutableAttributedString(string: "") + let color = nativeListColor(theme, "primaryText", "#202020") + // OneKey patch: rich currency changes font runs without dropping the established line baseline. + let current = button.attributedTitle(for: .normal) + var attributes = + current.flatMap { $0.length > 0 ? $0.attributes(at: 0, effectiveRange: nil) : nil } ?? [:] + attributes[.foregroundColor] = color + if currentItem?.data.string("presentation") == "networkSelector" + && currentItem?.data["height"] != nil + { + let paragraph = + (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() + as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() + paragraph.alignment = .right + attributes[.paragraphStyle] = paragraph + } + for segment in segments { + attributes[.font] = nativeListTabularFont( + ofSize: segment.string("style") == "subscript" ? 10 : 16, weight: .medium) + value.append(NSAttributedString(string: segment.string("text"), attributes: attributes)) + } + button.setAttributedTitle(value, for: .normal) + } + private func showAccessory( + _ index: Int, + _ value: String, + action: (String, NativeSelectionTarget?)? = nil, + color: UIColor? = nil + ) { + guard accessoryButtons.indices.contains(index), !value.isEmpty else { return } + let button = accessoryButtons[index] + button.isHidden = false + button.setTitle(value, for: .normal) + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + if let color { button.setTitleColor(color, for: .normal) } + while accessoryActions.count <= index { accessoryActions.append(("", nil)) } + accessoryActions[index] = action ?? ("", nil) + } + private func updateSummaryText(_ item: NativeListItem) { + let title = item.data.string("title") + titleLabel.isHidden = title.isEmpty + setLineHeight(titleLabel, text: title, lineHeight: 24) + + let value = item.data.string("value") + let isExplicitNetworkHeader = + item.data.string("presentation") == "networkSelector" && item.data["height"] != nil + let valueButton = accessoryButtons[0] + valueButton.isHidden = value.isEmpty + if value.isEmpty { + valueButton.setTitle(nil, for: .normal) + valueButton.setAttributedTitle(nil, for: .normal) + } else { + setButtonLine( + valueButton, + text: value, + font: nativeListFont(ofSize: 16, weight: isExplicitNetworkHeader ? .medium : .regular), + color: nativeListColor(currentTheme, "secondaryText", "#646464"), + lineHeight: 24 + ) + } + } + private func selectorTabularFont(_ font: UIFont) -> UIFont { + var settings = + font.fontDescriptor.fontAttributes[.featureSettings] as? [[UIFontDescriptor.FeatureKey: Int]] + ?? [] + settings.removeAll { $0[.type] == kNumberSpacingType } + settings.append([.type: kNumberSpacingType, .selector: kMonospacedNumbersSelector]) + return UIFont( + descriptor: font.fontDescriptor.addingAttributes([.featureSettings: settings]), + size: font.pointSize) + } + private func selectorTabularText(_ original: NSAttributedString) -> NSAttributedString { + let result = NSMutableAttributedString(attributedString: original) + // OneKey patch: body typography explicitly supplies letterSpacing=0 in Tamagui. + result.addAttribute(.kern, value: 0, range: NSRange(location: 0, length: result.length)) + original.enumerateAttribute(.font, in: NSRange(location: 0, length: original.length)) { + value, range, _ in + if let font = value as? UIFont { + result.addAttribute(.font, value: self.selectorTabularFont(font), range: range) + } + } + return result + } + private func applySelectorTypography(_ item: NativeListItem) { + guard + ["accountSelector", "networkSelector", "walletSidebar"].contains( + item.data.string("presentation")) + else { return } + func visit(_ view: UIView) { + if let button = view as? UIButton { + if let original = button.attributedTitle(for: .normal) { + selectorTypographyRestorers.append { button.setAttributedTitle(original, for: .normal) } + button.setAttributedTitle(selectorTabularText(original), for: .normal) + } + } else if let label = view as? UILabel, let font = label.font { + let original = label.attributedText + selectorTypographyRestorers.append { + label.font = font + label.attributedText = original + } + label.font = selectorTabularFont(font) + if let original { label.attributedText = selectorTabularText(original) } + } + for child in view.subviews { visit(child) } + } + visit(contentView) + } +} diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index fda34b262..8f59ea54e 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -1296,9 +1296,12 @@ final class NativeListView: UIView { private func syncSectionIndexToVisibleRows() { guard !sectionIndexScrubbing, !sectionIndexEntries.isEmpty else { return } - let firstVisible = collectionView.indexPathsForVisibleItems.map(\.item).min() ?? 0 - let index = sectionIndexEntries.lastIndex { $0.position <= firstVisible } - sectionIndexView.setActiveIndex(index) + let offset = collectionView.contentOffset.y + collectionView.adjustedContentInset.top + let index = sectionIndexEntries.lastIndex { + guard let attributes = flowLayout.layoutAttributesForItem(at: IndexPath(item: $0.position, section: 0)) else { return false } + return attributes.frame.minY <= offset + 1 + } + sectionIndexView.setActiveIndex(index ?? 0) } private func configureRefresh(_ config: NativeListConfig) { @@ -1713,14 +1716,6 @@ final class NativeListView: UIView { total + CGFloat(data.dictionary("style")?.dictionary("container")?.double("height", default: data.double("height", default: data.dictionaries("badges").isEmpty ? 68 : 92)) ?? data.double("height", default: data.dictionaries("badges").isEmpty ? 68 : 92)) } } - if item.type == "identity", item.data.string("presentation") == "walletSidebar" { - // OneKey patch: default sidebar badge geometry is 24 points taller. - // return 68 - return item.data.dictionaries("badges").isEmpty ? 68 : 92 - } - if item.type == "identity", item.data.string("presentation") == "networkSelector" { - return 47 - } let availableWidth = max(0, collectionView.bounds.width - flowLayout.sectionInset.left - flowLayout.sectionInset.right) let columnWidth = config?.layout == "grid" ? floor((availableWidth - CGFloat((config?.gridColumns ?? 2) - 1) * (config?.itemSpacing ?? 0)) / CGFloat(config?.gridColumns ?? 2)) @@ -1729,53 +1724,7 @@ final class NativeListView: UIView { if let height = NativeListRendererRegistry.measure(item, width: rowWidth, theme: config?.theme, layout: config?.layout ?? "linear") { return max(0, height + (NativeListRendererRegistry.appliesSizePreset(item) ? (item.data.string("size") == "small" ? -8 : item.data.string("size") == "large" ? 12 : 0) : 0)) } - let base: CGFloat - switch item.type { - case "sectionHeader": - let variant = item.data.string("variant") - let isNetworkSelector = item.data.string("presentation") == "networkSelector" - let isHistory = variant == "history" || - item.key.hasPrefix("history-") || - (item.sectionKey?.hasPrefix("history-") ?? false) - base = config?.layout == "table" - ? 28 - : isNetworkSelector - ? 47 - : isHistory - ? 16 - : variant == "summary" - ? 68 - : variant == "gallery" - ? 32 - : item.data.dictionary("checkbox") != nil - ? 56 - : config?.layout == "linear" ? 30 : 36 - case "market": - let style = item.data.dictionary("style") - let imageHeight = CGFloat(style?.dictionary("image")?.double( - "height", - default: item.data.string("variant") == "stock" ? 40 : 32 - ) ?? (item.data.string("variant") == "stock" ? 40 : 32)) - let verticalPadding = CGFloat(style?.double("verticalPadding", default: 12) ?? 12) - base = max(item.data.string("variant") == "stock" ? 72 : 68, imageHeight + verticalPadding * 2) - default: - if item.type == "identity", !item.data.string("tertiary").isEmpty { - base = 72 - } else if item.type == "identity", !item.data.string("subtitle").isEmpty { - base = 60 - } else { - base = 56 - } - } - let hasFixedHeaderHeight = item.type == "sectionHeader" && - ["summary", "gallery"].contains(item.data.string("variant")) - let modifier: CGFloat = hasFixedHeaderHeight - ? 0 - : item.data.string("size", default: "medium") == "small" - ? -8 - : item.data.string("size") == "large" ? 12 : 0 - let sectionSpacing: CGFloat = 0 - return max(0, base + modifier + sectionSpacing) + return 56 } @@ -2318,11 +2267,15 @@ final class NativeListFlowLayout: UICollectionViewFlowLayout { !stickyItemIndexes.isEmpty, let collectionView else { return super.layoutAttributesForElements(in: rect) } let base = super.layoutAttributesForElements(in: rect)?.compactMap { $0.copy() as? UICollectionViewLayoutAttributes } ?? [] - let firstVisible = collectionView.indexPathsForVisibleItems.map(\.item).min() ?? 0 - guard let stickyIndex = stickyItemIndexes.filter({ $0 <= firstVisible }).max(), + let pinY = collectionView.contentOffset.y + collectionView.adjustedContentInset.top + // Visible cells can still describe the old viewport during an imperative + // scroll. Resolve the pinned header from unmodified layout positions. + guard let stickyIndex = stickyItemIndexes.filter({ + guard let attributes = super.layoutAttributesForItem(at: IndexPath(item: $0, section: 0)) else { return false } + return attributes.frame.minY <= pinY + }).max(), let original = super.layoutAttributesForItem(at: IndexPath(item: stickyIndex, section: 0)), let sticky = original.copy() as? UICollectionViewLayoutAttributes else { return base } - let pinY = collectionView.contentOffset.y + collectionView.adjustedContentInset.top let nextHeaderY = stickyItemIndexes .filter { $0 > stickyIndex } .min() From b61199fd2cb643cd1f68e94835856f12d2977e4c Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 21:58:59 +0800 Subject: [PATCH 51/71] fix(native-list): lay out sticky overlays and narrow text helpers --- .../com/onekey/nativelist/NativeListView.kt | 9 ++++ .../ios/NativeListIdentityRenderer.swift | 35 +++---------- .../ios/NativeListSectionHeaderRenderer.swift | 50 ++++--------------- 3 files changed, 25 insertions(+), 69 deletions(-) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt index 6161f76ad..6ff10fe7c 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt @@ -1255,6 +1255,15 @@ class NativeListView( } stickyHeaderHost.translationY = minOf(0, nextTop - height).toFloat() stickyHeaderHost.visibility = VISIBLE + // React Native can retain the measured parent while this overlay becomes + // visible in pre-draw. Lay out the container-owned header immediately. + if (stickyHeaderHost.isLayoutRequested || stickyHeaderHost.width != width || stickyHeaderHost.height != height) { + stickyHeaderHost.measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY), + ) + stickyHeaderHost.layout(recyclerView.paddingLeft, 0, recyclerView.paddingLeft + width, height) + } } private fun configureSectionIndex(next: NativeListConfig) { diff --git a/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift b/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift index a4e523683..21eb14dea 100644 --- a/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift @@ -645,9 +645,8 @@ final class NativeListIdentityCell: NativeListRendererCell { paragraphStyle.minimumLineHeight = lineHeight paragraphStyle.maximumLineHeight = lineHeight paragraphStyle.alignment = label.textAlignment - if currentItem?.type == "market" || currentItem?.data.string("presentation") == "walletSidebar" - { - // Attributed paragraphs must preserve the tail ellipsis. + let presentation = currentItem?.data.string("presentation") ?? "" + if presentation == "walletSidebar" { paragraphStyle.lineBreakMode = label.lineBreakMode } var attributes: [NSAttributedString.Key: Any] = [ @@ -655,33 +654,13 @@ final class NativeListIdentityCell: NativeListRendererCell { .foregroundColor: label.textColor as Any, .paragraphStyle: paragraphStyle, ] - let isNetworkFallback = - label === fallbackLabel && currentItem?.data.string("presentation") == "networkSelector" - if currentItem?.type == "market" - || (currentItem?.data["height"] != nil - && (["accountSelector", "walletSidebar"].contains( - currentItem?.data.string("presentation") ?? "") - || currentItem?.type == "sectionHeader" - && currentItem?.data.string("presentation") == "networkSelector")) - || isNetworkFallback - { - // OneKey patch: React Native centers font metrics inside explicit line heights. - let baselineOffset = max(0, (lineHeight - label.font.lineHeight) / 2) - // OneKey patch: TextKit's 14/20 headings align their baseline to the upper physical pixel. - let isSelectorHeading = - lineHeight == 20 - && (currentItem?.type == "sectionHeader" - && currentItem?.data.string("presentation") == "networkSelector" - || currentItem?.type == "market" && label.font.pointSize == 14) - let scale = window?.screen.scale ?? traitCollection.displayScale - attributes[.baselineOffset] = - isSelectorHeading && scale > 0 ? ceil(baselineOffset * scale) / scale : baselineOffset - } - if letterSpacing != 0 || currentItem?.type == "market" - || currentItem?.data.string("presentation") == "market" + let isNetworkFallback = label === fallbackLabel && presentation == "networkSelector" + if (currentItem?.data["height"] != nil + && ["accountSelector", "walletSidebar"].contains(presentation)) || isNetworkFallback { - attributes[.kern] = letterSpacing + attributes[.baselineOffset] = max(0, (lineHeight - label.font.lineHeight) / 2) } + if letterSpacing != 0 { attributes[.kern] = letterSpacing } label.attributedText = NSAttributedString(string: text, attributes: attributes) } } diff --git a/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift b/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift index d9d4ed5ec..757fba462 100644 --- a/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift @@ -447,42 +447,20 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { paragraphStyle.minimumLineHeight = lineHeight paragraphStyle.maximumLineHeight = lineHeight paragraphStyle.alignment = label.textAlignment - if currentItem?.type == "market" || currentItem?.data.string("presentation") == "walletSidebar" - { - // Attributed paragraphs must preserve the tail ellipsis. - paragraphStyle.lineBreakMode = label.lineBreakMode - } var attributes: [NSAttributedString.Key: Any] = [ .font: label.font as Any, .foregroundColor: label.textColor as Any, .paragraphStyle: paragraphStyle, ] - let isNetworkFallback = false - if currentItem?.type == "market" - || (currentItem?.data["height"] != nil - && (["accountSelector", "walletSidebar"].contains( - currentItem?.data.string("presentation") ?? "") - || currentItem?.type == "sectionHeader" - && currentItem?.data.string("presentation") == "networkSelector")) - || isNetworkFallback + if currentItem?.data["height"] != nil + && currentItem?.data.string("presentation") == "networkSelector" { - // OneKey patch: React Native centers font metrics inside explicit line heights. let baselineOffset = max(0, (lineHeight - label.font.lineHeight) / 2) - // OneKey patch: TextKit's 14/20 headings align their baseline to the upper physical pixel. - let isSelectorHeading = - lineHeight == 20 - && (currentItem?.type == "sectionHeader" - && currentItem?.data.string("presentation") == "networkSelector" - || currentItem?.type == "market" && label.font.pointSize == 14) let scale = window?.screen.scale ?? traitCollection.displayScale attributes[.baselineOffset] = - isSelectorHeading && scale > 0 ? ceil(baselineOffset * scale) / scale : baselineOffset - } - if letterSpacing != 0 || currentItem?.type == "market" - || currentItem?.data.string("presentation") == "market" - { - attributes[.kern] = letterSpacing + lineHeight == 20 && scale > 0 ? ceil(baselineOffset * scale) / scale : baselineOffset } + if letterSpacing != 0 { attributes[.kern] = letterSpacing } label.attributedText = NSAttributedString(string: text, attributes: attributes) } private func setButtonLine( @@ -496,21 +474,17 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.minimumLineHeight = lineHeight paragraphStyle.maximumLineHeight = lineHeight - let isMarketText = currentItem?.type == "market" let isSelectorValue = currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil && currentItem?.data.string("variant") != "summary" let isSelectorSummary = - currentItem?.type == "sectionHeader" - && currentItem?.data.string("presentation") == "networkSelector" + currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil && currentItem?.data.string("variant") == "summary" (button as? NativeListAccessoryButton)?.selectorSummaryLineHeight = isSelectorSummary ? lineHeight : nil - (button as? NativeListAccessoryButton)?.marketLineHeight = isMarketText ? lineHeight : nil // OneKey patch: summary text uses its source line box; currency retains trailing alignment. - // Market's line box already handles alignment; source text starts at its origin. paragraphStyle.alignment = - isSelectorValue ? .right : isSelectorSummary || isMarketText ? .natural : .center + isSelectorValue ? .right : isSelectorSummary ? .natural : .center if isSelectorSummary { button.contentHorizontalAlignment = .leading button.titleLabel?.textAlignment = .natural @@ -520,20 +494,14 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { button.titleLabel?.textAlignment = .right } let baselineOffset: CGFloat = - isMarketText - || currentItem?.type == "sectionHeader" - && currentItem?.data.string("presentation") == "networkSelector" - && currentItem?.data["height"] != nil + isSelectorValue || isSelectorSummary ? max(0, (lineHeight - font.lineHeight) / 2) : 0 - var attributes: [NSAttributedString.Key: Any] = [ + let attributes: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: color, .paragraphStyle: paragraphStyle, - .baselineOffset: isMarketText && lineHeight == 20 && font.pointSize == 14 - ? ceil(baselineOffset * max(1, traitCollection.displayScale)) - / max(1, traitCollection.displayScale) : baselineOffset, + .baselineOffset: baselineOffset, ] - if isMarketText { attributes[.kern] = 0 } button.setAttributedTitle( NSAttributedString(string: text, attributes: attributes), for: .normal From 81fd3b337a017616c0df61a679123b7508221254 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 21:59:41 +0800 Subject: [PATCH 52/71] docs(native-list): complete stage four and make header examples explicit --- .../pages/NativeListExamplePage.tsx | 12 ++++ .../react-native-native-list/docs/DESIGN.md | 67 +++++++++++++++++-- .../docs/MIGRATION_BASELINE.md | 21 +++++- .../react-native-native-list/docs/SPEC.md | 47 ++++++++++--- 4 files changed, 133 insertions(+), 14 deletions(-) diff --git a/example/react-native/pages/NativeListExamplePage.tsx b/example/react-native/pages/NativeListExamplePage.tsx index ef6a6cee7..32bb22b9e 100644 --- a/example/react-native/pages/NativeListExamplePage.tsx +++ b/example/react-native/pages/NativeListExamplePage.tsx @@ -2137,6 +2137,12 @@ const actionRows: readonly RowModel[] = [ key: 'action-token-section-header', sectionKey: 'action-tokens', title: 'Tokens', + style: { + container: { height: 30, contentVerticalAlignment: 'bottom' }, + horizontalPadding: 12, + verticalPadding: 0, + title: { fontSize: 14, lineHeight: 20, fontWeight: 'regular' }, + }, }, ...tokenManagerRows, { type: 'system', key: 'action-section-gap', variant: 'spacer', height: 64 }, @@ -2417,6 +2423,12 @@ function snapshotFor({ key: 'linear-token-section-header', sectionKey: 'linear-tokens', title: 'Tokens', + style: { + container: { height: 30, contentVerticalAlignment: 'bottom' }, + horizontalPadding: 12, + verticalPadding: 0, + title: { fontSize: 14, lineHeight: 20, fontWeight: 'regular' }, + }, }, ...tokenManagerRows, ], diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index 29130a5e7..07c92a81f 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -108,8 +108,9 @@ Message or Rail. No public plugin API or Nitro schema is added. Remaining templa and partial updates are retired only with their own migration. See [SPEC.md](SPEC.md#conformance-and-six-stage-migration) for all six stages. -Structural reuse is partitioned into three internal families on all platforms: -`message`, `rail` and `legacy`. Unmigrated templates retain their existing shared tree. +Structural reuse now has eleven dedicated families on all platforms; see the +current registry inventory in SPEC.md. The `legacy` family serves WalletGroup +and retains its shared native member tree until stage 5. Neither `row.key`, content, style, height nor placement changes the family. Web selects a compatible wrapper pool even when the row at a mounted index changes family. iOS registers separate reuse identifiers and reloads retained @@ -418,8 +419,8 @@ not scrolling-performance measurements. Stage 3 is complete: Rail, MediaTile, Action, System, Activity, DataRow and MetricCard (7/7). With the earlier Message pilot, eight template types now use -the closed renderer registry. Stage 4 still owns Market/Identity/SectionHeader, -stage 5 owns WalletGroup, and stage 6 removes their remaining legacy policies. +the closed renderer registry at that checkpoint. Stage 4 completion is recorded +below; stage 5 owns WalletGroup, and stage 6 removes remaining legacy policies. Historical “remaining stage 3” lists above describe each incremental checkpoint. The final iOS ownership pass moves measurement and size-preset policy into each @@ -428,3 +429,61 @@ After rebuilding and reinstalling, default → styled → cleared heights were verified as MediaTile 233 → 260 → 233, Action 60 → 100 → 60, System retry 44 → 160 → 44, Activity 60 → 160 → 60, DataRow 60 → 120 → 60 and MetricCard 132 → 240 → 132 points. The runtime stores `ios-renderer-measurement-final.json`. + +## Stage 4: Market, Identity and SectionHeader (2026-09-23) + +The closed registries now dispatch eleven templates to dedicated native hosts +and Web bodies. Market owns its leading visual, bounded badge slots, quote +controls and quote-only binding. Identity owns title/subtitle/tertiary, selector +presentation and trailing controls. SectionHeader owns title/value/checkbox, +summary updates and the platform-specific header measurement. iOS container +measurement no longer contains branches for these three templates. Android's +sticky header uses the same registered SectionHeader host as ordinary rows. +WalletGroup's nested native Identity implementation remains scoped to stage 5; +the unused legacy Market/Header branches and global style maps are stage 6 +cleanup, not an alternative dispatch path for these migrated rows. + +Shared primitives accept only the geometry/typography needed by these renderers. +They retain unchanged image requests and reset style defaults before each full +bind. Web restores image geometry without restoring stale async opacity. +Accessory orientation/spacing starts from the renderer's defaults on every bind, +including style removal without recycling. Selector rounding, native idle corner +radii and explicit-height precedence remain intact. Header partial updates +retain resolved font metrics, so repeated updates do not rescale Android text. +The template-local iOS helpers no longer branch on other template types. + +The baseline comparison included token/stock/perp Market, standard/account/ +network/sidebar Identity, and standard/summary/gallery/history/network headers. +All were exercised with style set/clear and same-key family changes. Fresh legacy +iOS Identity/Header hosts matched the extracted text layouts; the original +mixed-family baseline also exposed stale text attributes inherited from Market. +Dedicated reuse families prevent that cross-template state from being reused. + +Interaction acceptance covers quote patches with preserved style/image state, +badge actions, press-in and long-press anchors with a window point, checkbox +selection, summary title/value patches and subsequent value actions, end/top +scroll, empty/repopulation and the common fixed footer. The real section fixture +contains three indexed headers and ten Identity members per section. It exposed +two container problems: iOS selected pinned headers from the previous visible-cell +set during imperative scrolling; Android's pre-draw overlay could remain at zero +size beneath a React Native parent. The iOS container now uses original layout +positions for pinning/index highlighting, and Android lays out its measured +header overlay when needed. These policies remain outside row renderers. + +The repository caller audit is recorded in MIGRATION_BASELINE.md. History +examples already specify their variant; both Token Manager example headers now +specify rowStyle height/typography/spacing. External-consumer compatibility +fallbacks remain until the stage 6 audit. + +Validation artifacts are in the task's external-drive validation runtime: +`ios-stage4-*` and `android-stage4-*` screenshots/accessibility trees, with Web +captures under the stage 4 harness. These are native simulator and headed Chrome +functional/visual checks, not a consumer-app release acceptance or a scrolling +performance measurement. Web also exercised narrow/RTL sections, index clicks +and image-node retention after a quote patch. + +Stage 4 completion: package typecheck and 161 tests across five suites pass, +with zero focused lint errors (two existing shadowing warnings). iOS Debug +build/link passes; Android Debug APK and seven unit tests across three suites +pass. Both native runtimes passed the interaction sequence after the container +fixes. Stage 5 WalletGroup/member dragging and stage 6 legacy/key cleanup remain. diff --git a/native-views/react-native-native-list/docs/MIGRATION_BASELINE.md b/native-views/react-native-native-list/docs/MIGRATION_BASELINE.md index 65fdcc3b8..927345935 100644 --- a/native-views/react-native-native-list/docs/MIGRATION_BASELINE.md +++ b/native-views/react-native-native-list/docs/MIGRATION_BASELINE.md @@ -14,9 +14,9 @@ sets. Generated bindings and examples do not define renderer semantics. | Rule | Existing behavior and source | Disposition | | --- | --- | --- | -| `history-` prefix | iOS `NativeListCell.bindSectionHeader` and `RNCNativeListView.rowHeight`: row key **or** section key implies history title/height. Web `estimateWebRowHeight`: same two keys imply 16-unit header height; its rendering still follows the explicit variant. Android `bindSectionHeader`/`applySize`: only **section key** implies uppercase 12/16 title, 0 padding and 16-unit minimum height. Explicit `variant: history` works independently. | Preserve the platform differences in legacy renderers. Stage 4 must migrate callers to explicit `variant: history`; stage 6 can remove these fallbacks after that audit. | +| `history-` prefix | iOS `NativeListCell.bindSectionHeader` and `RNCNativeListView.rowHeight`: row key **or** section key implies history title/height. Web `estimateWebRowHeight`: same two keys imply 16-unit header height; its rendering still follows the explicit variant. Android `bindSectionHeader`/`applySize`: only **section key** implies uppercase 12/16 title, 0 padding and 16-unit minimum height. Explicit `variant: history` works independently. | Stage 4 moved these defaults into the SectionHeader renderers. Both example history headers and the validation fixture already set `variant: history`; no implicit history-header caller remains in this repository. Stage 6 still requires an external-consumer audit before deleting the compatibility fallback. | | `token-`, `balance-token-`, exact `linear-custom-token` | Android `NativeListRowView.bind` suppresses `separator: true` for these keys across row types. iOS/Web have no equivalent key exception. | Preserve as an explicitly named Android legacy container policy, including Message. Do not embed it in Message style resolution. Stage 6 requires caller-owned `separator: false` before removal. | -| exact section keys `linear-tokens`, `action-tokens` | Android `bindSectionHeader`/`applySize` applies token-manager 14/20 regular typography, horizontal 12/top 10/bottom 0 padding and 30-unit minimum height (subject to earlier explicit presentation/variant branches). No matching iOS/Web rule. | Preserve until stage 4 moves the caller to explicit style/height; then remove in stage 6. | +| exact section keys `linear-tokens`, `action-tokens` | Android `bindSectionHeader`/`applySize` applies token-manager 14/20 regular typography, horizontal 12/top 10/bottom 0 padding and 30-unit minimum height (subject to earlier explicit presentation/variant branches). No matching iOS/Web rule. | Stage 4 migrated both example callers to an explicit 30-unit container height, bottom alignment, 12-unit horizontal / zero vertical padding and regular 14/20 title. The fallback remains in SectionHeader until stage 6 verifies external consumers. | | exact tail keys `market-loading-more`, `market-load-more-retry`, `market-end` | Android `NativeListView.isMarketPaginationUpdate` drops those trailing rows before checking a stable Market prefix when either snapshot has loadMore; this keeps the scroll anchor during pagination. It is a container update optimization, not a row renderer. | Preserve in the container. Replace with explicit structural-row recognition in a separately validated pagination change. | | exact column key `asset` | Web `createDataRow` adds row badges only to that table column. Native table binders attach row badges to the first column instead. | Preserve until stage 3 audits DataRow callers; do not silently broaden every column's badge behavior. | @@ -84,3 +84,20 @@ badge-gap estimate inside its trailing budget and Android's status allowance) and now applies explicit resolved style metrics. Style removal restores the baseline dimensions. Rail does not show persistent row selection; press and reorder feedback remain container-owned. + +## Stage 4 caller audit + +The 2026-09-23 search covered all tracked source and examples. +`example/react-native/pages/NativeListExamplePage.tsx` has two history headers; +both already supply `variant: history`. Other `history-` occurrences identify +Activity section membership or explicitly styled validation fixtures. The two +Token Manager headers now declare their geometry and typography through the +shared rowStyle contract. Bottom alignment puts the 20-unit title at the bottom +of its 30-unit row without requiring a new asymmetric-padding property. +Those explicit style values use logical units on all platforms, replacing +Android's old scaled default at these caller sites. + +This repository audit does not establish that external app-monorepo consumers +have migrated. Keep the documented native/Web key fallbacks until stage 6 +checks those callers. WalletGroup's nested native Identity cells continue to +use the old implementation until the separately scoped stage 5 migration. diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index fa08f49b5..a818e1711 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -44,8 +44,9 @@ UI thread; [DESIGN.md](DESIGN.md) describes update and event ownership. `row.key` identifies data; renderer reuse identity describes compatible view structure. Content, styles, height and placement must not enter reuse identity. -The implemented families are `message`, `rail`, `mediaTile`, `action` and `legacy`; the latter serves all -unmigrated templates. Same-key family changes must replace the incompatible +Eleven templates have their own renderer family: Message, Rail, MediaTile, Action, +System, Activity, DataRow, MetricCard, Market, Identity and SectionHeader. +The `legacy` family serves WalletGroup until stage 5. Same-key family changes must replace the incompatible host. OneKeyImage owns image caches; NativeList owns row/slot binding identity. ## Platform contract @@ -67,7 +68,8 @@ migration adds no new input limits, retry policy or automatic layout correction. ## Performance and resources Snapshots and patches cross the bridge in batches. Visible rows use platform -recycling/windowing; shared primitives own image cancellation. Message and all seven stage 3 templates use dedicated lightweight native hosts and persistent Web bodies. +recycling/windowing; shared primitives own image cancellation. Message, all seven stage 3 templates, and Market/Identity/SectionHeader use dedicated +lightweight native hosts and persistent Web bodies. None allocates the legacy native tree. This structural change is not a measured scrolling-performance result; performance claims still need a separate workload and measurements. @@ -79,15 +81,16 @@ separate workload and measurements. | 1. Baseline | Complete: [implicit keys, defaults, special updates and acceptance baseline](MIGRATION_BASELINE.md) inventoried | Preserve each existing rule or explicitly migrate its caller | | 2. Message pilot | Complete: registered lightweight native hosts, persistent Web body, resolved styling/measurement and lifecycle acceptance | Lightweight native hosts, resolved styling/measurement, update classification and lifecycle acceptance | | 3. Simple templates | Complete (7/7): Rail, MediaTile, Action, System, Activity, DataRow and MetricCard migrated on all three platforms | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | -| 4. Complex templates | Not started | Migrate Market, Identity and SectionHeader while preserving quote, selection and header behavior | +| 4. Complex templates | Complete (3/3): Market, Identity and SectionHeader migrated on all three platforms | Migrate Market, Identity and SectionHeader while preserving quote, selection and header behavior | | 5. WalletGroup | Not started | Independent member renderers; verify member events/styles, compact dragging and height restoration | | 6. Cleanup | Not started | Remove migrated key inference and global style maps; container no longer manipulates template internals | -The closed internal registry selects Message and the seven stage 3 renderers by template type. Market, Identity, SectionHeader and WalletGroup remain in the legacy family. Message owns +The closed internal registry selects eleven renderers by template type. WalletGroup +remains in the legacy family. Message owns its text column, optional leading visual and thumbnail; its native host does not allocate Market, WalletGroup or table views. Web preserves the Message body, -text nodes and unchanged image elements when rebinding. Remaining templates -continue through the legacy family. +text nodes and unchanged image elements when rebinding. WalletGroup is the +only remaining top-level legacy template. Message resolves text/spacing/image inputs from data/theme/styles, classifies updates as unchanged, content/style, assets or replacement, and binds/measures @@ -156,7 +159,35 @@ defined in STYLE_SPEC.md. Image slots retain unchanged requests across rebinding and reset when a source disappears or its slot identity changes. Stage 3 completion is not completion of the six-stage architecture plan. -Remaining work is stage 4 (Market, Identity, SectionHeader), stage 5 +Stage 4 is recorded below. Remaining work after it is stage 5 (WalletGroup member rendering and drag acceptance), then stage 6 (retire the remaining compatibility inference/style maps and container access to internals). No migrated simple template dispatches through a legacy binder. + +### Stage 4 acceptance scope + +Market, Identity and SectionHeader move to dedicated registered renderers on all +three platforms. This stage preserves existing defaults and implicit-key +compatibility recorded in MIGRATION_BASELINE.md. It adds no public style fields. +Market owns quote-only binding and its platform row interactions; Identity owns +selection presentation; SectionHeader owns stable summary updates and measurement. +Sticky placement and header/footer routing stay in the list container. +Acceptance includes variant changes on the same key, styled → unstyled rebinding, +unchanged image requests, partial updates with current action context, selection, +scroll/recycle, and shared footer/sticky-header behavior. + +Each complex renderer now owns its view tree, defaults, semantic style slots and +measurement. Native hosts share bounded leading/image/accessory primitives. Web +retains the compatible body and unchanged image elements; loaded image state is +not reset when geometry is restored. Quote and summary updates keep the latest +row model for subsequent action/selection dispatch. No new public API or Nitro +schema is introduced. The remaining native composite tree is retained only for +WalletGroup and its nested members until stages 5–6. + +Stage 4 validation: package typecheck and all 161 tests in five suites pass; +focused lint has zero errors and two existing shadowing warnings. iOS Debug +build/link and Android Debug APK plus seven unit tests pass. Both dedicated +native simulators and headed Chrome exercised the acceptance cases above. +The section fixture additionally verified imperative scroll pinning and active +index updates after the container fixes. Remaining stages are WalletGroup (5) +and compatibility/global-map cleanup (6); this is not a performance signoff. From a13172a4c3d2f0961a5000d5d4337bddfc86dc9d Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 22:24:22 +0800 Subject: [PATCH 53/71] refactor(native-list): register keyed WalletGroup web renderer --- .../react-native-native-list/docs/SPEC.md | 20 ++- .../src/__tests__/NativeList.web.test.ts | 84 +++++++++++ .../src/web/NativeListWebEngine.ts | 132 +----------------- .../src/web/templates/RowContainerStyle.ts | 66 +++++++++ .../src/web/templates/RowRendererRegistry.ts | 5 + .../web/templates/WalletGroupRowRenderer.ts | 109 +++++++++++++++ 6 files changed, 285 insertions(+), 131 deletions(-) create mode 100644 native-views/react-native-native-list/src/web/templates/RowContainerStyle.ts create mode 100644 native-views/react-native-native-list/src/web/templates/WalletGroupRowRenderer.ts diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index a818e1711..df8c97c53 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -82,7 +82,7 @@ separate workload and measurements. | 2. Message pilot | Complete: registered lightweight native hosts, persistent Web body, resolved styling/measurement and lifecycle acceptance | Lightweight native hosts, resolved styling/measurement, update classification and lifecycle acceptance | | 3. Simple templates | Complete (7/7): Rail, MediaTile, Action, System, Activity, DataRow and MetricCard migrated on all three platforms | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | | 4. Complex templates | Complete (3/3): Market, Identity and SectionHeader migrated on all three platforms | Migrate Market, Identity and SectionHeader while preserving quote, selection and header behavior | -| 5. WalletGroup | Not started | Independent member renderers; verify member events/styles, compact dragging and height restoration | +| 5. WalletGroup | In progress | Independent member renderers; verify member events/styles, compact dragging and height restoration | | 6. Cleanup | Not started | Remove migrated key inference and global style maps; container no longer manipulates template internals | The closed internal registry selects eleven renderers by template type. WalletGroup @@ -191,3 +191,21 @@ native simulators and headed Chrome exercised the acceptance cases above. The section fixture additionally verified imperative scroll pinning and active index updates after the container fixes. Remaining stages are WalletGroup (5) and compatibility/global-map cleanup (6); this is not a performance signoff. + +### Stage 5 acceptance contract (implementation in progress) + +WalletGroup owns only member composition, measurement and compact presentation. +Each member uses the registered Identity renderer and retains its own key, style, +selection, action origin and binding lifetime. Group styles never cascade into +members. Rebinding reconciles by member key; removed members are recycled +immediately, so retention is bounded by the current member count (plus one +compact parent on native). Recycling releases every member and compact parent. + +Acceptance covers member press/accessory routing, disabled and pressDisabled +members, selection-only changes, style set/change/clear, member reorder/removal, +family replacement and reuse. Native and Web drag previews keep the existing +68-point compact allocation; `+N` counts children whose draggable is not false. +A non-draggable member cannot initiate group drag. Drop/cancel restore the +resolved group height and all configured member/container styles. Reorder +orchestration stays in the list container. Existing platform defaults stay +unchanged. Verify on the dedicated external-drive simulators and headed Web. diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index e59f828b6..ebc11f86c 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -2596,4 +2596,88 @@ describe('complex renderer lifecycle', () => { engine.destroy(); } }); + it('reconciles WalletGroup members by key and clears local styles without replacing images', () => { + const { document } = new JSDOM('').window; + const host = document.createElement('div'); + document.body.appendChild(host); + const parent: IdentityRow = { + type: 'identity', + key: 'group', + presentation: 'walletSidebar', + title: 'Parent', + leading: { kind: 'wallet', image }, + height: 68, + }; + const child = { ...parent, key: 'child', title: 'Child' }; + const removed = { ...parent, key: 'removed', title: 'Removed' }; + const row: Extract = { + type: 'walletGroup', + key: parent.key, + parent, + children: [child, removed], + }; + const engine = new NativeListWebEngine(host, snap(row), {}, false); + try { + const body = host.querySelector( + '[data-nl-renderer="walletGroup"]' + )!; + const member = body.querySelector( + '[data-native-list-group-member-key="child"]' + )!; + const imageElement = member.querySelector('img'); + const discarded = body.querySelector( + '[data-native-list-group-member-key="removed"]' + )!; + engine.applySnapshot( + snap({ + ...row, + children: [ + removed, + { + ...child, + style: { + container: { height: 96, opacity: 0.6 }, + title: { fontSize: 21 }, + }, + }, + ], + style: { + horizontalPadding: 9, + verticalPadding: 3, + container: { backgroundColor: '#123456' }, + }, + }) + ); + expect(body.children[2]).toBe(member); + expect(member.querySelector('img')).toBe(imageElement); + expect(member.style.height).toBe('96px'); + expect(body.style.paddingInline).toBe('9px'); + expect( + body.querySelector('[data-nl-slot="title"]')!.style + .fontSize + ).not.toContain('21px'); + engine.applySnapshot(snap({ ...row, children: [child] })); + expect(body.children).toHaveLength(2); + expect(body.children[1]).toBe(member); + expect(member.querySelector('img')).toBe(imageElement); + expect(member.style.height).toBe('68px'); + expect(member.style.opacity).toBe('1'); + expect( + member.querySelector('[data-nl-slot="title"]')!.style + .fontSize + ).toBe(''); + expect(body.style.paddingInline).toBe(''); + expect(body.style.getPropertyValue('--nl-container-background')).toBe(''); + expect( + discarded.querySelector('[data-nl-renderer="identity"]')!.childNodes + ).toHaveLength(0); + engine.applySnapshot(snap(parent)); + expect(host.querySelector('[data-nl-renderer="walletGroup"]')).toBeNull(); + expect( + host.querySelector('[data-nl-renderer="identity"]')!.textContent + ).toContain('Parent'); + } finally { + engine.destroy(); + } + }); }); diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index 40cc48900..2cefd6246 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -1,4 +1,4 @@ -import { identityRowRenderer } from './templates/IdentityRowRenderer'; +import { applyRowContainerStyle } from './templates/RowContainerStyle'; import { applyTextStyleToSlot, applyValueSegments } from './templates/RowText'; import { setMarketQuoteContent } from './templates/MarketRowRenderer'; export { @@ -444,18 +444,6 @@ export function estimateWebRowHeight( // if (row.type === 'system' && row.variant === 'spacer') return row.height; const explicitHeight = row.style?.container?.height ?? row.height; if (explicitHeight !== undefined) return explicitHeight; - if (row.type === 'walletGroup') - // OneKey patch: wallet badges participate in the outer group height. - // return (row.children.length + 1) * 68 + row.children.length * 12; - return ( - [row.parent, ...row.children].reduce( - (height, member) => - height + estimateWebRowHeight(member, snapshot, availableWidth), - 0 - ) + - row.children.length * 12 + - (row.parent.height !== undefined ? 2 : 0) - ); const registered = rowRenderer(row); if (registered) return Math.max( @@ -1931,121 +1919,8 @@ function applySelectorTabularNumbers(body: HTMLElement, row: RowModel) { }); } -function createWalletGroupRow( - context: RenderContext, - row: Extract -): HTMLElement { - const body = createElement( - context.document, - 'div', - 'ok-native-list-wallet-group' - ); - [row.parent, ...row.children].forEach((member, memberIndex) => { - const memberElement = createElement( - context.document, - 'div', - 'ok-native-list-wallet-member' - ); - setData(memberElement, 'nativeListGroupMemberKey', member.key); - setData(memberElement, 'testid', member.testID); - setData(memberElement, 'nativeListGroupParent', memberIndex === 0); - setData(memberElement, 'nativeListSelected', member.selected); - // OneKey patch: grouped members have the same selector typography as standalone wallets. - // memberElement.appendChild(identityRowRenderer.create(context.document,member,rendererPrimitives(context))); - const memberBody = identityRowRenderer.create( - context.document, - member, - rendererPrimitives(context) - ); - applySelectorTabularNumbers(memberBody, member); - applyRowStyle(memberBody, member); - memberElement.appendChild(memberBody); - // OneKey patch: group children use their own measured badge height. - memberElement.style.flexBasis = - String( - member.style?.container?.height ?? - member.height ?? - 68 + (member.badges?.length ? 24 : 0) - ) + 'px'; - memberElement.style.height = memberElement.style.flexBasis; - memberElement.style.opacity = String( - (member.style?.container?.opacity ?? member.opacity ?? 1) * - (member.disabled ? 0.5 : 1) - ); - if (member.style?.container?.cornerRadius !== undefined) - memberElement.style.borderRadius = `${member.style.container.cornerRadius}px`; - body.appendChild(memberElement); - }); - return body; -} - /** Text owns a single wrapping budget, including its rich runs. */ -function applyRowContainerStyle(body: HTMLElement, row: RowModel): void { - if ( - row.type === 'system' && - row.variant === 'warning' && - (row.style?.container?.height ?? row.height) !== undefined - ) - body.style.height = '100%'; - const container = row.style?.container; - if (!container) return; - if (container.backgroundColor !== undefined) { - body.style.setProperty( - '--nl-container-background', - container.backgroundColor - ); - body.setAttribute('data-nl-container-background', 'true'); - // A CSS rule allows existing hover/press feedback to override the resting fill. - body.style.removeProperty('background'); - body.style.removeProperty('background-color'); - } - if (container.cornerRadius !== undefined) - body.style.borderRadius = `${container.cornerRadius}px`; - if (container.borderWidth !== undefined) { - // Paint above member backgrounds without changing the content box or hit targets. - body.setAttribute('data-nl-container-border', 'true'); - body.style.setProperty( - '--nl-container-border-width', - `${container.borderWidth}px` - ); - body.style.setProperty( - '--nl-container-border-color', - container.borderColor ?? 'transparent' - ); - body.style.borderColor = 'transparent'; - } else if (container.borderColor !== undefined) - body.style.borderColor = container.borderColor; - if (container.contentVerticalAlignment !== undefined) { - const alignment = { - top: 'flex-start', - center: 'center', - bottom: 'flex-end', - }[container.contentVerticalAlignment]; - const vertical = - row.type === 'walletGroup' || - row.type === 'mediaTile' || - row.type === 'metricCard' || - (row.type === 'identity' && row.presentation === 'walletSidebar') || - (row.type === 'system' && row.variant === 'warning'); - if (vertical) { - body.style.display = 'flex'; - body.style.flexDirection = 'column'; - if (row.type === 'mediaTile') body.style.gap = '0px'; - body.style.justifyContent = alignment; - Array.from(body.children).forEach((child) => { - (child as HTMLElement).style.flexShrink = '0'; - }); - } else { - body.style.alignItems = alignment; - // Timestamp/accessory defaults may have their own align-self. - Array.from(body.children).forEach((child) => { - (child as HTMLElement).style.alignSelf = alignment; - }); - } - } -} - /** * Applies a row style once the template has been built. A style key names the * model field it modifies and the element rendering that field carries @@ -2081,10 +1956,6 @@ export function createRowBody( const registered = rowRenderer(row); if (registered) return registered.create(context.document, rendererPrimitives(context)); - switch (row.type) { - case 'walletGroup': - return createWalletGroupRow(context, row); - } throw new Error('No renderer registered for ' + row.type); } @@ -2114,6 +1985,7 @@ export class NativeListWebEngine { private readonly mounted = new Map(); private readonly pool: Record = { legacy: [], + walletGroup: [], message: [], rail: [], mediaTile: [], diff --git a/native-views/react-native-native-list/src/web/templates/RowContainerStyle.ts b/native-views/react-native-native-list/src/web/templates/RowContainerStyle.ts new file mode 100644 index 000000000..332f188ec --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/RowContainerStyle.ts @@ -0,0 +1,66 @@ +import type { RowModel } from '../../models'; + +export function applyRowContainerStyle(body: HTMLElement, row: RowModel): void { + if ( + row.type === 'system' && + row.variant === 'warning' && + (row.style?.container?.height ?? row.height) !== undefined + ) + body.style.height = '100%'; + const container = row.style?.container; + if (!container) return; + if (container.backgroundColor !== undefined) { + body.style.setProperty( + '--nl-container-background', + container.backgroundColor + ); + body.setAttribute('data-nl-container-background', 'true'); + // A CSS rule allows existing hover/press feedback to override the resting fill. + body.style.removeProperty('background'); + body.style.removeProperty('background-color'); + } + if (container.cornerRadius !== undefined) + body.style.borderRadius = `${container.cornerRadius}px`; + if (container.borderWidth !== undefined) { + // Paint above member backgrounds without changing the content box or hit targets. + body.setAttribute('data-nl-container-border', 'true'); + body.style.setProperty( + '--nl-container-border-width', + `${container.borderWidth}px` + ); + body.style.setProperty( + '--nl-container-border-color', + container.borderColor ?? 'transparent' + ); + body.style.borderColor = 'transparent'; + } else if (container.borderColor !== undefined) + body.style.borderColor = container.borderColor; + if (container.contentVerticalAlignment !== undefined) { + const alignment = { + top: 'flex-start', + center: 'center', + bottom: 'flex-end', + }[container.contentVerticalAlignment]; + const vertical = + row.type === 'walletGroup' || + row.type === 'mediaTile' || + row.type === 'metricCard' || + (row.type === 'identity' && row.presentation === 'walletSidebar') || + (row.type === 'system' && row.variant === 'warning'); + if (vertical) { + body.style.display = 'flex'; + body.style.flexDirection = 'column'; + if (row.type === 'mediaTile') body.style.gap = '0px'; + body.style.justifyContent = alignment; + Array.from(body.children).forEach((child) => { + (child as HTMLElement).style.flexShrink = '0'; + }); + } else { + body.style.alignItems = alignment; + // Timestamp/accessory defaults may have their own align-self. + Array.from(body.children).forEach((child) => { + (child as HTMLElement).style.alignSelf = alignment; + }); + } + } +} diff --git a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts index 73bcb8468..01a667d17 100644 --- a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts +++ b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts @@ -1,3 +1,5 @@ +import type { WalletGroupRow } from '../../models'; +import { walletGroupRowRenderer } from './WalletGroupRowRenderer'; import type { IdentityRow, SectionHeaderRow } from '../../models'; import { identityRowRenderer } from './IdentityRowRenderer'; import { sectionHeaderRowRenderer } from './SectionHeaderRowRenderer'; @@ -46,6 +48,8 @@ function registration(renderer: Renderer, row: R) { }; } const factories = { + walletGroup: (row: RowModel) => + registration(walletGroupRowRenderer, row as WalletGroupRow), identity: (row: RowModel) => registration(identityRowRenderer, row as IdentityRow), sectionHeader: (row: RowModel) => @@ -65,6 +69,7 @@ const factories = { registration(mediaTileRowRenderer, row as MediaTileRow), }; const renderers = { + walletGroup: walletGroupRowRenderer, identity: identityRowRenderer, sectionHeader: sectionHeaderRowRenderer, market: marketRowRenderer, diff --git a/native-views/react-native-native-list/src/web/templates/WalletGroupRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/WalletGroupRowRenderer.ts new file mode 100644 index 000000000..162bd4885 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/WalletGroupRowRenderer.ts @@ -0,0 +1,109 @@ +import type { IdentityRow, WalletGroupRow } from '../../models'; +import { identityRowRenderer } from './IdentityRowRenderer'; +import { applyRowContainerStyle } from './RowContainerStyle'; +import { setData } from './RowElements'; +import type { RowPrimitives } from './RowVisual'; + +type Member = { wrapper: HTMLElement; body: HTMLElement }; +const membersByBody = new WeakMap>(); +const memberHeight = (member: IdentityRow, width: number) => + member.style?.container?.height ?? + member.height ?? + identityRowRenderer.measure(member, width); + +function bind( + body: HTMLElement, + row: WalletGroupRow, + primitives: RowPrimitives +) { + const members = membersByBody.get(body)!; + const nextKeys = new Set( + [row.parent, ...row.children].map((member) => member.key) + ); + for (const [key, member] of members) { + if (!nextKeys.has(key)) { + identityRowRenderer.recycle(member.body); + member.wrapper.remove(); + members.delete(key); + } + } + body.style.cssText = ''; + for (const name of body.getAttributeNames()) + if (name.startsWith('data-') && name !== 'data-nl-renderer') + body.removeAttribute(name); + if (row.style?.horizontalPadding !== undefined) + body.style.paddingInline = `${row.style.horizontalPadding}px`; + if (row.style?.verticalPadding !== undefined) + body.style.paddingBlock = `${row.style.verticalPadding}px`; + [row.parent, ...row.children].forEach((memberRow, index) => { + let member = members.get(memberRow.key); + if (member) identityRowRenderer.bind(member.body, memberRow, primitives); + else { + const wrapper = body.ownerDocument.createElement('div'); + wrapper.className = 'ok-native-list-wallet-member'; + const memberBody = identityRowRenderer.create( + body.ownerDocument, + memberRow, + primitives + ); + wrapper.appendChild(memberBody); + member = { wrapper, body: memberBody }; + members.set(memberRow.key, member); + } + const { wrapper, body: memberBody } = member; + setData(wrapper, 'nativeListGroupMemberKey', memberRow.key); + setData(wrapper, 'testid', memberRow.testID); + setData(wrapper, 'nativeListGroupParent', index === 0); + setData(wrapper, 'nativeListSelected', memberRow.selected); + wrapper.style.cssText = ''; + wrapper.style.flexBasis = `${memberHeight(memberRow, 0)}px`; + wrapper.style.height = wrapper.style.flexBasis; + wrapper.style.opacity = String( + (memberRow.style?.container?.opacity ?? memberRow.opacity ?? 1) * + (memberRow.disabled ? 0.5 : 1) + ); + if (memberRow.style?.container?.cornerRadius !== undefined) + wrapper.style.borderRadius = `${memberRow.style.container.cornerRadius}px`; + memberBody.style.fontVariantNumeric = 'tabular-nums'; + if (memberRow.backgroundColor) + memberBody.style.backgroundColor = memberRow.backgroundColor; + applyRowContainerStyle(memberBody, memberRow); + body.appendChild(wrapper); + }); +} +function create( + document: Document, + row: WalletGroupRow, + primitives: RowPrimitives +) { + const body = document.createElement('div'); + body.className = 'ok-native-list-wallet-group'; + body.dataset.nlRenderer = 'walletGroup'; + membersByBody.set(body, new Map()); + bind(body, row, primitives); + return body; +} +function recycle(body: HTMLElement) { + const members = membersByBody.get(body); + members?.forEach((member) => identityRowRenderer.recycle(member.body)); + members?.clear(); + body.replaceChildren(); +} +export const walletGroupRowRenderer = { + key: 'walletGroup' as const, + create, + bind, + recycle, + appliesSizePreset: () => false, + measure: (row: WalletGroupRow, width: number) => + [row.parent, ...row.children].reduce( + (total, member) => total + memberHeight(member, width), + 0 + ) + + row.children.length * 12 + + (row.parent.height !== undefined ? 2 : 0), + measureRendered: ( + _body: HTMLElement, + _row: WalletGroupRow + ): number | undefined => undefined, +}; From 9c4b01c9b1eda9931124af132edb733956213fa3 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 22:35:37 +0800 Subject: [PATCH 54/71] refactor(native-list): compose WalletGroup from keyed iOS Identity hosts --- .../ios/NativeListIdentityRenderer.swift | 11 + .../ios/NativeListModels.swift | 2 +- .../ios/NativeListRendererCell.swift | 13 +- .../ios/NativeListRendererRegistry.swift | 1 + .../ios/NativeListWalletGroupRenderer.swift | 242 ++++++++++++++++++ .../ios/RNCNativeListView.swift | 10 - 6 files changed, 264 insertions(+), 15 deletions(-) create mode 100644 native-views/react-native-native-list/ios/NativeListWalletGroupRenderer.swift diff --git a/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift b/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift index 21eb14dea..7c6f34ba4 100644 --- a/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift @@ -373,6 +373,17 @@ final class NativeListIdentityCell: NativeListRendererCell { mainStack.addArrangedSubview(line) selectorViews.append(line) } + let accessories = item.data.dictionaries("trailing") + if !accessories.isEmpty { + trailingStack.axis = .horizontal + trailingStack.alignment = .center + trailingStack.spacing = 8 + root.addArrangedSubview(trailingStack) + trailingStack.bind( + item, descriptors: accessories, theme: theme, + style: item.data.dictionary("style") ?? [:], defaultSpacing: 8, + checkboxState: checkboxState) + } return } if item.data.string("presentation") == "accountSelector" { diff --git a/native-views/react-native-native-list/ios/NativeListModels.swift b/native-views/react-native-native-list/ios/NativeListModels.swift index 9ce7397d3..8e2e9f334 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -4,7 +4,7 @@ import UIKit // Message is the first migrated renderer. Other templates still use the // compatible legacy tree; style, content and row keys do not affect reuse. enum NativeListRendererKey: String, CaseIterable { - case legacy, message, rail, mediaTile, action, system, activity, dataRow, metricCard, market, identity, sectionHeader + case legacy, message, rail, mediaTile, action, system, activity, dataRow, metricCard, market, identity, sectionHeader, walletGroup } struct NativeListItem { diff --git a/native-views/react-native-native-list/ios/NativeListRendererCell.swift b/native-views/react-native-native-list/ios/NativeListRendererCell.swift index 7020f65ba..9b3647a7d 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererCell.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererCell.swift @@ -6,6 +6,9 @@ enum NativeListRendererUpdate { case unchanged, content, assets, replace } class NativeListRendererCell: NativeListRowHost { let root = UIStackView() var contentInsets = UIEdgeInsets.zero + var usesIntrinsicContentHeight: Bool { false } + var defaultBorderWidth: CGFloat { 0 } + func defaultBorderColor(_ theme: [String: Any]?) -> UIColor { .clear } var defaultCornerRadius: CGFloat { 0 } var defaultCornerCurve: CALayerCornerCurve { .circular } var pressChangesBackground: Bool { true } @@ -99,7 +102,8 @@ class NativeListRendererCell: NativeListRowHost { if root.axis == .vertical, let alignment = item.data.dictionary("style")?.dictionary("container")?[ "contentVerticalAlignment"] as? String - ?? (item.styledHeight != nil ? (defaultVerticalAlignment ?? "top") : nil) + ?? (item.styledHeight != nil || usesIntrinsicContentHeight + ? (defaultVerticalAlignment ?? "top") : nil) { NSLayoutConstraint.deactivate(Array(rootConstraints.suffix(2))) contentPosition = @@ -153,7 +157,7 @@ class NativeListRendererCell: NativeListRowHost { bindingEpoch: bindingEpoch, source: source, slot: slot, anchorInset: anchorInset)) } - private func applyAppearance() { + func applyAppearance() { guard let item else { return } let container = item.data.dictionary("style")?.dictionary("container") ?? [:] let showSelection = @@ -190,10 +194,11 @@ class NativeListRendererCell: NativeListRowHost { layer.masksToBounds = radius > 0 contentView.layer.cornerRadius = radius contentView.layer.maskedCorners = layer.maskedCorners - contentView.layer.borderWidth = CGFloat(container.double("borderWidth")) + contentView.layer.borderWidth = CGFloat( + container.double("borderWidth", default: Double(defaultBorderWidth))) contentView.layer.borderColor = UIColor( - nativeListHex: container.string("borderColor", default: "#00000000"), fallback: .clear + nativeListHex: container.string("borderColor"), fallback: defaultBorderColor(theme) ).cgColor if item.data.bool("backgroundFullWidth"), let fill = (container["backgroundColor"] ?? item.data["backgroundColor"]) as? String diff --git a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift index 994ca02d9..25c97ecad 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift @@ -8,6 +8,7 @@ enum NativeListRendererRegistry { .sectionHeader: ( NativeListSectionHeaderCell.self, { NativeListSectionHeaderCell(frame: .zero) } ), + .walletGroup: (NativeListWalletGroupCell.self, { NativeListWalletGroupCell(frame: .zero) }), .identity: (NativeListIdentityCell.self, { NativeListIdentityCell(frame: .zero) }), .market: (NativeListMarketCell.self, { NativeListMarketCell(frame: .zero) }), .legacy: (NativeListCell.self, { NativeListCell(frame: .zero) }), diff --git a/native-views/react-native-native-list/ios/NativeListWalletGroupRenderer.swift b/native-views/react-native-native-list/ios/NativeListWalletGroupRenderer.swift new file mode 100644 index 000000000..616fe2738 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListWalletGroupRenderer.swift @@ -0,0 +1,242 @@ +import UIKit + +/// Composite only: member content, assets and local styles belong to Identity. +final class NativeListWalletGroupCell: NativeListRendererCell, UIGestureRecognizerDelegate { + private struct MemberView { + let cell: NativeListIdentityCell + let height: NSLayoutConstraint + } + private var members: [NativeListItem] = [] + private var memberViews: [String: MemberView] = [:] + private var compactCell: NativeListIdentityCell? + private let compactContainer = UIView() + private let dragBadge = NativeListInsetLabel() + private var compact = false + private lazy var memberTap = UITapGestureRecognizer( + target: self, action: #selector(memberPressed(_:))) + override var usesIntrinsicContentHeight: Bool { true } + override var defaultCornerRadius: CGFloat { 20 } + override var defaultCornerCurve: CALayerCornerCurve { .continuous } + override var defaultBorderWidth: CGFloat { 1 } + override var pressChangesBackground: Bool { false } + override var showsSelection: Bool { false } + override func defaultBorderColor(_ theme: [String: Any]?) -> UIColor { + nativeListColor(theme, "separator", "#E0E0E0") + } + override func unselectedBackground( + _ item: NativeListItem, theme: [String: Any]?, layout: String, itemIndex: Int? + ) -> UIColor { + nativeListColor(theme, "subduedBackground", "#F9F9F9") + } + private static func memberItems(_ item: NativeListItem) -> [NativeListItem] { + ([item.data.dictionary("parent")].compactMap { $0 } + item.data.dictionaries("children")) + .compactMap { try? NativeListItem(data: $0) } + } + private static func memberHeight(_ item: NativeListItem) -> CGFloat { + item.styledHeight + ?? (item.data["height"] != nil + ? CGFloat(item.data.double("height")) + : NativeListIdentityCell.measure(item, width: 0, theme: nil, layout: "linear") ?? 68) + } + override class func appliesSizePreset(_ item: NativeListItem) -> Bool { false } + override class func measure( + _ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String + ) -> CGFloat? { + let members = memberItems(item) + return members.reduce( + CGFloat(max(0, members.count - 1) * 12 + (members.first?.data["height"] != nil ? 2 : 0)) + ) { $0 + memberHeight($1) } + } + override init(frame: CGRect) { + super.init(frame: frame) + root.axis = .vertical + root.alignment = .fill + root.spacing = 12 + memberTap.delegate = self + root.addGestureRecognizer(memberTap) + compactContainer.translatesAutoresizingMaskIntoConstraints = false + compactContainer.isUserInteractionEnabled = false + compactContainer.isHidden = true + contentView.addSubview(compactContainer) + dragBadge.translatesAutoresizingMaskIntoConstraints = false + dragBadge.horizontalInset = 6 + dragBadge.textAlignment = .center + dragBadge.font = nativeListTabularFont(ofSize: 12, weight: .semibold) + dragBadge.layer.cornerRadius = 12 + dragBadge.layer.masksToBounds = true + dragBadge.layer.borderWidth = 1 + compactContainer.addSubview(dragBadge) + NSLayoutConstraint.activate([ + compactContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + compactContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + compactContainer.topAnchor.constraint(equalTo: contentView.topAnchor), + compactContainer.heightAnchor.constraint(equalToConstant: 68), + dragBadge.trailingAnchor.constraint(equalTo: compactContainer.trailingAnchor, constant: -4), + dragBadge.bottomAnchor.constraint(equalTo: compactContainer.bottomAnchor, constant: -4), + dragBadge.heightAnchor.constraint(equalToConstant: 24), + dragBadge.widthAnchor.constraint(greaterThanOrEqualToConstant: 24), + ]) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func accessibilityText(_ item: NativeListItem) -> String { + item.data.string( + "accessibilityLabel", default: item.data.dictionary("parent")?.string("title") ?? "") + } + override func bindContent( + _ item: NativeListItem, theme: [String: Any]?, layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + members = Self.memberItems(item) + let keys = Set(members.map(\.key)) + for key in Array(memberViews.keys) where !keys.contains(key) { + let view = memberViews.removeValue(forKey: key)! + view.cell.prepareForReuse() + root.removeArrangedSubview(view.cell) + view.cell.removeFromSuperview() + } + let style = item.data.dictionary("style") ?? [:] + let inset: Double = members.first?.data["height"] != nil ? 1 : 0 + let horizontal = CGFloat(style.double("horizontalPadding", default: inset)) + let vertical = CGFloat(style.double("verticalPadding", default: inset)) + contentInsets = UIEdgeInsets( + top: vertical, left: horizontal, bottom: vertical, right: horizontal) + for (index, member) in members.enumerated() { + let view: MemberView + if let existing = memberViews[member.key] { + view = existing + } else { + let cell = NativeListIdentityCell(frame: .zero) + cell.translatesAutoresizingMaskIntoConstraints = false + let height = cell.heightAnchor.constraint(equalToConstant: Self.memberHeight(member)) + height.isActive = true + view = MemberView(cell: cell, height: height) + memberViews[member.key] = view + cell.onAction = { [weak self] item, action, target, origin in + self?.onAction?(item, action, target, origin) + } + cell.onBindingInvalidated = { [weak self] cell, epoch in + self?.onBindingInvalidated?(cell, epoch) + } + } + view.height.constant = Self.memberHeight(member) + view.cell.listStyle = listStyle + view.cell.bind( + item: member, theme: theme, layout: layout, selected: member.data.bool("selected"), + checkboxState: checkboxState) + root.insertArrangedSubview(view.cell, at: index) + } + if let parent = members.first { + if compactCell == nil { + let cell = NativeListIdentityCell(frame: .zero) + cell.translatesAutoresizingMaskIntoConstraints = false + compactContainer.insertSubview(cell, at: 0) + NSLayoutConstraint.activate([ + cell.leadingAnchor.constraint(equalTo: compactContainer.leadingAnchor), + cell.trailingAnchor.constraint(equalTo: compactContainer.trailingAnchor), + cell.topAnchor.constraint(equalTo: compactContainer.topAnchor), + cell.bottomAnchor.constraint(equalTo: compactContainer.bottomAnchor), + ]) + compactCell = cell + } + compactCell?.listStyle = listStyle + compactCell?.bind( + item: parent, theme: theme, layout: layout, selected: parent.data.bool("selected"), + checkboxState: checkboxState) + } + let count = members.dropFirst().filter { ($0.data["draggable"] as? Bool) != false }.count + dragBadge.text = count > 0 ? "+\(count)" : nil + dragBadge.isHidden = count == 0 + dragBadge.backgroundColor = nativeListColor(theme, "inverseBackground", "#202020") + dragBadge.textColor = nativeListColor(theme, "inverseText", "#FCFCFC") + dragBadge.layer.borderColor = nativeListColor(theme, "rowBackground", "#FFFFFF").cgColor + } + override func bindSelectionContent( + _ item: NativeListItem, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + members = Self.memberItems(item) + for member in members { + memberViews[member.key]?.cell.updateSelection( + item: member, selected: member.data.bool("selected"), checkboxState: checkboxState) + } + if let parent = members.first { + compactCell?.updateSelection( + item: parent, selected: parent.data.bool("selected"), checkboxState: checkboxState) + } + } + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) + -> Bool + { + var view = touch.view + while let current = view, current !== root { + if current is UIControl { return false } + view = current.superview + } + return true + } + @objc private func memberPressed(_ gesture: UITapGestureRecognizer) { + let point = gesture.location(in: root) + for member in members { + guard let cell = memberViews[member.key]?.cell, cell.frame.contains(point) else { continue } + if !member.data.bool("disabled") && !member.data.bool("pressDisabled") { + onAction?(member, "press", nil, cell.rowActionOrigin()) + } + return + } + } + override func canStartWalletGroupReorder(at point: CGPoint) -> Bool { + let point = root.convert(point, from: self) + for member in members where memberViews[member.key]?.cell.frame.contains(point) == true { + return (member.data["draggable"] as? Bool) != false && !member.data.bool("disabled") + } + return true + } + override func applyAppearance() { + super.applyAppearance() + if compact { + contentView.backgroundColor = .clear + contentView.layer.borderWidth = 0 + } + } + override func setPressed(_ pressed: Bool) { + if compact { compactCell?.setPressed(pressed) } else { super.setPressed(pressed) } + } + override func setWalletGroupReorderCompact(_ compact: Bool) { + self.compact = compact + root.isHidden = compact + root.alpha = compact ? 0 : 1 + compactContainer.isHidden = !compact + compactContainer.alpha = compact ? 1 : 0 + applyAppearance() + } + override func prepareWalletGroupReorderExpansion() { + compact = false + root.isHidden = false + root.alpha = 0 + compactContainer.isHidden = false + compactContainer.alpha = 1 + applyAppearance() + } + override func animateWalletGroupReorderExpansion() { + root.alpha = 1 + compactContainer.alpha = 0 + } + override func finishWalletGroupReorderExpansion() { setWalletGroupReorderCompact(false) } + override func recycleContent() { + for view in memberViews.values { + view.cell.prepareForReuse() + view.cell.removeFromSuperview() + } + memberViews.removeAll() + members.removeAll() + compactCell?.prepareForReuse() + compactCell?.removeFromSuperview() + compactCell = nil + compact = false + root.isHidden = false + root.alpha = 1 + compactContainer.isHidden = true + compactContainer.alpha = 1 + dragBadge.text = nil + } +} diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index 8f59ea54e..b6fe8c34d 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -1706,16 +1706,6 @@ final class NativeListView: UIView { if item.type == "walletGroup", usingCompactReorderHeight, item.key == interactiveReorderCompactKey { return 68 } if let height = item.styledHeight { return height } if item.data["height"] != nil { return CGFloat(item.data.double("height")) } - if item.type == "walletGroup" { - if usingCompactReorderHeight, item.key == interactiveReorderCompactKey { return 68 } - let childCount = item.data.dictionaries("children").count - // OneKey patch: wallet badges contribute their own member heights. - // return CGFloat((childCount + 1) * 68 + childCount * 12) - let members = [item.data.dictionary("parent")].compactMap { $0 } + item.data.dictionaries("children") - return members.reduce(CGFloat(childCount * 12 + (members.first?["height"] != nil ? 2 : 0))) { total, data in - total + CGFloat(data.dictionary("style")?.dictionary("container")?.double("height", default: data.double("height", default: data.dictionaries("badges").isEmpty ? 68 : 92)) ?? data.double("height", default: data.dictionaries("badges").isEmpty ? 68 : 92)) - } - } let availableWidth = max(0, collectionView.bounds.width - flowLayout.sectionInset.left - flowLayout.sectionInset.right) let columnWidth = config?.layout == "grid" ? floor((availableWidth - CGFloat((config?.gridColumns ?? 2) - 1) * (config?.itemSpacing ?? 0)) / CGFloat(config?.gridColumns ?? 2)) From 94351a67daf6b24681579ee829e76f28683f15c9 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 22:35:38 +0800 Subject: [PATCH 55/71] refactor(native-list): compose WalletGroup from keyed Android Identity hosts --- .../nativelist/NativeListIdentityRenderer.kt | 13 + .../com/onekey/nativelist/NativeListModels.kt | 2 +- .../nativelist/NativeListRendererRegistry.kt | 2 + .../nativelist/NativeListRendererRowView.kt | 20 +- .../NativeListWalletGroupRenderer.kt | 337 ++++++++++++++++++ 5 files changed, 367 insertions(+), 7 deletions(-) create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListWalletGroupRenderer.kt diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt index c8cfa594a..853d46a07 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt @@ -517,6 +517,19 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : LayoutParams(LayoutParams.WRAP_CONTENT, dp(badgeHeight)).apply { topMargin = dp(4) }, ) } + val accessories = item.json.optJSONArray("trailing") ?: JSONArray() + if (accessories.length() > 0) { + addView(trailingColumn, wrap().apply { topMargin = dp(4) }) + trailingColumn.bind( + item, + accessories, + theme, + item.json.optJSONObject("style") ?: JSONObject(), + sourceScale, + checkboxState, + horizontal = true, + ) + } return } if (item.json.optString("presentation") == "networkSelector") { diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index 33162ec11..03bcfde3d 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -71,7 +71,7 @@ private fun updateLengthPrefixed(digest: MessageDigest, value: String) { // Message and Rail own dedicated renderers. Remaining templates still share // a compatible legacy tree; keys and style values never partition the pool. -internal enum class NativeListRendererKey { LEGACY, MESSAGE, RAIL, MEDIA_TILE, ACTION, SYSTEM, ACTIVITY, DATA_ROW, METRIC_CARD, MARKET, IDENTITY, SECTION_HEADER } +internal enum class NativeListRendererKey { LEGACY, MESSAGE, RAIL, MEDIA_TILE, ACTION, SYSTEM, ACTIVITY, DATA_ROW, METRIC_CARD, MARKET, IDENTITY, SECTION_HEADER, WALLET_GROUP } internal data class NativeListItem( val key: String, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt index 184d1835a..a05356a19 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt @@ -6,6 +6,7 @@ internal object NativeListRendererRegistry { private val factories: Map NativeListRowHost> = mapOf( NativeListRendererKey.SECTION_HEADER to ::NativeListSectionHeaderRowView, + NativeListRendererKey.WALLET_GROUP to ::NativeListWalletGroupRowView, NativeListRendererKey.IDENTITY to ::NativeListIdentityRowView, NativeListRendererKey.MARKET to ::NativeListMarketRowView, NativeListRendererKey.LEGACY to ::NativeListRowView, @@ -22,6 +23,7 @@ internal object NativeListRendererRegistry { private val keys = mapOf( "sectionHeader" to NativeListRendererKey.SECTION_HEADER, + "walletGroup" to NativeListRendererKey.WALLET_GROUP, "identity" to NativeListRendererKey.IDENTITY, "market" to NativeListRendererKey.MARKET, "message" to NativeListRendererKey.MESSAGE, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt index ffb5ec4ff..ed633f375 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt @@ -88,6 +88,12 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th protected open fun horizontalWidth(item: NativeListItem) = dp(280) + protected open val defaultBorderWidth = 0 + + protected open fun defaultBorderColor(theme: JSONObject?) = Color.TRANSPARENT + + protected open fun resolvedMeasureHeight(explicit: Int?): Int? = explicit + protected open val defaultCornerRadius = 0 protected open val defaultSeparatorInset = 12 protected open val pressChangesBackground = true @@ -264,7 +270,7 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th ) } - private fun appearance() { + protected open fun appearance() { val item = current ?: return val pressed = touchPressed || reorderActive pressContent(pressed) @@ -310,13 +316,14 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th cornerRadii = radii } foreground = - if (container.has("borderWidth")) + if (container.has("borderWidth") || defaultBorderWidth > 0) GradientDrawable().apply { setColor(Color.TRANSPARENT) cornerRadii = radii setStroke( - stylePx(container.optDouble("borderWidth")), - color(container.optString("borderColor", "#00000000")), + if (container.has("borderWidth")) stylePx(container.optDouble("borderWidth")) + else dp(defaultBorderWidth), + color(container.optString("borderColor"), defaultBorderColor(theme)), ) } else null @@ -344,8 +351,9 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th // Android's native measurement consumes the exact resolved text/image views. super.onMeasure( widthMeasureSpec, - explicitHeight?.let { MeasureSpec.makeMeasureSpec(it, MeasureSpec.EXACTLY) } - ?: heightMeasureSpec, + resolvedMeasureHeight(explicitHeight)?.let { + MeasureSpec.makeMeasureSpec(it, MeasureSpec.EXACTLY) + } ?: heightMeasureSpec, ) } diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListWalletGroupRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListWalletGroupRenderer.kt new file mode 100644 index 000000000..38ae92257 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListWalletGroupRenderer.kt @@ -0,0 +1,337 @@ +package com.margelo.nitro.nativelist + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.TimeInterpolator +import android.animation.ValueAnimator +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.view.Gravity +import android.view.View +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import com.facebook.react.uimanager.ThemedReactContext +import org.json.JSONObject + +/** Composite only; each keyed member owns an independent Identity binding. */ +internal class NativeListWalletGroupRowView(context: ThemedReactContext) : + NativeListRendererRowView(context) { + private val membersColumn = LinearLayout(context).apply { orientation = VERTICAL } + private val memberViews = linkedMapOf() + private var members = emptyList() + private val compactContainer = + FrameLayout(context).apply { + visibility = GONE + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } + private var compactParent: NativeListIdentityRowView? = null + private val dragBadge = TextView(context) + private var compact = false + private var expandedHeight = 0 + private var animatedHeight: Int? = null + private var expandAnimator: ValueAnimator? = null + private var currentTheme: JSONObject? = null + private var currentLayout = "linear" + override val defaultCornerRadius = 12 + override val defaultBorderWidth = 1 + override val pressChangesBackground = false + override val showsSelection = false + + override fun usesSourceScale(item: NativeListItem, provided: Boolean) = + item.usesSelectorSourceScale || provided + + override fun defaultBorderColor(theme: JSONObject?) = + parseNativeListColor(theme?.optString("separator", "#0000001F") ?: "#0000001F") + + override fun unselectedBackground( + item: NativeListItem, + theme: JSONObject?, + layout: String, + itemIndex: Int?, + ) = parseNativeListColor(theme?.optString("subduedBackground", "#F9F9F9") ?: "#F9F9F9") + + override fun resolvedMeasureHeight(explicit: Int?): Int? = + if (compact) dp(68) else animatedHeight ?: explicit ?: expandedHeight + + override fun accessibilityText(item: NativeListItem) = + item.json.optString( + "accessibilityLabel", + item.json.optJSONObject("parent")?.optString("title") ?: "", + ) + + private fun memberItems(item: NativeListItem): List { + val children = item.json.getJSONArray("children") + return listOf(NativeListItem.parse(item.json.getJSONObject("parent"))) + + (0 until children.length()).map { NativeListItem.parse(children.getJSONObject(it)) } + } + + private fun memberHeight(item: NativeListItem) = + item.styledHeight?.let(::stylePx) + ?: dp( + item.json.optInt( + "height", + if ((item.json.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68, + ) + ) + + init { + orientation = VERTICAL + addView(membersColumn, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) + addView(compactContainer, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) + dragBadge.gravity = Gravity.CENTER + dragBadge.typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + compactContainer.addView(dragBadge) + } + + override fun bindContent( + item: NativeListItem, + theme: JSONObject?, + layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + currentTheme = theme + currentLayout = layout + members = memberItems(item) + val keys = members.map { it.key }.toSet() + memberViews.keys + .toList() + .filter { it !in keys } + .forEach { key -> + memberViews.remove(key)?.let { + it.dispose() + membersColumn.removeView(it) + } + } + val style = item.json.optJSONObject("style") + val inset = if (members.first().json.has("height")) dp(1) else 0 + val horizontal = + style + ?.takeIf { it.has("horizontalPadding") } + ?.let { stylePx(it.optDouble("horizontalPadding")) } ?: inset + val vertical = + style?.takeIf { it.has("verticalPadding") }?.let { stylePx(it.optDouble("verticalPadding")) } + ?: inset + membersColumn.setPadding(horizontal, vertical, horizontal, vertical) + setPadding(0, 0, 0, 0) + gravity = + Gravity.FILL_HORIZONTAL or + when (style?.optJSONObject("container")?.optString("contentVerticalAlignment")) { + "bottom" -> Gravity.BOTTOM + "center" -> Gravity.CENTER_VERTICAL + else -> Gravity.TOP + } + expandedHeight = + item.styledHeight?.let(::stylePx) + ?: modelHeight(item, layout) + ?: (members.sumOf(::memberHeight) + dp((members.size - 1) * 12) + inset * 2) + members.forEachIndexed { index, member -> + val view = + memberViews.getOrPut(member.key) { + NativeListIdentityRowView(reactContext).also { view -> + view.onRowPress = { source, origin -> + if (isEnabled) onRowPress?.invoke(source, origin) + } + view.onAction = { source, key, target, origin -> + if (isEnabled) onAction?.invoke(source, key, target, origin) + } + view.onBindingInvalidated = { source, epoch -> + onBindingInvalidated?.invoke(source, epoch) + } + } + } + view.listStyle = listStyle + view.bind( + member, + theme, + layout, + "vertical", + null, + member.json.optBoolean("selected"), + checkboxState, + sourceScale, + ) + if (membersColumn.indexOfChild(view) != index) { + membersColumn.removeView(view) + membersColumn.addView(view, index) + } + view.layoutParams = + LayoutParams(LayoutParams.MATCH_PARENT, memberHeight(member)).apply { + if (index > 0) topMargin = dp(12) + } + } + if (compactParent == null) { + compactParent = + NativeListIdentityRowView(reactContext).also { + compactContainer.addView( + it, + 0, + FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT), + ) + } + } + compactParent!!.listStyle = listStyle + val parent = members.first() + compactParent!!.bind( + parent, + theme, + layout, + "vertical", + null, + parent.json.optBoolean("selected"), + checkboxState, + sourceScale, + ) + compactContainer.layoutParams.height = dp(68) + val count = members.drop(1).count { it.json.optBoolean("draggable", true) } + dragBadge.text = "+$count" + dragBadge.visibility = if (count == 0) GONE else VISIBLE + dragBadge.textSize = if (sourceScale) 12f else NativeListScale.font(resources, 12f) + dragBadge.setPadding(dp(6), 0, dp(6), 0) + dragBadge.minWidth = dp(24) + dragBadge.setTextColor( + parseNativeListColor(theme?.optString("inverseText", "#FCFCFC") ?: "#FCFCFC") + ) + dragBadge.background = + GradientDrawable().apply { + setColor( + parseNativeListColor(theme?.optString("inverseBackground", "#000000DF") ?: "#000000DF") + ) + setStroke( + dp(1), + parseNativeListColor(theme?.optString("rowBackground", "#FFFFFF") ?: "#FFFFFF"), + ) + cornerRadius = dp(12).toFloat() + } + dragBadge.layoutParams = + FrameLayout.LayoutParams(LayoutParams.WRAP_CONTENT, dp(24), Gravity.END or Gravity.BOTTOM) + .apply { + rightMargin = dp(4) + bottomMargin = dp(4) + } + } + + override fun bindSelectionContent( + item: NativeListItem, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + members = memberItems(item) + members.forEach { member -> + memberViews[member.key]?.bindSelection( + member, + currentTheme, + currentLayout, + null, + member.json.optBoolean("selected"), + checkboxState, + ) + } + members.firstOrNull()?.let { + compactParent?.bindSelection( + it, + currentTheme, + currentLayout, + null, + it.json.optBoolean("selected"), + checkboxState, + ) + } + } + + override fun canStartWalletGroupReorder(localY: Float): Boolean { + val y = localY - membersColumn.top + members.forEach { member -> + val view = memberViews[member.key] ?: return@forEach + if (y >= view.top && y < view.bottom) + return member.json.optBoolean("draggable", true) && !member.json.optBoolean("disabled") + } + return true + } + + override fun appearance() { + super.appearance() + if (compact) { + background = null + foreground = null + } + } + + private fun cancelExpansion() { + expandAnimator?.removeAllListeners() + expandAnimator?.cancel() + expandAnimator = null + animatedHeight = null + } + + override fun setReorderActive(active: Boolean) { + if (compact == active && expandAnimator == null) return + cancelExpansion() + compact = active + membersColumn.visibility = if (active) GONE else VISIBLE + membersColumn.alpha = 1f + compactContainer.visibility = if (active) VISIBLE else GONE + compactContainer.alpha = 1f + compactParent?.setReorderActive(active) + appearance() + requestLayout() + } + + override fun finishWalletGroupReorder( + durationMs: Long, + interpolator: TimeInterpolator, + completion: (() -> Unit)?, + ) { + cancelExpansion() + val startHeight = height + compact = false + compactParent?.setReorderActive(false) + membersColumn.visibility = VISIBLE + membersColumn.alpha = 0f + appearance() + expandAnimator = + ValueAnimator.ofInt(startHeight, expandedHeight).apply { + duration = durationMs + this.interpolator = interpolator + addUpdateListener { + animatedHeight = it.animatedValue as Int + membersColumn.alpha = it.animatedFraction + compactContainer.alpha = 1f - it.animatedFraction + requestLayout() + } + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + expandAnimator = null + animatedHeight = null + membersColumn.alpha = 1f + compactContainer.visibility = GONE + compactContainer.alpha = 1f + requestLayout() + completion?.invoke() + } + } + ) + start() + } + } + + override fun recycleContent() { + cancelExpansion() + memberViews.values.forEach { it.dispose() } + memberViews.clear() + membersColumn.removeAllViews() + membersColumn.alpha = 1f + membersColumn.visibility = VISIBLE + members = emptyList() + compactParent?.dispose() + compactParent?.let(compactContainer::removeView) + compactParent = null + compactContainer.visibility = GONE + compactContainer.alpha = 1f + compact = false + expandedHeight = 0 + currentTheme = null + } + + override fun disposeContent() {} +} From 3bead8a3dabcc2faf8931889cb4a8403301878f0 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 22:35:38 +0800 Subject: [PATCH 56/71] fix(native-list): gate disabled WalletGroup member interactions --- .../src/__tests__/NativeList.web.test.ts | 57 +++++++++++++++++++ .../src/web/NativeListWebEngine.ts | 5 +- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index ebc11f86c..1066d731f 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -2680,4 +2680,61 @@ describe('complex renderer lifecycle', () => { engine.destroy(); } }); + it('gates disabled group member controls after rebinding', () => { + const { document } = new JSDOM('').window; + const host = document.createElement('div'); + document.body.appendChild(host); + const member: IdentityRow = { + type: 'identity', + key: 'child', + presentation: 'walletSidebar', + title: 'Child', + leading: { kind: 'icon', name: 'StarOutline' }, + trailing: [ + { kind: 'menu', actionKey: 'menu' }, + { kind: 'checkbox', target: { scope: 'row' }, state: 'unchecked' }, + ], + }; + const row: Extract = { + type: 'walletGroup', + key: 'parent', + parent: { ...member, key: 'parent' }, + children: [member], + }; + const onRowAction = jest.fn(); + const onSelectionDelta = jest.fn(); + const engine = new NativeListWebEngine( + host, + { ...snap(row), selection: { mode: 'multiple', selectedKeys: [] } }, + { onRowAction, onSelectionDelta }, + false + ); + const previousElement = global.Element; + global.Element = document.defaultView!.Element; + const buttons = () => + host + .querySelector('[data-native-list-group-member-key="child"]')! + .querySelectorAll('button'); + try { + buttons()[0]!.click(); + expect(onRowAction.mock.calls[0]![0]).toMatchObject({ + rowKey: 'child', + actionKey: 'menu', + }); + engine.applySnapshot( + snap({ ...row, children: [{ ...member, disabled: true }] }) + ); + buttons()[0]!.click(); + expect(onRowAction).toHaveBeenCalledTimes(1); + expect( + canStartWebWalletGroupReorder( + { ...row, children: [{ ...member, disabled: true }] }, + 'child' + ) + ).toBe(false); + } finally { + engine.destroy(); + global.Element = previousElement; + } + }); }); diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index 2cefd6246..65ebc70e2 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -643,7 +643,7 @@ export function canStartWebWalletGroupReorder( const member = [row.parent, ...row.children].find( (candidate) => candidate.key === memberKey ); - return member?.draggable !== false; + return member?.draggable !== false && !member?.disabled; } function itemStart(item: WebLayoutItem, horizontal: boolean): number { @@ -3730,7 +3730,7 @@ export class NativeListWebEngine { action.dataset.nativeListHoverAction === 'true' ? action.dataset.nativeListAction : action.dataset.nativeListHoverAction; - if (sourceRow && !sourceRow.disabled && actionKey) + if (sourceRow && !row?.disabled && !sourceRow.disabled && actionKey) this.emitRowAction(sourceRow, actionKey, action, rowElement ?? undefined); }; @@ -3774,6 +3774,7 @@ export class NativeListWebEngine { (member) => member.key === memberKey ) ?? row : row; + if (sourceRow.disabled) return; const action = target.closest('[data-native-list-action]'); if (action) { const scope = action.dataset.selectionScope; From 63e8131f1bd253b15932ed902b0ed6890676a117 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 22:38:18 +0800 Subject: [PATCH 57/71] docs(native-list): complete WalletGroup migration and runtime acceptance --- .../react-native-native-list/docs/DESIGN.md | 57 +++++++++++++++++- .../docs/ROW_TEMPLATES.md | 4 ++ .../react-native-native-list/docs/SPEC.md | 58 ++++++++++++------- 3 files changed, 94 insertions(+), 25 deletions(-) diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index 07c92a81f..8fb1c6a3b 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -108,9 +108,9 @@ Message or Rail. No public plugin API or Nitro schema is added. Remaining templa and partial updates are retired only with their own migration. See [SPEC.md](SPEC.md#conformance-and-six-stage-migration) for all six stages. -Structural reuse now has eleven dedicated families on all platforms; see the -current registry inventory in SPEC.md. The `legacy` family serves WalletGroup -and retains its shared native member tree until stage 5. +Structural reuse now has twelve dedicated families on all platforms; see the +current registry inventory in SPEC.md. WalletGroup composes keyed Identity +hosts. The old native classes remain only as stage 6 cleanup. Neither `row.key`, content, style, height nor placement changes the family. Web selects a compatible wrapper pool even when the row at a mounted index changes family. iOS registers separate reuse identifiers and reloads retained @@ -487,3 +487,54 @@ with zero focused lint errors (two existing shadowing warnings). iOS Debug build/link passes; Android Debug APK and seven unit tests across three suites pass. Both native runtimes passed the interaction sequence after the container fixes. Stage 5 WalletGroup/member dragging and stage 6 legacy/key cleanup remain. + +## Stage 5: WalletGroup (2026-09-23) + +All twelve templates now enter through the closed registry. The new native +WalletGroup hosts and Web renderer reconcile member views by member key, bind +each through the migrated Identity renderer, and recycle removed members +immediately. Retention is bounded by the current member count; native holds one +additional compact parent. There is no spare-member high-water pool. Recycle +releases member images, action bindings and the compact parent. Group appearance +and padding stay on the composite; member text/image/container styles stay local. + +iOS and Web expanded measurement moved from the list container into WalletGroup. +Android uses its registered composite host to resolve expanded, compact and +animated heights. Explicit group/member style heights retain precedence, and +member defaults/badges keep the previous measurement. The list continues to own +reorder activation, destination selection, scroll and final order events. Native +compact rendering uses a separate Identity parent and `+N` badge; Web uses the +existing leased preview. Only children whose draggable is not false count toward +`+N`; non-draggable and disabled members cannot initiate a drag. Drop restoration +uses the current configured height/appearance rather than default chrome. + +Member event origins retain the nested Identity host and its binding epoch. +Removing/rebinding a member invalidates its previous action anchor. The iOS +member tap recognizer ignores UIControls so menu actions do not also emit a row +press. Native WalletSidebar now binds optional trailing controls, matching Web; +callers allocate sufficient height for these controls. Member selection visuals +continue to follow each Identity's selected field. This migration does not add +nested members to the list's existing top-level selection domain. Web now gates +disabled member actions as well as group-level disabled state. + +Acceptance ran on the task's independent external-drive iOS 26.5 simulator, +Android API 36 emulator and headed Chrome. Member press/menu and updated action +keys/anchors, selected/disabled state, member removal/swap, style set/reset, +same-key Identity/composite replacement, empty/refill, recycle and the shared +footer passed. Child-initiated drags reorder the whole group; excluded members +do not reorder. Captures show the 68-point preview and +1 badge (one draggable +child and one excluded add-wallet row). The fixture restores 274-point default +and 340-point styled heights. iOS no-op drag, Android touch cancel and Web Escape +cancel also restore the expanded appearance. A fast iOS QA scroll initially +moved the group outside the viewport; returning to Top verified the full settled +340-point group, without a product-code change. + +Validation: TypeScript and 163 tests in five suites; focused lint zero errors +with two pre-existing warnings; iOS Debug build/link; Android Debug APK plus +seven tests in three suites. Web tests additionally assert keyed member/image +retention, removed-member disposal, style clearing and disabled menu dispatch. +The validation runtime's `stage5` directory contains fixture, scripts, screenshots, +accessibility trees and iOS drag recordings. This is functional/visual simulator +acceptance, not a performance benchmark. Stage 6 alone remains in this six-stage +architecture plan: remove dead native composite/global maps and retire the +inventoried compatibility policies after checking their callers. diff --git a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md index fa2e0a116..577475c3f 100644 --- a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md +++ b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md @@ -79,6 +79,10 @@ separately from source implementation in STYLE_SPEC §9. - **Style surface:** group `style` exposes the shared container appearance and horizontal/vertical padding. Text styles belong to `parent.style` or the individual `children[i].style`; there is no inherited group `title`/`value` text style. +- **Member lifecycle:** each member uses an independent Identity renderer, reused + by member key. Removed members release their images/actions immediately. + Press and accessory events use the member key; group dragging remains atomic. + Optional trailing controls require sufficient caller-supplied member height. - **Layout boundary:** preserve parent-first member order and wallet member structure. Local padding cannot change ownership, member count or drag identity. Group `lineGap` does not override the text spacing inside member rows. diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index df8c97c53..205175a6c 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -44,10 +44,9 @@ UI thread; [DESIGN.md](DESIGN.md) describes update and event ownership. `row.key` identifies data; renderer reuse identity describes compatible view structure. Content, styles, height and placement must not enter reuse identity. -Eleven templates have their own renderer family: Message, Rail, MediaTile, Action, -System, Activity, DataRow, MetricCard, Market, Identity and SectionHeader. -The `legacy` family serves WalletGroup until stage 5. Same-key family changes must replace the incompatible -host. OneKeyImage owns image caches; NativeList owns row/slot binding identity. +All twelve templates have their own renderer family: Message, Rail, MediaTile, +Action, System, Activity, DataRow, MetricCard, Market, Identity, SectionHeader and +WalletGroup. Same-key family changes must replace the incompatible host. OneKeyImage owns image caches; NativeList owns row/slot binding identity. ## Platform contract @@ -68,8 +67,8 @@ migration adds no new input limits, retry policy or automatic layout correction. ## Performance and resources Snapshots and patches cross the bridge in batches. Visible rows use platform -recycling/windowing; shared primitives own image cancellation. Message, all seven stage 3 templates, and Market/Identity/SectionHeader use dedicated -lightweight native hosts and persistent Web bodies. +recycling/windowing; shared primitives own image cancellation. All twelve templates use dedicated lightweight native hosts and persistent Web +bodies. WalletGroup composes the migrated Identity member hosts. None allocates the legacy native tree. This structural change is not a measured scrolling-performance result; performance claims still need a separate workload and measurements. @@ -82,15 +81,15 @@ separate workload and measurements. | 2. Message pilot | Complete: registered lightweight native hosts, persistent Web body, resolved styling/measurement and lifecycle acceptance | Lightweight native hosts, resolved styling/measurement, update classification and lifecycle acceptance | | 3. Simple templates | Complete (7/7): Rail, MediaTile, Action, System, Activity, DataRow and MetricCard migrated on all three platforms | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | | 4. Complex templates | Complete (3/3): Market, Identity and SectionHeader migrated on all three platforms | Migrate Market, Identity and SectionHeader while preserving quote, selection and header behavior | -| 5. WalletGroup | In progress | Independent member renderers; verify member events/styles, compact dragging and height restoration | +| 5. WalletGroup | Complete: registered composites and independent keyed Identity members on all three platforms | Independent member renderers; verify member events/styles, compact dragging and height restoration | | 6. Cleanup | Not started | Remove migrated key inference and global style maps; container no longer manipulates template internals | -The closed internal registry selects eleven renderers by template type. WalletGroup -remains in the legacy family. Message owns -its text column, optional leading visual and thumbnail; its native host does not -allocate Market, WalletGroup or table views. Web preserves the Message body, -text nodes and unchanged image elements when rebinding. WalletGroup is the -only remaining top-level legacy template. +The closed internal registry selects all twelve renderers by template type. +No supported template dispatches to the legacy native tree. WalletGroup owns +composition and compact appearance; its members use independent Identity +renderers. Legacy native classes/global maps remain unreachable for supported +rows and will be removed in stage 6. Web preserves compatible bodies and +unchanged member image elements across rebinding. Message resolves text/spacing/image inputs from data/theme/styles, classifies updates as unchanged, content/style, assets or replacement, and binds/measures @@ -159,9 +158,8 @@ defined in STYLE_SPEC.md. Image slots retain unchanged requests across rebinding and reset when a source disappears or its slot identity changes. Stage 3 completion is not completion of the six-stage architecture plan. -Stage 4 is recorded below. Remaining work after it is stage 5 -(WalletGroup member rendering and drag acceptance), then stage 6 (retire the -remaining compatibility inference/style maps and container access to internals). +Stages 4 and 5 are recorded below. Stage 6 remains: retire the remaining +compatibility inference/style maps and container access to internals. No migrated simple template dispatches through a legacy binder. ### Stage 4 acceptance scope @@ -181,18 +179,18 @@ measurement. Native hosts share bounded leading/image/accessory primitives. Web retains the compatible body and unchanged image elements; loaded image state is not reset when geometry is restored. Quote and summary updates keep the latest row model for subsequent action/selection dispatch. No new public API or Nitro -schema is introduced. The remaining native composite tree is retained only for -WalletGroup and its nested members until stages 5–6. +schema is introduced. Stage 5 below also replaces the native WalletGroup composite tree. The now +unreachable legacy classes remain for stage 6 cleanup. Stage 4 validation: package typecheck and all 161 tests in five suites pass; focused lint has zero errors and two existing shadowing warnings. iOS Debug build/link and Android Debug APK plus seven unit tests pass. Both dedicated native simulators and headed Chrome exercised the acceptance cases above. The section fixture additionally verified imperative scroll pinning and active -index updates after the container fixes. Remaining stages are WalletGroup (5) -and compatibility/global-map cleanup (6); this is not a performance signoff. +index updates after the container fixes. Stage 5 acceptance follows below; stage 6 compatibility/global-map cleanup +remains. This is not a performance signoff. -### Stage 5 acceptance contract (implementation in progress) +### Stage 5 ownership and acceptance WalletGroup owns only member composition, measurement and compact presentation. Each member uses the registered Identity renderer and retains its own key, style, @@ -205,7 +203,23 @@ Acceptance covers member press/accessory routing, disabled and pressDisabled members, selection-only changes, style set/change/clear, member reorder/removal, family replacement and reuse. Native and Web drag previews keep the existing 68-point compact allocation; `+N` counts children whose draggable is not false. -A non-draggable member cannot initiate group drag. Drop/cancel restore the +A non-draggable or disabled member cannot initiate group drag. Drop/cancel restore the resolved group height and all configured member/container styles. Reorder orchestration stays in the list container. Existing platform defaults stay unchanged. Verify on the dedicated external-drive simulators and headed Web. + +Stage 5 runtime verification: iOS and Android member press/menu actions carry +the member key and current action origin, including after content updates. +Both platforms verified selected/disabled members, style set/clear, member +removal/reordering, same-key family replacement, empty/repopulation, scrolling +and the shared fixed footer. Real member/parent drags move the group atomically; +excluded members do not start dragging. Default 274-point and styled 340-point +fixture heights restore after drop. iOS also verified a no-op long press; +Android verified touch cancellation; Web verified Escape cancellation. + +All 163 package tests and typecheck pass; focused lint has zero errors (two +existing warnings). iOS Debug build/link, Android Debug APK and seven Android +unit tests pass. Headed Chrome includes a 390px viewport and in-drag captures. +Native simulator captures, action payloads and iOS recordings are stored with +the stage 5 harness in the external validation runtime. These are standalone +NativeList checks, not a consumer-app release or scrolling-performance signoff. From 86b925c9f8eaca94f08b666797d78a69f5f04d58 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 23:09:05 +0800 Subject: [PATCH 58/71] refactor(native-list): remove obsolete native row hosts --- .../onekey/nativelist/NativeListAdapter.kt | 3 + .../com/onekey/nativelist/NativeListModels.kt | 30 +- .../onekey/nativelist/NativeListPrimitives.kt | 475 +++ .../nativelist/NativeListRendererRegistry.kt | 3 +- .../onekey/nativelist/NativeListRowHost.kt | 11 + .../onekey/nativelist/NativeListRowView.kt | 3599 ----------------- .../com/onekey/nativelist/NativeListView.kt | 4 +- .../nativelist/NativeListStyleSlotTest.kt | 61 - .../react-native-native-list/docs/SPEC.md | 2 +- .../ios/NativeListAccessoryStack.swift | 93 + .../ios/NativeListCell.swift | 3444 ---------------- .../ios/NativeListModels.swift | 13 +- .../ios/NativeListRendererRegistry.swift | 7 +- .../ios/NativeListRowHost.swift | 30 +- .../ios/NativeListSectionHeaderRenderer.swift | 89 + .../ios/RNCNativeListView.swift | 4 +- 16 files changed, 717 insertions(+), 7151 deletions(-) create mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListPrimitives.kt delete mode 100644 native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt delete mode 100644 native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt delete mode 100644 native-views/react-native-native-list/ios/NativeListCell.swift diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt index 9d5d0af79..39186d548 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt @@ -13,6 +13,9 @@ import java.util.WeakHashMap import kotlin.math.ceil import kotlin.math.roundToInt +internal class NativeListViewHolder(val rowView: NativeListRowHost) : + androidx.recyclerview.widget.RecyclerView.ViewHolder(rowView) + internal class NativeListAdapter( private val context: ThemedReactContext, ) : RecyclerView.Adapter() { diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index 03bcfde3d..dd9ef4f50 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -6,6 +6,8 @@ import java.nio.ByteBuffer import java.security.MessageDigest import java.util.Locale +internal data class NativeSelectionTarget(val scope: String, val key: String?) + internal fun isNativeListRowPressEnabled( type: String, variant: String, @@ -25,28 +27,6 @@ internal fun isNativeListWholeRowInteractive( pressDisabled = pressDisabled, ) -/** - * Maps a style key - which names a model field - to the view slot that renders it. - * Legacy Identity and SectionHeader share a small text view pool. - * Migrated templates resolve their own slots. See docs/STYLE_SPEC.md section 4. - * - * Returns null when the template does not render that field, so an unmapped key - * is ignored rather than reaching an unrelated view. - */ -internal fun nativeListStyleSlot(type: String, variant: String, field: String): String? = - when (type) { - "identity" -> when (field) { - "title", "subtitle", "tertiary", "badge", "value", "valueSecondary" -> field - else -> null - } - "sectionHeader" -> when (field) { - "title", "subtitle", "value" -> field - else -> null - } - // Market owns its own richer style path. - else -> null - } - // The image fallback-state cache is process-wide, so it is keyed by a digest of the request // identity instead of the raw headers, which can carry credentials such as Authorization. internal fun nativeListSourceFallbackStateKey(uri: String, headers: Map): String? { @@ -69,9 +49,8 @@ private fun updateLengthPrefixed(digest: MessageDigest, value: String) { digest.update(bytes) } -// Message and Rail own dedicated renderers. Remaining templates still share -// a compatible legacy tree; keys and style values never partition the pool. -internal enum class NativeListRendererKey { LEGACY, MESSAGE, RAIL, MEDIA_TILE, ACTION, SYSTEM, ACTIVITY, DATA_ROW, METRIC_CARD, MARKET, IDENTITY, SECTION_HEADER, WALLET_GROUP } +// Template type alone partitions reuse; data keys and styles never select a renderer. +internal enum class NativeListRendererKey { MESSAGE, RAIL, MEDIA_TILE, ACTION, SYSTEM, ACTIVITY, DATA_ROW, METRIC_CARD, MARKET, IDENTITY, SECTION_HEADER, WALLET_GROUP } internal data class NativeListItem( val key: String, @@ -138,6 +117,7 @@ internal data class NativeListItem( fun parse(json: JSONObject): NativeListItem { val key = json.getString("key") val type = json.getString("type") + NativeListRendererRegistry.key(type) // Reject unsupported templates at the model boundary. return NativeListItem( key = key, type = type, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListPrimitives.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListPrimitives.kt new file mode 100644 index 000000000..2eb06c9e4 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListPrimitives.kt @@ -0,0 +1,475 @@ +package com.margelo.nitro.nativelist + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Typeface +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.graphics.PathParser +import kotlin.math.roundToInt + +/** React Native color strings use CSS #RRGGBBAA ordering; Android expects #AARRGGBB. */ +internal fun parseNativeListColor(value: String): Int { + val normalized = value.trim() + val androidValue = + if ( + normalized.length == 9 && + normalized[0] == '#' && + normalized.drop(1).all { it.isDigit() || it.lowercaseChar() in 'a'..'f' } + ) { + "#${normalized.takeLast(2)}${normalized.substring(1, 7)}" + } else { + normalized + } + return Color.parseColor(androidValue) +} + +internal object NativeListScale { + private const val STANDARD_DP_WIDTH_THRESHOLD = 400 + + fun factor(resources: android.content.res.Resources): Float { + val widthDp = resources.configuration.screenWidthDp + return if (widthDp in 1 until STANDARD_DP_WIDTH_THRESHOLD) 0.9f else 1f + } + + fun dp(resources: android.content.res.Resources, value: Int): Int = + (value * factor(resources) * resources.displayMetrics.density).roundToInt() + + fun dp(resources: android.content.res.Resources, value: Float): Float = + (value * factor(resources) * resources.displayMetrics.density).roundToInt().toFloat() + + fun font(resources: android.content.res.Resources, value: Float): Float = + if (factor(resources) == 1f) value else (value * factor(resources)).roundToInt().toFloat() +} + +internal object NativeListFonts { + private val cache = mutableMapOf() + + private fun load(context: android.content.Context, weight: String, fallback: String): Typeface = + cache.getOrPut(weight) { + try { + Typeface.createFromAsset(context.assets, "fonts/Roobert-$weight.ttf") + } catch (_: RuntimeException) { + Typeface.create(fallback, Typeface.NORMAL) + } + } + + fun regular(context: android.content.Context) = load(context, "Regular", "sans-serif") + + fun medium(context: android.content.Context) = load(context, "Medium", "sans-serif-medium") + + fun semibold(context: android.content.Context) = load(context, "SemiBold", "sans-serif-medium") + + fun bold(context: android.content.Context) = load(context, "Bold", "sans-serif") +} + +internal class DottedUnderlineTextView(context: android.content.Context) : + NativeListTextView(context) { + var useSourceScale = false + + private fun scaledDp(value: Float) = + if (useSourceScale) value * resources.displayMetrics.density + else NativeListScale.dp(resources, value) + + var showsDottedUnderline = false + var dottedUnderlineColor = Color.TRANSPARENT + private val dottedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (!showsDottedUnderline || text.isEmpty()) return + dottedPaint.color = dottedUnderlineColor + val radius = scaledDp(0.75f) + val spacing = scaledDp(4f) + val lineWidth = paint.measureText(text.toString()).coerceAtMost(width.toFloat()) + val y = height - radius + var x = scaledDp(1f) + while (x <= lineWidth - radius) { + canvas.drawCircle(x, y, radius, dottedPaint) + x += spacing + } + } +} + +internal class PackedTitleLineLayout(context: android.content.Context) : LinearLayout(context) { + var packsChildrenAtStart = false + // OneKey patch: optional cap for the Market subtitle's localized name. + var leadingTextMaxWidth = Int.MAX_VALUE + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + if (!packsChildrenAtStart || childCount < 2) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + return + } + + val title = getChildAt(0) + val widthMode = MeasureSpec.getMode(widthMeasureSpec) + val widthSize = MeasureSpec.getSize(widthMeasureSpec) + var accessoryWidth = 0 + for (index in 1 until childCount) { + val child = getChildAt(index) + if (child.visibility == GONE) continue + measureChildWithMargins( + child, + widthMeasureSpec, + paddingLeft + paddingRight + accessoryWidth, + heightMeasureSpec, + paddingTop + paddingBottom, + ) + val margins = child.layoutParams as MarginLayoutParams + accessoryWidth += child.measuredWidth + margins.leftMargin + margins.rightMargin + } + val titleMargins = title.layoutParams as MarginLayoutParams + if (widthMode != MeasureSpec.UNSPECIFIED) { + (title as TextView).maxWidth = + (widthSize - + paddingLeft - + paddingRight - + accessoryWidth - + titleMargins.leftMargin - + titleMargins.rightMargin) + .coerceAtLeast(0) + .coerceAtMost(leadingTextMaxWidth) + } + (title.layoutParams as LayoutParams).apply { + width = LayoutParams.WRAP_CONTENT + weight = 0f + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + if (widthMode != MeasureSpec.UNSPECIFIED) { + setMeasuredDimension(widthSize, measuredHeight) + } + } +} + +// OneKey patch: fit subtitle segments at intrinsic width, shrinking text only when necessary. +internal class SelectorSubtitleLayout(context: android.content.Context) : LinearLayout(context) { + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val available = MeasureSpec.getSize(widthMeasureSpec) + val labels = mutableListOf>() + var fixedWidth = 0 + for (index in 0 until childCount) { + val child = getChildAt(index) + val params = child.layoutParams as LayoutParams + if (child is TextView) { + child.measure(MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), heightMeasureSpec) + labels.add(child to child.measuredWidth) + } else fixedWidth += params.width + params.leftMargin + params.rightMargin + } + val desired = labels.sumOf { it.second } + val textWidth = (available - fixedWidth).coerceAtLeast(0) + labels.forEach { (label, width) -> + (label.layoutParams as LayoutParams).width = + if (desired <= textWidth) width + else (width.toLong() * textWidth / desired.coerceAtLeast(1)).toInt() + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } +} + +internal class OneKeyIconView(context: android.content.Context) : View(context) { + var useSourceScale = false + var iconName: String = "" + set(value) { + field = value + invalidate() + } + + var tintColor: Int = Color.rgb(100, 100, 100) + set(value) { + field = value + invalidate() + } + + var glyphSizeDp: Int? = null + set(value) { + field = value + invalidate() + } + + private val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val pathData = iconPaths[iconName] ?: return + val drawSize = + minOf( + minOf(width, height).toFloat(), + glyphSizeDp?.let { + if (useSourceScale) (it * resources.displayMetrics.density).roundToInt().toFloat() + else NativeListScale.dp(resources, it).toFloat() + } ?: Float.MAX_VALUE, + ) + // OneKey patch: custom account-error artwork uses an 18-point viewBox. + // val scale = drawSize / 24f + val scale = drawSize / (selectorIconViewBoxes[iconName] ?: 24f) + fill.color = tintColor + canvas.save() + canvas.translate((width - drawSize) / 2f, (height - drawSize) / 2f) + canvas.scale(scale, scale) + val sourceFillTypes = actionIconFillTypes[iconName] + pathData.forEachIndexed { index, data -> + PathParser.createPathFromPathData(data)?.let { path -> + path.fillType = sourceFillTypes?.getOrNull(index) ?: Path.FillType.EVEN_ODD + // OneKey patch: provider illustration colors are part of their source asset. + fill.color = selectorIconColors[iconName]?.getOrNull(index) ?: tintColor + canvas.drawPath(path, fill) + } + } + canvas.restore() + } + + companion object { + // OneKey patch: preserve provider colors and non-24 viewBoxes. + private val selectorIconColors: Map> = + mapOf( + "GlobusOutline" to listOf(null), + "LockSolid" to listOf(null), + "GoogleIllus" to + listOf( + parseNativeListColor("#4285F4"), + parseNativeListColor("#34A853"), + parseNativeListColor("#FBBC05"), + parseNativeListColor("#EA4335"), + ), + "AppleBrand" to listOf(null), + "BotIllus" to + listOf( + parseNativeListColor("#8897A5"), + parseNativeListColor("#3FA9F5"), + parseNativeListColor("#8897A5"), + parseNativeListColor("#8897A5"), + parseNativeListColor("#10243E"), + parseNativeListColor("#10243E"), + parseNativeListColor("#10243E"), + ), + "AllNetworksSolid" to listOf(null, null), + "CrossedSmallSolid" to listOf(null), + "AccountErrorCustom" to listOf(Color.argb(0x72, 0, 0, 0), Color.argb(0x72, 0, 0, 0)), + "Circle" to listOf(null), + ) + private val selectorIconViewBoxes = + mapOf( + "GlobusOutline" to 24f, + "LockSolid" to 24f, + "GoogleIllus" to 24f, + "AppleBrand" to 16f, + "BotIllus" to 24f, + "AllNetworksSolid" to 24f, + "CrossedSmallSolid" to 24f, + "AccountErrorCustom" to 18f, + "Circle" to 24f, + ) + // Keep the SVG fill-rule used by each Action-row source path. React Native + // SVG defaults to nonzero (WINDING); only paths declaring fillRule="evenodd" + // use EVEN_ODD. Other existing icons retain their prior rendering behavior. + private val actionIconFillTypes = + mapOf( + "GlobusOutline" to listOf(Path.FillType.EVEN_ODD), + "LockSolid" to listOf(Path.FillType.EVEN_ODD), + "GoogleIllus" to + listOf( + Path.FillType.WINDING, + Path.FillType.WINDING, + Path.FillType.WINDING, + Path.FillType.WINDING, + ), + "AppleBrand" to listOf(Path.FillType.WINDING), + "BotIllus" to + listOf( + Path.FillType.WINDING, + Path.FillType.WINDING, + Path.FillType.WINDING, + Path.FillType.WINDING, + Path.FillType.WINDING, + Path.FillType.WINDING, + Path.FillType.WINDING, + ), + "AllNetworksSolid" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), + "CrossedSmallSolid" to listOf(Path.FillType.WINDING), + "AccountErrorCustom" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), + "Circle" to listOf(Path.FillType.WINDING), + "BadgeVerifiedSolid" to listOf(Path.FillType.EVEN_ODD), + "ChevronRightSmallOutline" to listOf(Path.FillType.WINDING), + "MinusCircleOutline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), + "PlusCircleOutline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), + "PlusSmallOutline" to listOf(Path.FillType.WINDING), + "DotHorOutline" to listOf(Path.FillType.WINDING), + "MinusCircleSolid" to listOf(Path.FillType.EVEN_ODD), + "PencilOutline" to listOf(Path.FillType.EVEN_ODD), + "DragOutline" to listOf(Path.FillType.WINDING), + "StarSolid" to listOf(Path.FillType.WINDING), + "ChevronGrabberVerOutline" to listOf(Path.FillType.WINDING), + "ChevronBottomOutline" to listOf(Path.FillType.WINDING), + "ChevronTopOutline" to listOf(Path.FillType.WINDING), + "ImageSquareWavesOutline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), + ) + + // Exact 24x24 paths from app-monorepo packages/components Icon sources. + private val iconPaths = + mapOf( + // OneKey patch: official selector SVG path geometry. + "GlobusOutline" to + listOf( + "M12 2c5.185 0 9.448 3.947 9.95 9H22v2h-.05c-.502 5.053-4.765 9-9.95 9s-9.448-3.947-9.95-9H2v-2h.05C2.552 5.947 6.815 2 12 2M9.523 13c.09 1.982.438 3.726.934 5.002.29.746.612 1.282.917 1.614.304.331.517.384.626.384s.322-.053.626-.384c.305-.332.627-.868.917-1.614.496-1.276.845-3.02.934-5.002zm-5.459 0a8 8 0 0 0 4.8 6.36 10 10 0 0 1-.271-.633C7.994 17.187 7.61 15.189 7.52 13zm12.416 0c-.09 2.189-.474 4.187-1.073 5.727a10 10 0 0 1-.271.633 8 8 0 0 0 4.8-6.36zM8.863 4.639A8 8 0 0 0 4.064 11h3.457c.09-2.189.473-4.187 1.072-5.727q.127-.327.27-.634M12 4c-.109 0-.322.053-.626.384-.305.332-.627.868-.917 1.614-.496 1.276-.844 3.02-.934 5.002h4.954c-.09-1.982-.438-3.726-.934-5.002-.29-.746-.612-1.282-.917-1.614C12.322 4.053 12.109 4 12 4m3.136.639q.144.307.271.634c.599 1.54.982 3.538 1.073 5.727h3.456a8 8 0 0 0-4.8-6.361" + ), + "LockSolid" to + listOf( + "M12 2a5 5 0 0 1 5 5v2h3v13H4V9h3V7a5 5 0 0 1 5-5m-1 11v5h2v-5zm1-9a3 3 0 0 0-3 3v2h6V7a3 3 0 0 0-3-3" + ), + "GoogleIllus" to + listOf( + "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09", + "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23", + "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22z", + "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53", + ), + "AppleBrand" to + listOf( + "M11.67.834c.117 1.074-.315 2.153-.955 2.928-.64.773-1.692 1.378-2.718 1.298-.14-1.054.38-2.151.971-2.836C9.63 1.45 10.746.872 11.67.834M14.994 7.093c-.176.108-1.992 1.224-1.972 3.482.025 2.769 2.428 3.693 2.46 3.705l-.004.015a10.1 10.1 0 0 1-1.264 2.593c-.764 1.116-1.556 2.229-2.806 2.254-.598.011-1-.162-1.416-.343-.437-.19-.891-.386-1.609-.386-.751 0-1.226.203-1.683.398-.397.169-.78.333-1.32.354-1.208.047-2.124-1.207-2.895-2.32C.909 14.57-.294 10.414 1.322 7.612c.803-1.395 2.237-2.275 3.794-2.298.671-.014 1.32.244 1.89.47.434.172.821.326 1.135.326.282 0 .659-.149 1.099-.323.692-.273 1.539-.607 2.41-.518.599.026 2.276.24 3.354 1.818z" + ), + "BotIllus" to + listOf( + "M11 2a1 1 0 1 1 2 0v1.8l1.6 1.6a1 1 0 1 1-1.4 1.4L12 5.6l-1.2 1.2a1 1 0 0 1-1.4-1.4L11 3.8z", + "M8.0 6.0h8.0a5.0 5.0 0 0 1 5.0 5.0v4.0a5.0 5.0 0 0 1 -5.0 5.0h-8.0a5.0 5.0 0 0 1 -5.0 -5.0v-4.0a5.0 5.0 0 0 1 5.0 -5.0z", + "M3.0 10.0h0.0a1.5 1.5 0 0 1 1.5 1.5v3.0a1.5 1.5 0 0 1 -1.5 1.5h0.0a1.5 1.5 0 0 1 -1.5 -1.5v-3.0a1.5 1.5 0 0 1 1.5 -1.5z", + "M21.0 10.0h0.0a1.5 1.5 0 0 1 1.5 1.5v3.0a1.5 1.5 0 0 1 -1.5 1.5h0.0a1.5 1.5 0 0 1 -1.5 -1.5v-3.0a1.5 1.5 0 0 1 1.5 -1.5z", + "M7.5 12.0a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0", + "M13.5 12.0a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0", + "M8.5 15.4c.9.8 2.08 1.2 3.5 1.2s2.6-.4 3.5-1.2c.24-.2.6-.18.8.06.2.23.17.6-.06.8-1.14.98-2.58 1.46-4.24 1.46s-3.1-.48-4.24-1.46a.58.58 0 0 1-.06-.8c.2-.24.56-.26.8-.06", + ), + "AllNetworksSolid" to + listOf( + "M15.333 13.998a1.335 1.335 0 1 1 0 2.67 1.335 1.335 0 0 1 0-2.67", + "M12 0c6.627 0 12 5.373 12 12s-5.373 12-12 12S0 18.627 0 12 5.373 0 12 0M8 12.668A2 2 0 0 0 6 14.666V16c0 1.103.895 1.997 1.998 1.998h1.334A2 2 0 0 0 11.33 16v-1.334a2 2 0 0 0-1.998-1.998zm7.333 0a2.665 2.665 0 1 0 0 5.33 2.665 2.665 0 0 0 0-5.33M7.999 6.001A2 2 0 0 0 6.001 8v1.334c0 1.103.895 1.998 1.998 1.998h1.334a2 2 0 0 0 1.998-1.998V7.999a2 2 0 0 0-1.998-1.998zm6.667 0A2 2 0 0 0 12.668 8v1.334c0 1.103.895 1.998 1.998 1.998H16a2 2 0 0 0 1.998-1.998V7.999A2 2 0 0 0 16 6.001z", + ), + "CrossedSmallSolid" to + listOf( + "M17.87 8.25 14.12 12l3.75 3.75-2.12 2.121-3.75-3.75-3.75 3.75-2.121-2.121L9.879 12l-3.75-3.75 2.12-2.121L12 9.879l3.75-3.75 2.122 2.121Z" + ), + "AccountErrorCustom" to + listOf( + "M12.5 12.75a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5", + "M0 3.5A3.5 3.5 0 0 1 3.5 0h8.088A2.41 2.41 0 0 1 14 2.412V5h1a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H4a4 4 0 0 1-4-4zm2 3.163V14a2 2 0 0 0 2 2h11a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H3.5c-.537 0-1.045-.12-1.5-.337M2 3.5A1.5 1.5 0 0 0 3.5 5H12V2.412A.41.41 0 0 0 11.588 2H3.5A1.5 1.5 0 0 0 2 3.5", + ), + "Circle" to listOf("M0 12a12 12 0 1 0 24 0a12 12 0 1 0 -24 0"), + "BadgeVerifiedSolid" to + listOf( + "M9.483 11.458v3.5h-1v-3.5z M10.467 2.698a2.03 2.03 0 0 1 3.065 0l1.358 1.564a.03.03 0 0 0 .028.01l2.046-.325a2.03 2.03 0 0 1 2.347 1.971l.037 2.07q0 .016.014.026l1.776 1.066a2.03 2.03 0 0 1 .532 3.019l-1.304 1.609a.03.03 0 0 0-.005.03l.675 1.956a2.03 2.03 0 0 1-1.533 2.656l-2.033.394a.03.03 0 0 0-.023.019l-.741 1.933a2.03 2.03 0 0 1-2.88 1.05l-1.811-1.006a.03.03 0 0 0-.03 0l-1.811 1.005a2.03 2.03 0 0 1-2.88-1.049l-.742-1.933a.03.03 0 0 0-.023-.019l-2.033-.394a2.03 2.03 0 0 1-1.532-2.656l.675-1.957a.03.03 0 0 0-.005-.029l-1.304-1.61a2.03 2.03 0 0 1 .532-3.018l1.776-1.066a.03.03 0 0 0 .014-.026l.035-2.07a2.03 2.03 0 0 1 2.349-1.97l2.045.324a.03.03 0 0 0 .028-.01zm1.516 3.76a.5.5 0 0 0-.447.276l-1.861 3.724H8.483a1 1 0 0 0-1 1v3.5a1 1 0 0 0 1 1h6.692a2 2 0 0 0 1.981-1.73l.341-2.5a2 2 0 0 0-1.982-2.27h-1.939l.197-1.269a1.5 1.5 0 0 0-1.481-1.731z" + ), + "ArrowBottomOutline" to + listOf("m13 17.586 5-5L19.414 14 12 21.414 4.586 14 6 12.586l5 5V3h2z"), + "ArrowTopOutline" to + listOf("M19.414 10 18 11.414l-5-5V21h-2V6.414l-5 5L4.586 10 12 2.586z"), + "ChartTrendingUpOutline" to + listOf("M22 13h-2V9.414l-7 7-4-4-6 6L1.586 17 9 9.586l4 4L18.586 8H15V6h7z"), + "SwapHorOutline" to + listOf( + "M21 16H6.914l2.293 2.293-1.414 1.414L2.086 14H21zm.914-6H3V8h14.086l-2.293-2.293 1.414-1.414z" + ), + "ShieldExclamationOutline" to + listOf( + "M12 12.25a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5m0-4.75a1 1 0 0 1 1 1v2a1 1 0 0 1-2 0v-2a1 1 0 0 1 1-1", + "M11.352 2.223c.42-.145.876-.145 1.296 0l6.98 2.4a1.995 1.995 0 0 1 1.347 1.886v5.432c0 2.799-1.146 4.817-2.805 6.387-1.61 1.525-3.735 2.652-5.696 3.71a1 1 0 0 1-.947 0c-1.961-1.058-4.086-2.185-5.697-3.71-1.659-1.57-2.805-3.588-2.805-6.386V6.508c0-.852.542-1.61 1.347-1.887l6.98-2.4ZM5.02 6.509v5.432c0 2.16.848 3.676 2.18 4.938 1.272 1.204 2.957 2.149 4.799 3.145 1.842-.996 3.527-1.94 4.799-3.145 1.332-1.262 2.181-2.778 2.181-4.938V6.51L12 4.109z", + ), + "InfoCircleOutline" to + listOf( + "M13 17h-2v-5h-1v-2h3zm0-8h-2V7h2z", + "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2m0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16", + ), + "SpeakerPromoteOutline" to + listOf( + "M21.996 11a4 4 0 0 1-3 3.874v5.495l-5.361-1.711A3.998 3.998 0 0 1 5.996 17v-.779l-4-1.276v-7.89l17-5.424v5.495c1.725.444 3 2.01 3 3.874m-2 0c0-.74-.402-1.385-1-1.731v3.46c.597-.345 1-.989 1-1.729M7.998 7.24v7.521l8.998 2.871V4.368l-8.998 2.87ZM3.996 8.516v4.967l2.002.638V7.877zm4 8.484a2 2 0 0 0 3.706 1.041L7.996 16.86z" + ), + "PlusCircleOutline" to + listOf( + "M13 11h4v2h-4v4h-2v-4l-4 .001v-2L11 11V7h2z", + "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2m0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16", + ), + "PlusSmallOutline" to listOf("M13 11h5v2h-5v5h-2v-5H6v-2h5V6h2z"), + "DotHorOutline" to listOf("M6 14H2v-4h4zm8 0h-4v-4h4zm8 0h-4v-4h4z"), + "MinusCircleOutline" to + listOf( + "M17 13H7v-2h10z", + "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2m0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16", + ), + "MinusCircleSolid" to + listOf( + "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2M7 13h10v-2H7z" + ), + "ErrorSolid" to listOf("M23.256 20H.742L12 1.041zM11 15v2h2v-2zm0-6v5h2V9z"), + "QuestionmarkSolid" to + listOf( + "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2m-1 15h2v-2h-2zM9 7v3h2V9h2v1l-2 1.5V14h2v-1.5l2-1.5V7z" + ), + "PencilOutline" to + listOf( + "M22.414 7.5 7.914 22H2v-5.914l14.5-14.5zM4 16.914V20h3.086l9.5-9.5L13.5 7.414zM14.914 6 18 9.086 19.586 7.5 16.5 4.414z" + ), + "ChevronRightSmallOutline" to listOf("M15.414 12 10 17.414 8.586 16l4-4-4-4L10 6.586z"), + "DragOutline" to + listOf("M11 21H7v-4h4zm6 0h-4v-4h4zm-6-7H7v-4h4zm6 0h-4v-4h4zm-6-7H7V3h4zm6 0h-4V3h4z"), + "StarOutline" to + listOf( + "m15.455 7.243 7.729 1.123-5.592 5.45 1.32 7.698L12 17.879l-6.911 3.635 1.32-7.698-5.592-5.45 7.728-1.123L12 .24zM9.872 9.071l-4.759.69 3.444 3.358-.814 4.738L12 15.62l.465.245 3.791 1.993-.813-4.739 3.443-3.357-4.758-.69L12 4.758z" + ), + "StarSolid" to + listOf( + "m15.405 7.313 7.84 1.034-5.735 5.443 1.44 7.774L12 17.793l-6.948 3.771 1.44-7.774L.756 8.347l7.839-1.034L12 .178z" + ), + "ChevronGrabberVerOutline" to + listOf( + "M17.414 15 12 20.414 6.586 15 8 13.586l4 4 4-4zm0-6L16 10.414l-4-4-4 4L6.586 9 12 3.586z" + ), + "ChevronBottomOutline" to + listOf("M20.707 9.707 12 18.414 3.293 9.707l1.414-1.414L12 15.586l7.293-7.293z"), + "ChevronTopOutline" to + listOf("m20.707 14.293-1.414 1.414L12 8.414l-7.293 7.293-1.414-1.414L12 5.586z"), + "ImageSquareWavesOutline" to + listOf( + "M14.25 7a2 2 0 1 1 0 4 2 2 0 0 1 0-4", + "M21 21H3V3h18zM5 16.414V19h12.586L14 15.414l-2 2-4-4zm0-2.828 3-3 4 4 2-2 5 5V5H5z", + ), + ) + } +} + +internal class OneKeyCheckboxView(context: android.content.Context) : View(context) { + var usesSelectorGeometry = false + private val glyphPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + private var state = "unchecked" + + fun setState(value: String, color: Int) { + state = value + glyphPaint.color = color + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val pathData = + when (state) { + "checked" -> + "M12.204 5.043a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414 0l-2-2a1 1 0 1 1 1.414-1.414l1.293 1.293 3.793-3.793a1 1 0 0 1 1.414 0" + "indeterminate" -> "M4 8a1 1 0 0 1 1-1h6a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1" + else -> return + } + // OneKey patch: the source icon is a 16dp child after the 2dp border, not 80% of a rounded + // frame. + val drawSize = + if (usesSelectorGeometry) (16 * resources.displayMetrics.density).roundToInt().toFloat() + else minOf(width, height) * 0.8f + val borderOffset = (2 * resources.displayMetrics.density).roundToInt().toFloat() + canvas.save() + canvas.translate( + if (usesSelectorGeometry) borderOffset else (width - drawSize) / 2f, + if (usesSelectorGeometry) borderOffset else (height - drawSize) / 2f, + ) + canvas.scale(drawSize / 16f, drawSize / 16f) + PathParser.createPathFromPathData(pathData)?.let { canvas.drawPath(it, glyphPaint) } + canvas.restore() + } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt index a05356a19..5713b89b4 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt @@ -9,7 +9,6 @@ internal object NativeListRendererRegistry { NativeListRendererKey.WALLET_GROUP to ::NativeListWalletGroupRowView, NativeListRendererKey.IDENTITY to ::NativeListIdentityRowView, NativeListRendererKey.MARKET to ::NativeListMarketRowView, - NativeListRendererKey.LEGACY to ::NativeListRowView, NativeListRendererKey.MESSAGE to ::NativeListMessageRowView, NativeListRendererKey.RAIL to ::NativeListRailRowView, NativeListRendererKey.MEDIA_TILE to ::NativeListMediaTileRowView, @@ -36,7 +35,7 @@ internal object NativeListRendererRegistry { "action" to NativeListRendererKey.ACTION, ) - fun key(type: String) = keys[type] ?: NativeListRendererKey.LEGACY + fun key(type: String) = keys.getValue(type) fun create(context: ThemedReactContext, viewType: Int): NativeListRowHost = factories.getValue(NativeListRendererKey.entries[viewType])(context) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowHost.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowHost.kt index 5413cdd9b..d733d5adc 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowHost.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowHost.kt @@ -1,10 +1,21 @@ package com.margelo.nitro.nativelist import android.animation.TimeInterpolator +import android.view.View import android.widget.LinearLayout import com.facebook.react.uimanager.ThemedReactContext import org.json.JSONObject +internal data class NativeListActionOrigin( + val sourceView: View, + val ownerRowView: NativeListRowHost, + val bindingEpoch: Long, + val source: String, + val slot: Int? = null, + val anchorInsetPixels: Int = 0, + val windowPointPixels: android.graphics.PointF? = null, +) + /** List-facing lifecycle shared by lightweight and legacy row implementations. */ internal abstract class NativeListRowHost(context: ThemedReactContext) : LinearLayout(context) { var bindingEpoch: Long = 0 diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt deleted file mode 100644 index 93460f304..000000000 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ /dev/null @@ -1,3599 +0,0 @@ -package com.margelo.nitro.nativelist - -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.animation.TimeInterpolator -import android.animation.ValueAnimator -import android.graphics.Color -import android.graphics.Canvas -import android.graphics.LinearGradient -import android.graphics.Matrix -import android.graphics.Outline -import android.graphics.Paint -import android.graphics.Path -import android.graphics.RectF -import android.graphics.Shader -import android.graphics.Typeface -import android.graphics.drawable.Drawable -import android.graphics.drawable.GradientDrawable -import android.graphics.drawable.StateListDrawable -import android.os.Handler -import android.os.Looper -import android.text.Spannable -import android.text.SpannableStringBuilder -import android.text.TextUtils -import android.text.style.AbsoluteSizeSpan -import android.text.style.BackgroundColorSpan -import android.text.style.ForegroundColorSpan -import android.util.TypedValue -import android.view.Gravity -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup -import android.view.ViewOutlineProvider -import android.view.animation.LinearInterpolator -import android.widget.FrameLayout -import android.widget.LinearLayout -import android.widget.ProgressBar -import android.widget.TextView -import com.facebook.react.uimanager.ThemedReactContext -// OneKey patch: selector checkboxes share the source React Native border/background renderer. -import com.facebook.react.uimanager.BackgroundStyleApplicator -import com.facebook.react.uimanager.LengthPercentage -import com.facebook.react.uimanager.LengthPercentageType -import com.facebook.react.uimanager.style.BorderRadiusProp -import com.facebook.react.uimanager.style.LogicalEdge -import com.margelo.nitro.onekeyimage.OneKeyImageReusableView -import androidx.core.graphics.PathParser -import androidx.core.widget.TextViewCompat -import androidx.recyclerview.widget.RecyclerView -import org.json.JSONArray -import org.json.JSONObject -import kotlin.math.roundToInt - -internal data class NativeListActionOrigin( - val sourceView: View, - val ownerRowView: NativeListRowHost, - val bindingEpoch: Long, - val source: String, - val slot: Int? = null, - val anchorInsetPixels: Int = 0, - val windowPointPixels: android.graphics.PointF? = null, -) - -/** React Native color strings use CSS #RRGGBBAA ordering; Android expects #AARRGGBB. */ -internal fun parseNativeListColor(value: String): Int { - val normalized = value.trim() - val androidValue = if ( - normalized.length == 9 && normalized[0] == '#' && - normalized.drop(1).all { it.isDigit() || it.lowercaseChar() in 'a'..'f' } - ) { - "#${normalized.takeLast(2)}${normalized.substring(1, 7)}" - } else { - normalized - } - return Color.parseColor(androidValue) -} - -internal object NativeListScale { - private const val STANDARD_DP_WIDTH_THRESHOLD = 400 - - fun factor(resources: android.content.res.Resources): Float { - val widthDp = resources.configuration.screenWidthDp - return if (widthDp in 1 until STANDARD_DP_WIDTH_THRESHOLD) 0.9f else 1f - } - - fun dp(resources: android.content.res.Resources, value: Int): Int = - (value * factor(resources) * resources.displayMetrics.density).roundToInt() - - fun dp(resources: android.content.res.Resources, value: Float): Float = - (value * factor(resources) * resources.displayMetrics.density).roundToInt().toFloat() - - fun font(resources: android.content.res.Resources, value: Float): Float = - if (factor(resources) == 1f) value else (value * factor(resources)).roundToInt().toFloat() -} - -internal object NativeListFonts { - private val cache = mutableMapOf() - - private fun load(context: android.content.Context, weight: String, fallback: String): Typeface = - cache.getOrPut(weight) { - try { - Typeface.createFromAsset(context.assets, "fonts/Roobert-$weight.ttf") - } catch (_: RuntimeException) { - Typeface.create(fallback, Typeface.NORMAL) - } - } - - fun regular(context: android.content.Context) = load(context, "Regular", "sans-serif") - fun medium(context: android.content.Context) = load(context, "Medium", "sans-serif-medium") - fun semibold(context: android.content.Context) = load(context, "SemiBold", "sans-serif-medium") - fun bold(context: android.content.Context) = load(context, "Bold", "sans-serif") -} - -internal data class NativeSelectionTarget(val scope: String, val key: String?) - -internal class DottedUnderlineTextView(context: android.content.Context) : NativeListTextView(context) { - var useSourceScale = false - private fun scaledDp(value: Float) = if (useSourceScale) value * resources.displayMetrics.density else NativeListScale.dp(resources, value) - var showsDottedUnderline = false - var dottedUnderlineColor = Color.TRANSPARENT - private val dottedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - if (!showsDottedUnderline || text.isEmpty()) return - dottedPaint.color = dottedUnderlineColor - val radius = scaledDp(0.75f) - val spacing = scaledDp(4f) - val lineWidth = paint.measureText(text.toString()).coerceAtMost(width.toFloat()) - val y = height - radius - var x = scaledDp(1f) - while (x <= lineWidth - radius) { - canvas.drawCircle(x, y, radius, dottedPaint) - x += spacing - } - } -} - -internal class PackedTitleLineLayout(context: android.content.Context) : LinearLayout(context) { - var packsChildrenAtStart = false - // OneKey patch: optional cap for the Market subtitle's localized name. - var leadingTextMaxWidth = Int.MAX_VALUE - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - if (!packsChildrenAtStart || childCount < 2) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - return - } - - val title = getChildAt(0) - val widthMode = MeasureSpec.getMode(widthMeasureSpec) - val widthSize = MeasureSpec.getSize(widthMeasureSpec) - var accessoryWidth = 0 - for (index in 1 until childCount) { - val child = getChildAt(index) - if (child.visibility == GONE) continue - measureChildWithMargins( - child, - widthMeasureSpec, - paddingLeft + paddingRight + accessoryWidth, - heightMeasureSpec, - paddingTop + paddingBottom, - ) - val margins = child.layoutParams as MarginLayoutParams - accessoryWidth += child.measuredWidth + margins.leftMargin + margins.rightMargin - } - val titleMargins = title.layoutParams as MarginLayoutParams - if (widthMode != MeasureSpec.UNSPECIFIED) { - (title as TextView).maxWidth = (widthSize - paddingLeft - paddingRight - accessoryWidth - - titleMargins.leftMargin - titleMargins.rightMargin).coerceAtLeast(0).coerceAtMost(leadingTextMaxWidth) - } - (title.layoutParams as LayoutParams).apply { - width = LayoutParams.WRAP_CONTENT - weight = 0f - } - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - if (widthMode != MeasureSpec.UNSPECIFIED) { - setMeasuredDimension(widthSize, measuredHeight) - } - } -} - -// OneKey patch: fit subtitle segments at intrinsic width, shrinking text only when necessary. -internal class SelectorSubtitleLayout(context: android.content.Context) : LinearLayout(context) { - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val available = MeasureSpec.getSize(widthMeasureSpec) - val labels = mutableListOf>() - var fixedWidth = 0 - for (index in 0 until childCount) { - val child = getChildAt(index) - val params = child.layoutParams as LayoutParams - if (child is TextView) { - child.measure(MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), heightMeasureSpec) - labels.add(child to child.measuredWidth) - } else fixedWidth += params.width + params.leftMargin + params.rightMargin - } - val desired = labels.sumOf { it.second } - val textWidth = (available - fixedWidth).coerceAtLeast(0) - labels.forEach { (label, width) -> - (label.layoutParams as LayoutParams).width = if (desired <= textWidth) width else (width.toLong() * textWidth / desired.coerceAtLeast(1)).toInt() - } - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - } -} - -internal class NativeListRowView( - private val reactContext: ThemedReactContext, -) : NativeListRowHost(reactContext) { - private var marketLeadingUsesSourceClip = false - private val leadingFrame = object : FrameLayout(context) { - override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean { - if (!marketLeadingUsesSourceClip || child !== leadingImages[0]) return super.drawChild(canvas, child, drawingTime) - // OneKey patch: clip only the Market avatar to the source border's padding box. - val saved = canvas.save() - BackgroundStyleApplicator.clipToPaddingBox(this, canvas) - return try { super.drawChild(canvas, child, drawingTime) } finally { canvas.restoreToCount(saved) } - } - } - // OneKey patch: reset selector fragments and corner decorations on every bind. - private val selectorViews = mutableListOf() - private var selectorUsesSourceScale = false - private val selectorAccessibilityDelegate = object : View.AccessibilityDelegate() { - override fun onInitializeAccessibilityNodeInfo(host: View, info: android.view.accessibility.AccessibilityNodeInfo) { - super.onInitializeAccessibilityNodeInfo(host, info) - info.viewIdResourceName = host.getTag(com.facebook.react.R.id.react_test_id) as? String - } - } - private val selectorOriginalFontFeatures = mutableMapOf() - private val selectorOriginalPaintFlags = mutableMapOf() - private val marketOriginalPaintFlags = mutableMapOf() - private val selectorLineHeights = mutableMapOf() - private val selectorFontSizes = mutableMapOf() - private val selectorImages = mutableListOf() - private var selectorHeight: Int? = null - private val leadingImages = List(3) { OneKeyImageReusableView(reactContext) } - private val leadingOverlayBackground = View(context) - private val leadingCornerIconFrame = FrameLayout(context) - private val leadingCornerIcon = OneKeyIconView(context) - private val leadingFallback = TextView(context) - private val leadingIcon = OneKeyIconView(context) - private val headerTitleIcon = OneKeyIconView(context) - private val headerValueIcon = OneKeyIconView(context) - private val leadingActionIcon = OneKeyIconView(context) - private val secondaryImage = OneKeyImageReusableView(reactContext) - private val mainColumn = LinearLayout(context) - private val titleLine = PackedTitleLineLayout(context) - private val title = DottedUnderlineTextView(context) - private val subtitle = NativeListTextView(context) - // OneKey patch: the first text shrinks while the volume retains its width. - private val marketSubtitleLine = PackedTitleLineLayout(context) - private val tertiary = NativeListTextView(context) - private val status = NativeListTextView(context) - private val badgeLine = NativeListTextView(context) - private val marketBadgeViews = List(3) { LinearLayout(context) } - private val marketBadgeLabels = List(3) { TextView(context) } - private val marketBadgeImages = List(3) { OneKeyImageReusableView(reactContext) } - private val marketBadgeGlyphs = List(3) { OneKeyIconView(context) } - private val trailingColumn = LinearLayout(context) - private val trailingViews = List(2) { NativeListTextView(context) } - private val trailingIcons = List(2) { OneKeyIconView(context) } - private val checkbox = OneKeyCheckboxView(context) - private val spinner = ProgressBar(context) - private val unreadDot = View(context) - private val walletGroupRows = mutableListOf() - private val walletGroupDragBadgeBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG) - private val walletGroupDragBadgeBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.STROKE - } - private val walletGroupDragBadgeTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - textAlign = Paint.Align.CENTER - typeface = NativeListFonts.semibold(context) - } - private var walletGroupDragChildCount = 0 - private var walletGroupExpandedHeightPx = 0 - private var walletGroupExpandAnimator: ValueAnimator? = null - private var boundKey: String? = null - // OneKey patch: delayed retries cannot survive cell rebinding or recycling. - private val selectorImageRetries = mutableMapOf() - private var boundCheckboxData: JSONObject? = null - private var currentLayout = "linear" - private var restingRowBackground: Drawable? = null - private var pressedRowBackground: Drawable? = null - // OneKey patch: preserve a held row independently from RecyclerView snapshot rebinding. - private var touchPressed = false - private val marketLongPressHandler = Handler(Looper.getMainLooper()) - private var marketLongPressRunnable: Runnable? = null - private var marketTouchStartX = 0f - private var marketTouchStartY = 0f - private var marketTouchX = 0f - private var marketTouchY = 0f - private var marketLongPressFired = false - // OneKey patch: a row style wrote view properties that resetViews does not restore. - private var styledViewsDirty = false - private val styledTextRestorations = mutableMapOf Unit>() - private val styledBoxRestorations = mutableListOf<() -> Unit>() - private val semanticBadgeLabels = mutableListOf() - private val semanticSubtitleLabels = mutableListOf() - private val semanticValueViews = mutableListOf() - private var walletBadgeLine: View? = null - private var reorderActive = false - private var checkboxCheckedColor = Color.rgb(32, 32, 32) - private var checkboxUncheckedColor = Color.rgb(252, 252, 252) - private var checkboxBorderColor = Color.rgb(206, 206, 206) - private var checkboxIconColor = Color.rgb(252, 252, 252) - private var checkboxUsesSelectorStyle = false - private var iconSubduedColor = Color.rgb(141, 141, 141) - private var visualBackdropColor = Color.WHITE - private var imagePlaceholderColor = "#0000000F" - private val circleOutlineProvider = object : ViewOutlineProvider() { - override fun getOutline(view: View, outline: Outline) { - outline.setOval(0, 0, view.width, view.height) - } - } - private val separatorPaint = Paint(Paint.ANTI_ALIAS_FLAG) - private var showsSeparator = false - - init { - gravity = Gravity.CENTER_VERTICAL - isClickable = true - isFocusable = true - - leadingFallback.gravity = Gravity.CENTER - leadingFallback.typeface = NativeListFonts.bold(context) - leadingFrame.addView(leadingFallback) - leadingFrame.addView(leadingIcon) - leadingFrame.addView(leadingImages[0]) - leadingFrame.addView(leadingOverlayBackground) - leadingFrame.addView(leadingImages[1]) - leadingFrame.addView(leadingImages[2]) - leadingCornerIconFrame.addView(leadingCornerIcon) - leadingFrame.addView(leadingCornerIconFrame) - leadingFrame.addView(unreadDot) - leadingFrame.clipChildren = false - leadingFrame.clipToPadding = false - leadingIcon.layoutParams = FrameLayout.LayoutParams(dp(18), dp(18), Gravity.CENTER) - leadingActionIcon.layoutParams = LayoutParams(dp(24), dp(24)) - leadingCornerIcon.layoutParams = FrameLayout.LayoutParams(dp(18), dp(18), Gravity.CENTER) - unreadDot.layoutParams = FrameLayout.LayoutParams(dp(8), dp(8), Gravity.TOP or Gravity.END) - mainColumn.orientation = VERTICAL - mainColumn.gravity = Gravity.CENTER_VERTICAL - title.typeface = NativeListFonts.regular(context) - subtitle.typeface = NativeListFonts.regular(context) - tertiary.typeface = NativeListFonts.regular(context) - status.typeface = NativeListFonts.regular(context) - badgeLine.typeface = NativeListFonts.medium(context) - listOf(title, subtitle, tertiary, status, badgeLine).forEach { - it.includeFontPadding = false - it.fontFeatureSettings = "tnum" - } - subtitle.maxLines = 2 - status.maxLines = 1 - badgeLine.maxLines = 1 - titleLine.orientation = HORIZONTAL - titleLine.gravity = Gravity.CENTER_VERTICAL - titleLine.addView(title, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) - titleLine.addView(badgeLine, wrap()) - marketBadgeViews.forEachIndexed { index, badge -> - badge.orientation = HORIZONTAL - badge.gravity = Gravity.CENTER_VERTICAL - badge.isClickable = true - marketBadgeGlyphs[index].visibility = GONE - marketBadgeImages[index].visibility = GONE - marketBadgeLabels[index].apply { - includeFontPadding = false - maxLines = 1 - ellipsize = TextUtils.TruncateAt.END - textSize = sp(11f) - typeface = NativeListFonts.medium(context) - } - badge.addView(marketBadgeGlyphs[index], LayoutParams(dp(16), dp(16))) - badge.addView(marketBadgeImages[index], LayoutParams(dp(14), dp(14))) - badge.addView(marketBadgeLabels[index], wrap()) - } - mainColumn.addView(titleLine) - mainColumn.addView(subtitle) - mainColumn.addView(tertiary) - mainColumn.addView(status) - - - - trailingColumn.orientation = VERTICAL - trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL - trailingViews.forEach { trailingColumn.addView(it) } - trailingIcons.forEach { trailingColumn.addView(it) } - trailingColumn.addView(checkbox) - trailingColumn.addView(spinner) - - - - secondaryImage.outlineProvider = object : ViewOutlineProvider() { - override fun getOutline(view: View, outline: Outline) { - outline.setRoundRect(0, 0, view.width, view.height, dp(6).toFloat()) - } - } - secondaryImage.clipToOutline = true - - setOnTouchListener { _, event -> - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> if ((tag as? NativeListItem)?.isRowPressEnabled == true) { - touchPressed = true - marketLongPressFired = false - val item = tag as? NativeListItem - if (item?.type == "market") { - marketTouchStartX = event.x - marketTouchStartY = event.y - marketTouchX = event.x - marketTouchY = event.y - item.json.optString("pressInActionKey").takeIf(String::isNotEmpty)?.let { actionKey -> - emitAction(item, actionKey, null, this, "row") - } - item.json.optString("longPressActionKey").takeIf(String::isNotEmpty)?.let { actionKey -> - val expectedKey = item.key - val runnable = Runnable { - val current = tag as? NativeListItem - if (touchPressed && current?.key == expectedKey && current.type == "market") { - marketLongPressRunnable = null - marketLongPressFired = true - val location = IntArray(2) - getLocationInWindow(location) - onAction?.invoke(current, actionKey, null, actionOrigin(this, "row").copy( - windowPointPixels = android.graphics.PointF(location[0] + marketTouchX, location[1] + marketTouchY), - )) - } - } - marketLongPressRunnable = runnable - marketLongPressHandler.postDelayed(runnable, 800L) - } - } - background = pressedRowBackground - } - MotionEvent.ACTION_MOVE -> { - marketTouchX = event.x - marketTouchY = event.y - val outside = event.x < 0 || event.y < 0 || event.x >= width || event.y >= height - val movedMarket = (tag as? NativeListItem)?.type == "market" && - (kotlin.math.abs(event.x - marketTouchStartX) > dp(10) || - kotlin.math.abs(event.y - marketTouchStartY) > dp(10)) - if (outside || movedMarket) { - cancelMarketLongPress() - touchPressed = false - restoreRestingBackground() - } - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - cancelMarketLongPress() - touchPressed = false - restoreRestingBackground() - } - } - false - } - setOnClickListener { view -> - (view.tag as? NativeListItem)?.let { item -> - if (item.type == "market" && marketLongPressFired) { - marketLongPressFired = false - return@let - } - // OneKey patch: allow create-address accessories when whole-row press is gated. - if (item.isRowPressEnabled) onRowPress?.invoke(item, actionOrigin(view, "row")) - } - } - setWillNotDraw(false) - } - - override fun dispatchDraw(canvas: Canvas) { - super.dispatchDraw(canvas) - if (showsSeparator) { - val separatorStyle = listStyle?.optJSONObject("separator") - val start = if (separatorStyle?.has("inset") == true) { - dp(separatorStyle.optDouble("inset").roundToInt()).toFloat() - } else if ((tag as? NativeListItem)?.type == "identity") { - dp(60).toFloat() - } else { - dp(12).toFloat() - } - canvas.drawLine(start, height - 1f, width.toFloat(), height - 1f, separatorPaint) - } - if (reorderActive && (tag as? NativeListItem)?.type == "walletGroup" && walletGroupDragChildCount > 0) { - val label = "+$walletGroupDragChildCount" - val badgeHeight = dp(24).toFloat() - val badgeWidth = maxOf( - dp(24).toFloat(), - walletGroupDragBadgeTextPaint.measureText(label) + dp(12), - ) - val right = width - dp(4).toFloat() - val bottom = height - dp(4).toFloat() - val left = right - badgeWidth - val top = bottom - badgeHeight - canvas.drawRoundRect( - left, - top, - right, - bottom, - badgeHeight / 2f, - badgeHeight / 2f, - walletGroupDragBadgeBackgroundPaint, - ) - canvas.drawRoundRect( - left, - top, - right, - bottom, - dp(12).toFloat(), - dp(12).toFloat(), - walletGroupDragBadgeBorderPaint, - ) - val metrics = walletGroupDragBadgeTextPaint.fontMetrics - val baseline = (top + bottom - metrics.ascent - metrics.descent) / 2f - canvas.drawText(label, (left + right) / 2f, baseline, walletGroupDragBadgeTextPaint) - } - } - - // OneKey patch: RecyclerView may replace item delegates during a selection update. - override fun onInitializeAccessibilityNodeInfo(info: android.view.accessibility.AccessibilityNodeInfo) { - super.onInitializeAccessibilityNodeInfo(info) - info.viewIdResourceName = getTag(com.facebook.react.R.id.react_test_id) as? String - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val marketItem = (tag as? NativeListItem)?.takeIf { it.type == "market" } - val marketStyle = marketItem?.json?.optJSONObject("style") - if (marketStyle?.has("horizontalPadding") == true) { - // OneKey patch: Yoga rounds the two absolute edges, not both padding values. - val sourcePadding = marketStyle.optDouble("horizontalPadding") * resources.displayMetrics.density - val width = MeasureSpec.getSize(widthMeasureSpec) - val rowHeight = marketItem.styledHeight?.let(::styleDp) ?: selectorHeight - val sourceVerticalPadding = marketStyle.takeIf { it.has("verticalPadding") && rowHeight != null } - ?.optDouble("verticalPadding")?.times(resources.displayMetrics.density) - setPadding( - sourcePadding.roundToInt(), - sourceVerticalPadding?.roundToInt() ?: paddingTop, - width - (width - sourcePadding).roundToInt(), - if (sourceVerticalPadding != null && rowHeight != null) rowHeight - (rowHeight - sourceVerticalPadding).roundToInt() else paddingBottom, - ) - } - - // OneKey patch: RecyclerView must use the adapter's measured selector height. - val heightItem = tag as? NativeListItem - val styledHeight = heightItem?.let(::styledHeightPixels)?.takeUnless { - heightItem.type == "walletGroup" && (reorderActive || walletGroupExpandAnimator?.isRunning == true) - } - super.onMeasure(widthMeasureSpec, (styledHeight ?: selectorHeight)?.let { MeasureSpec.makeMeasureSpec(it, MeasureSpec.EXACTLY) } ?: heightMeasureSpec) - val item = tag as? NativeListItem ?: return - if (item.json.has("height") && item.json.optString("presentation") == "networkSelector" && item.type in setOf("identity", "sectionHeader") && checkbox.visibility == VISIBLE && trailingColumn.parent === this) { - // OneKey patch: Yoga snaps the compound accessory before its children; their rounded edges can overflow it. - val density = resources.displayMetrics.density - val visibleValues = trailingViews.filter { it.visibility == VISIBLE } - val sourceTrailingWidth = visibleValues.sumOf { it.measuredWidth } + (20 + 12 * visibleValues.size) * density - val sourceTrailingLeft = (measuredWidth - 12 * density - sourceTrailingWidth).roundToInt() - val measuredTrailingLeft = measuredWidth - paddingRight - trailingColumn.measuredWidth - val mainWidth = (mainColumn.measuredWidth + sourceTrailingLeft - measuredTrailingLeft).coerceAtLeast(0) - mainColumn.measure(MeasureSpec.makeMeasureSpec(mainWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(mainColumn.measuredHeight, MeasureSpec.EXACTLY)) - } - } - - // OneKey patch: Yoga rounds a half-pixel text center upward; LinearLayout truncates it. - override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { - super.onLayout(changed, left, top, right, bottom) - val item = tag as? NativeListItem ?: return - val hasContainerAlignment = item.json.optJSONObject("style")?.optJSONObject("container")?.has("contentVerticalAlignment") == true - if (item.type == "market" && item.json.optJSONObject("style") != null) { - // OneKey patch: preserve Yoga's half-pixel centering for Market columns and badges. - for (column in listOf(leadingFrame, mainColumn, trailingColumn)) { - if (!hasContainerAlignment && column.parent === this && column.visibility != GONE) { - column.offsetTopAndBottom((height - column.height + 1) / 2 - column.top) - } - } - // OneKey patch: token avatars stay centered in the row when text rounding adds a pixel. - if (!hasContainerAlignment && item.json.optString("variant") != "token" && leadingFrame.parent === this && mainColumn.parent === this) { - leadingFrame.offsetTopAndBottom(mainColumn.top + (mainColumn.height - leadingFrame.height + 1) / 2 - leadingFrame.top) - } - // OneKey patch: preserve Yoga's absolute-edge rounding for the Market network badge. - if (leadingOverlayBackground.visibility == VISIBLE && item.json.optString("variant") == "token") { - val recycler = parent as? RecyclerView - val adapter = recycler?.adapter as? NativeListAdapter - val position = recycler?.getChildAdapterPosition(this) ?: RecyclerView.NO_POSITION - if (recycler != null && adapter != null && position != RecyclerView.NO_POSITION && adapter.itemAt(position)?.key == item.key) { - val density = resources.displayMetrics.density.toDouble() - val rowTop = adapter.marketSourceEdgePx(position) - val rowHeight = adapter.marketSourceEdgePx(position + 1) - rowTop - val imageHeight = item.json.optJSONObject("style")?.optJSONObject("image")?.optDouble("height", 32.0) ?: 32.0 - val contentTop = (recycler.paddingTop / density).roundToInt() * density - val badgeTop = contentTop + rowTop + (rowHeight - imageHeight * density) / 2 + (imageHeight - 16) * density - val badgeHeight = (badgeTop + 20 * density).roundToInt() - badgeTop.roundToInt() - val top = ((imageHeight - 16) * density).roundToInt() - leadingOverlayBackground.layout(leadingOverlayBackground.left, top, leadingOverlayBackground.right, top + badgeHeight) - } - } - for (badge in marketBadgeViews) { - if (badge.parent === titleLine && badge.visibility != GONE) { - badge.offsetTopAndBottom((titleLine.height - badge.height + 1) / 2 - badge.top) - } - } - // OneKey patch: preserve fractional text advances and gaps until the final edge. - val style = item.json.optJSONObject("style") - for ((line, gap) in listOf( - titleLine to style.optDouble("titleBadgeGap", 4.0), - marketSubtitleLine to (item.json.optJSONObject("subtitlePrefix")?.optDouble("gap", 4.0) ?: 0.0), - )) { - var cursor = 0f - var hasPrevious = false - for (index in 0 until line.childCount) { - val child = line.getChildAt(index) - if (child.visibility == GONE) continue - if (hasPrevious) { - cursor += (gap * resources.displayMetrics.density).toFloat() - child.offsetLeftAndRight(cursor.roundToInt() - child.left) - } - val advance = if (index == 0 && child is TextView) { - val textWidth = if (line === titleLine) child.paint.measureText(child.text.toString()) else child.layout?.getLineWidth(0) - textWidth?.coerceAtMost(child.width.toFloat()) ?: child.width.toFloat() - } else child.width.toFloat() - cursor = if (hasPrevious) cursor + advance else child.left + advance - hasPrevious = true - } - } - } - val accessory = item.json.optJSONArray("trailing")?.optJSONObject(0) - if (!hasContainerAlignment && item.type == "identity" && item.json.has("height") && item.json.optString("presentation") == "accountSelector" && accessory?.optString("kind") == "icon" && accessory.optString("name") == "PlusSmallOutline") { - // OneKey patch: the borderless Plus retains the source's fixed top18/negative7 slot. - val icon = trailingIcons[0] - trailingColumn.offsetTopAndBottom(dp(18) - dp(7) - trailingColumn.top - icon.top) - } - if (!item.json.has("height") || hasContainerAlignment) return - val isNetworkIdentity = item.type == "identity" && item.json.optString("presentation") == "networkSelector" - val isNetworkSummary = item.type == "sectionHeader" && item.json.optString("presentation") == "networkSelector" && item.json.optString("variant") == "summary" - val centeredColumns = when { - isNetworkIdentity -> listOf(mainColumn, trailingColumn) - isNetworkSummary -> listOf(trailingColumn) - else -> return - } - for (column in centeredColumns) { - if (column.parent !== this || column.visibility == GONE) continue - val margins = column.layoutParams as MarginLayoutParams - val available = height - paddingTop - paddingBottom - margins.topMargin - margins.bottomMargin - val desiredTop = paddingTop + margins.topMargin + (available - column.height + 1) / 2 - column.offsetTopAndBottom(desiredTop - column.top) - } - } - - override fun bind( - item: NativeListItem, - theme: JSONObject?, - layout: String, - listOrientation: String, - itemIndex: Int?, - selected: Boolean, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - useSourceScale: Boolean, - ) { - val reusesImageIdentity = boundKey == item.key && (tag as? NativeListItem)?.type == item.type - val shouldRestorePressed = touchPressed && boundKey == item.key - cancelMarketLongPress() - marketLongPressFired = false - invalidateCurrentBinding() - bindingEpoch += 1 - boundKey = item.key - touchPressed = shouldRestorePressed - currentLayout = layout - tag = item - selectorUsesSourceScale = item.type == "market" || item.usesSelectorSourceScale || useSourceScale - reorderActive = false - if (!reusesImageIdentity) { - selectorImages.forEach(OneKeyImageReusableView::prepareForReuse) - leadingImages.forEach(OneKeyImageReusableView::prepareForReuse) - secondaryImage.prepareForReuse() - } - resetViews() - - val primary = color(theme, "primaryText", "#000000DF") - val secondary = color(theme, "secondaryText", "#0000009B") - val accent = color(theme, "accent", "#0D8200FC") - checkboxCheckedColor = primary - checkboxUncheckedColor = color(theme, "inverseText", "#FCFCFC") - checkboxIconColor = checkboxUncheckedColor - checkboxUsesSelectorStyle = item.json.optString("presentation") == "networkSelector" - checkboxBorderColor = Color.argb( - 0x31, - 0, - 0, - 0, - ) - if (item.json.optString("presentation") == "networkSelector") { - checkboxCheckedColor = color(theme, "checkboxBackground", "#202020") - checkboxBorderColor = color(theme, "checkboxBorder", "#00000031") - checkboxIconColor = color(theme, "checkboxIcon", "#FFFFFF") - // OneKey patch: the V1 checkbox fills even its unchecked body with iconInverse. - checkboxUncheckedColor = checkboxIconColor - } - iconSubduedColor = color(theme, "iconSubdued", "#00000072") - visualBackdropColor = color(theme, "rowBackground", "#FFFFFF") - imagePlaceholderColor = theme?.optString("strongBackground", "#0000000F") - ?.takeIf(String::isNotEmpty) ?: "#0000000F" - unreadDot.background = roundedFill( - parseNativeListColor("#E5484D"), - 4f, - ) - applySelectionState(item, theme, layout, itemIndex, selected) - pressedRowBackground = groupedBackground( - "single", - color( - theme, - "rowPressedBackground", - "#00000017", - ), - ) - background = if (touchPressed || reorderActive) pressedRowBackground else restingRowBackground - if (layout == "table") { - setPadding(dp(16), dp(8), dp(16), dp(8)) - } - title.setTextColor(primary) - subtitle.setTextColor(secondary) - tertiary.setTextColor(secondary) - status.setTextColor(secondary) - badgeLine.setTextColor(accent) - separatorPaint.color = safeColor( - listStyle?.optJSONObject("separator")?.optString("color"), - color(theme, "separator", "#0000001F"), - ) - separatorPaint.strokeWidth = 1f - showsSeparator = nativeListLegacyShowsSeparator(item) - invalidate() - trailingViews.forEach { it.setTextColor(primary) } - isEnabled = !item.json.optBoolean("disabled", false) - if (!isEnabled) touchPressed = false - background = if (touchPressed || reorderActive) pressedRowBackground else restingRowBackground - // OneKey patch: deprecation dims the row without disabling menu controls. - // alpha = if (isEnabled) 1f else 0.5f - alpha = item.json.optDouble("opacity", 1.0).toFloat() * (if (isEnabled) 1f else 0.5f) - contentDescription = item.json.optString("accessibilityLabel", item.json.optString("title")) - // OneKey patch: retain stable original selector test identifiers. - setTag(com.facebook.react.R.id.react_test_id, item.json.optString("testID").takeIf { it.isNotEmpty() }) - - when (item.type) { - "walletGroup" -> bindWalletGroup(item, theme, layout, listOrientation, checkboxState) - "identity" -> bindIdentity(item, theme, selected, checkboxState) - "market" -> bindMarket(item, theme) - "sectionHeader" -> bindSectionHeader(item, theme, checkboxState) - } - // Keep passive rows out of accessibility and keyboard focus while allowing - // their independently bound accessory controls to remain interactive. - val wholeRowPressEnabled = item.isWholeRowInteractive - isClickable = wholeRowPressEnabled - isFocusable = wholeRowPressEnabled - applySize(item) - applyListOrientation(item, listOrientation) - applySelectorTypography(item) - applyRowStyle(item) - } - - /** - * docs/STYLE_SPEC.md section 8. Must run after applySize(), which re-dispatches - * font size and typeface by row type and would otherwise overwrite the style. - * A style key names a model field; nativeListStyleSlot maps it to the view that - * renders that field, because the view pool is shared across templates. - */ - private fun applyContainerStyle(item: NativeListItem, style: JSONObject?) { - if (style == null) return - alpha = style.optDouble("opacity", item.json.optDouble("opacity", 1.0)).toFloat() * (if (isEnabled) 1f else 0.5f) - fun decorate(drawable: android.graphics.drawable.Drawable?, resting: Boolean): android.graphics.drawable.Drawable? { - val surface = (drawable?.constantState?.newDrawable()?.mutate() as? GradientDrawable) ?: return drawable - if (resting && style.has("backgroundColor")) surface.setColor(safeColor(style.optString("backgroundColor"), Color.TRANSPARENT)) - if (style.has("cornerRadius")) { - val radius = styleDp(style.optDouble("cornerRadius")).toFloat() - surface.cornerRadii = FloatArray(8) { radius } - } - if (style.has("borderWidth")) surface.setStroke(styleDp(style.optDouble("borderWidth")), safeColor(style.optString("borderColor"), Color.TRANSPARENT)) - else if (style.has("borderColor")) surface.setStroke(if (item.type == "walletGroup") dp(1) else 0, safeColor(style.optString("borderColor"), Color.TRANSPARENT)) - return surface - } - restingRowBackground = decorate(restingRowBackground, true) - pressedRowBackground = decorate(pressedRowBackground, false) - if (style.has("borderWidth")) { - val originalForeground = foreground - styledBoxRestorations.add { foreground = originalForeground } - foreground = (restingRowBackground?.constantState?.newDrawable()?.mutate() as? GradientDrawable)?.apply { - setColor(Color.TRANSPARENT) - } - // Background strokes are covered by opaque wallet members. Paint once above them. - (restingRowBackground as? GradientDrawable)?.setStroke(0, Color.TRANSPARENT) - (pressedRowBackground as? GradientDrawable)?.setStroke(0, Color.TRANSPARENT) - } - background = if (touchPressed || reorderActive) pressedRowBackground else restingRowBackground - val oldClip = clipToOutline - val oldGravity = gravity - styledBoxRestorations.add { clipToOutline = oldClip; gravity = oldGravity } - if (style.has("cornerRadius")) clipToOutline = true - if (style.has("contentVerticalAlignment")) { - gravity = (gravity and Gravity.VERTICAL_GRAVITY_MASK.inv()) or when (style.optString("contentVerticalAlignment")) { - "top" -> Gravity.TOP - "bottom" -> Gravity.BOTTOM - else -> Gravity.CENTER_VERTICAL - } - } - } - - private fun applyRowStyle(item: NativeListItem) { - val style = item.json.optJSONObject("style") ?: return - styledViewsDirty = true - applyContainerStyle(item, style.optJSONObject("container")) - // Market text/box styles are applied by its quote-aware binder. - if (item.type == "market") return - val padding = intArrayOf(paddingLeft, paddingTop, paddingRight, paddingBottom) - styledBoxRestorations.add { setPadding(padding[0], padding[1], padding[2], padding[3]) } - if (style.has("horizontalPadding")) { - val inset = styleDp(style.optDouble("horizontalPadding")) - setPadding(inset, paddingTop, inset, paddingBottom) - } - if (style.has("verticalPadding")) { - val inset = styleDp(style.optDouble("verticalPadding")) - setPadding(paddingLeft, inset, paddingRight, inset) - } - if (style.has("lineGap") && item.type != "walletGroup") { - val target = mainColumn - styleGap(target, styleDp(style.optDouble("lineGap"))) - } - if (style.has("leadingGap") && leadingFrame.parent != null) { - val gap = styleDp(style.optDouble("leadingGap")) - if ((leadingFrame.parent as? LinearLayout)?.orientation == VERTICAL) { - styleMargins(leadingFrame, end = 0, bottom = gap) - if (mainColumn.parent === this) styleMargins(mainColumn, top = 0) - } else styleMargins(leadingFrame, end = gap) - } - if (style.has("titleBadgeGap")) { - val gap = styleDp(style.optDouble("titleBadgeGap")) - walletBadgeLine?.let { styleMargins(it, top = gap) } ?: styleMargins(badgeLine, start = gap) - } - if (style.has("trailingGap")) { - val gap = styleDp(style.optDouble("trailingGap")) - styleGap(trailingColumn, gap) - } - style.optJSONObject("image")?.let { image -> - if (leadingFrame.parent != null) { - val params = leadingFrame.layoutParams - val oldWidth = params.width - val oldHeight = params.height - styledBoxRestorations.add { params.width = oldWidth; params.height = oldHeight; leadingFrame.layoutParams = params } - if (image.has("width")) params.width = styleDp(image.optDouble("width")) - if (image.has("height")) params.height = styleDp(image.optDouble("height")) - leadingFrame.layoutParams = params - if (image.has("width") || image.has("height")) { - listOf(leadingImages[0], leadingFallback).forEach { child -> - val childParams = child.layoutParams - val width = childParams.width - val height = childParams.height - styledBoxRestorations.add { childParams.width = width; childParams.height = height; child.layoutParams = childParams } - if (image.has("width")) childParams.width = params.width - if (image.has("height")) childParams.height = params.height - child.layoutParams = childParams - } - } - val shape = image.optString("shape") - val radius = when { - image.has("cornerRadius") -> styleDp(image.optDouble("cornerRadius")).toFloat() - shape == "square" -> 0f - shape == "rounded" -> styleDp(10.0).toFloat() - shape == "circle" -> -1f - else -> null - } - if (radius != null) { - listOf(leadingFrame, leadingImages[0], leadingFallback).forEach { view -> - val original = view.outlineProvider - val clip = view.clipToOutline - styledBoxRestorations.add { view.outlineProvider = original; view.clipToOutline = clip } - view.outlineProvider = object : ViewOutlineProvider() { - override fun getOutline(view: View, outline: Outline) { - if (radius < 0) outline.setOval(0, 0, view.width, view.height) - else outline.setRoundRect(0, 0, view.width, view.height, radius) - } - } - view.clipToOutline = view !== leadingFrame || (leadingImages[1].visibility == GONE && leadingCornerIconFrame.visibility == GONE && selectorViews.none { it.parent === leadingFrame }) - } - } - } - } - - - - val variant = item.json.optString("variant") - val fields = style.keys() - while (fields.hasNext()) { - val field = fields.next() - val slotStyle = style.optJSONObject(field) ?: continue - when (val slot = nativeListStyleSlot(item.type, variant, field)) { - null -> continue - "value", "valueSecondary" -> { - val view = if (item.type == "identity") semanticValueViews.getOrNull(if (slot == "value") 0 else 1) else styledSlotView(slot) - view?.let { applyStyledText(it, slotStyle) } - } - "status" -> applyStyledText(if (false) badgeLine else status, slotStyle) - "badge" -> (semanticBadgeLabels.ifEmpty { listOf(badgeLine) }).forEach { applyStyledText(it, slotStyle) } - "subtitle" -> (semanticSubtitleLabels.ifEmpty { listOf(subtitle) }).forEach { applyStyledText(it, slotStyle) } - else -> styledSlotView(slot)?.let { applyStyledText(it, slotStyle) } - } - } - } - - private fun styleDp(value: Double): Int = (value * resources.displayMetrics.density).roundToInt() - - private fun styledHeightPixels(item: NativeListItem): Int? = item.styledHeight?.let { height -> - val pixels = height * resources.displayMetrics.density - if (item.json.optString("heightRounding") == "floor") pixels.toInt() else pixels.roundToInt() - } - - private fun styleMargins(view: View, start: Int? = null, end: Int? = null, top: Int? = null, bottom: Int? = null) { - val params = view.layoutParams as? MarginLayoutParams ?: return - val saved = intArrayOf(params.marginStart, params.marginEnd, params.topMargin, params.bottomMargin) - styledBoxRestorations.add { - params.marginStart = saved[0]; params.marginEnd = saved[1] - params.topMargin = saved[2]; params.bottomMargin = saved[3] - view.layoutParams = params - } - start?.let { params.marginStart = it }; end?.let { params.marginEnd = it } - top?.let { params.topMargin = it }; bottom?.let { params.bottomMargin = it } - view.layoutParams = params - } - - private fun styleGap(stack: LinearLayout, gap: Int) { - val visible = (0 until stack.childCount).map(stack::getChildAt).filter { it.visibility != GONE } - visible.forEachIndexed { index, view -> - if (stack.orientation == HORIZONTAL) styleMargins(view, start = if (index == 0) 0 else gap, end = 0) - else styleMargins(view, top = if (index == 0) 0 else gap, bottom = 0) - } - } - - private fun styledSlotView(slot: String): TextView? = when (slot) { - "title" -> title - "subtitle" -> subtitle - "tertiary" -> tertiary - "status" -> status - "badge" -> badgeLine - "value" -> trailingViews[0] - "valueSecondary" -> trailingViews[1] - else -> null - } - - private fun applyStyledText(view: TextView, style: JSONObject) { - // Save the bound template's actual defaults, including alignment. Guessing - // one shared baseline loses the differences between labels and variants. - if (view !in styledTextRestorations) { - val textSize = view.textSize - val typeface = view.typeface - val colors = view.textColors - val originalText = view.text - val lineSpacingExtra = view.lineSpacingExtra - val lineSpacingMultiplier = view.lineSpacingMultiplier - val maxLines = view.maxLines - val ellipsize = view.ellipsize - val gravity = view.gravity - val offset = (view as? NativeListTextView)?.opticalOffsetY ?: 0f - val scrolling = (view as? NativeListTextView)?.rowHorizontallyScrolling ?: false - styledTextRestorations[view] = { - view.setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, textSize) - view.typeface = typeface - view.setTextColor(colors) - view.text = originalText - view.setLineSpacing(lineSpacingExtra, lineSpacingMultiplier) - view.maxLines = maxLines - view.ellipsize = ellipsize - view.gravity = gravity - (view as? NativeListTextView)?.opticalOffsetY = offset - view.setHorizontallyScrolling(scrolling) - } - } - if (style.has("fontSize")) { - view.setTextSize(TypedValue.COMPLEX_UNIT_PX, (style.optDouble("fontSize") * resources.displayMetrics.density).toFloat()) - val text = SpannableStringBuilder(view.text) - text.getSpans(0, text.length, AbsoluteSizeSpan::class.java).forEach(text::removeSpan) - view.text = text - } - style.optString("fontWeight").takeIf(String::isNotEmpty)?.let { - view.typeface = marketTypeface(it, "regular") - } - style.optString("color").takeIf(String::isNotEmpty)?.let { - val color = safeColor(it, view.currentTextColor) - view.setTextColor(color) - val text = SpannableStringBuilder(view.text) - text.getSpans(0, text.length, ForegroundColorSpan::class.java).forEach(text::removeSpan) - view.text = text - } - if (style.has("lineHeight")) { - val text = SpannableStringBuilder(view.text) - text.getSpans(0, text.length, NativeListLineHeightSpan::class.java).forEach(text::removeSpan) - text.setSpan(NativeListLineHeightSpan(styleDp(style.optDouble("lineHeight"))), 0, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - view.setLineSpacing(0f, 1f) - view.text = text - } - if (style.has("lines")) { - view.maxLines = style.optInt("lines").coerceIn(1, 3) - view.ellipsize = TextUtils.TruncateAt.END - } - if (style.has("truncate") || style.has("lines")) { - view.setHorizontallyScrolling(view.maxLines == 1) - view.ellipsize = if (style.optString("truncate", "tail") == "clip") null else TextUtils.TruncateAt.END - if (view.maxLines == 1) { - val text = SpannableStringBuilder(view.text) - Regex("\\r\\n|[\\r\\n]").findAll(text).toList().asReversed().forEach { match -> text.replace(match.range.first, match.range.last + 1, " ") } - view.text = text - } - } - if (style.has("verticalAlignment")) { - view.gravity = (view.gravity and Gravity.VERTICAL_GRAVITY_MASK.inv()) or when (style.optString("verticalAlignment")) { - "top" -> Gravity.TOP - "bottom" -> Gravity.BOTTOM - else -> Gravity.CENTER_VERTICAL - } - } - if (style.has("offsetY")) (view as? NativeListTextView)?.opticalOffsetY = (style.optDouble("offsetY") * resources.displayMetrics.density).toFloat() - style.optString("alignment").takeIf(String::isNotEmpty)?.let { - view.gravity = (view.gravity and Gravity.VERTICAL_GRAVITY_MASK) or when (it) { - "center" -> Gravity.CENTER_HORIZONTAL - "end" -> Gravity.END - else -> Gravity.START - } - } - } - - // OneKey patch: keep a small idle member pool after reuse or a direct rebind. - // The compact proxy/expansion keeps every member until it leaves that state. - private fun trimWalletGroupRows(required: Int) { - val retained = maxOf(8, required) - while (walletGroupRows.size > retained) { - val row = walletGroupRows.removeAt(walletGroupRows.lastIndex) - (row.parent as? ViewGroup)?.removeView(row) - row.dispose() - row.onRowPress = null - row.onAction = null - row.onBindingInvalidated = null - } - } - - override fun recycle() { - cancelMarketLongPress() - marketLongPressFired = false - touchPressed = false - restoreRestingBackground() - invalidateCurrentBinding() - boundKey = null - selectorImages.forEach(OneKeyImageReusableView::prepareForReuse) - leadingImages.forEach(OneKeyImageReusableView::prepareForReuse) - secondaryImage.prepareForReuse() - walletGroupRows.forEach { row -> - row.visibility = VISIBLE - row.alpha = 1f - row.recycle() - } - if (!reorderActive && walletGroupExpandAnimator?.isRunning != true) trimWalletGroupRows(0) - } - - override fun bindSelection( - item: NativeListItem, - theme: JSONObject?, - layout: String, - itemIndex: Int?, - selected: Boolean, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - ) { - if (boundKey != item.key) return - // OneKey patch: keep row callbacks and checkbox fallback state current without resetting images. - tag = item - resetRowStyle() - if (item.type == "walletGroup") { - val members = buildList { - add(item.json.getJSONObject("parent")) - item.json.getJSONArray("children").let { children -> - for (index in 0 until children.length()) add(children.getJSONObject(index)) - } - } - members.forEachIndexed { index, memberJson -> - walletGroupRows.getOrNull(index)?.bindSelection( - NativeListItem.parse(memberJson), - theme, - layout, - null, - memberJson.optBoolean("selected", false), - checkboxState, - ) - } - // The group owns the subdued backdrop. Selection belongs to a member; - // applying the translucent selected color to both levels produces dark - // bands in the 12dp gaps and double-composites the parent highlight. - background = restingRowBackground - applyRowStyle(item) - return - } - applySelectionState(item, theme, layout, itemIndex, selected) - if (item.type == "identity" && item.json.optString("presentation") == "walletSidebar") { - title.setTextColor( - color( - theme, - if (selected) "primaryText" else "secondaryText", - if (selected) "#FFFFFFED" else "#FFFFFFAF", - ), - ) - } - // OneKey patch: the comparator permits state changes only on these existing checkbox slots. - // boundCheckboxData?.let { bindCheckbox(item, it, checkboxState) } - if (boundCheckboxData != null) { - val latestCheckbox = if (item.type == "identity") { - item.json.optJSONArray("trailing")?.let { trailing -> - (0 until trailing.length()).mapNotNull { trailing.optJSONObject(it) } - .lastOrNull { it.optString("kind") == "checkbox" } - } - } else item.json.optJSONObject("checkbox") - boundCheckboxData = latestCheckbox ?: boundCheckboxData - boundCheckboxData?.let { bindCheckbox(item, it, checkboxState) } - } - if (item.type == "market") bindMarketQuote(item, theme) - applyRowStyle(item) - } - - override fun bindStableSummary(item: NativeListItem) { - if ( - boundKey != item.key || - item.type != "sectionHeader" || - item.json.optString("variant") != "summary" - ) { - return - } - resetRowStyle() - tag = item - contentDescription = item.json.optString("accessibilityLabel", item.json.optString("title")) - title.text = item.json.optString("title") - trailingViews[0].text = item.json.optString("value") - applySelectorTypography(item) - } - - override fun dispose() { - cancelMarketLongPress() - invalidateCurrentBinding() - restoreRestingBackground() - selectorImages.forEach(OneKeyImageReusableView::dispose) - selectorImages.clear() - leadingImages.forEach(OneKeyImageReusableView::dispose) - secondaryImage.dispose() - marketBadgeImages.forEach(OneKeyImageReusableView::dispose) - walletGroupRows.forEach(NativeListRowView::dispose) - } - - private fun actionOrigin( - sourceView: View, - source: String, - slot: Int? = null, - ) = NativeListActionOrigin(sourceView, this, bindingEpoch, source, slot, - if ((tag as? NativeListItem)?.json?.optString("presentation") == "accountSelector" && sourceView in trailingIcons && (sourceView.layoutParams as MarginLayoutParams).marginStart < 0) dp(7) else 0) - - private fun emitAction( - item: NativeListItem, - actionKey: String, - target: NativeSelectionTarget?, - sourceView: View, - source: String, - slot: Int? = null, - ) { - onAction?.invoke(item, actionKey, target, actionOrigin(sourceView, source, slot)) - } - - // OneKey patch: match SizableText TABULAR_NUMS on dynamic labels without replacing their typeface. - private fun applySelectorTypography(item: NativeListItem) { - val usesSelectorTypography = item.json.optString("presentation") in setOf("accountSelector", "networkSelector", "walletSidebar") - fun visit(view: View) { - if (view is OneKeyIconView) view.useSourceScale = selectorUsesSourceScale - if (view is DottedUnderlineTextView) view.useSourceScale = selectorUsesSourceScale - if (view is TextView && usesSelectorTypography) { - val selectorLineHeight = selectorLineHeights.getOrPut(view) { view.lineHeight } - val original = view.fontFeatureSettings - if (!selectorOriginalFontFeatures.containsKey(view)) selectorOriginalFontFeatures[view] = original - view.fontFeatureSettings = if (original.isNullOrEmpty()) "tnum" else if (original.contains("tnum")) original else "$original, 'tnum' 1" - // OneKey patch: SizableText disables font scaling and rounds font sizes to whole pixels. - val sourceTypography = selectorUsesSourceScale - if (sourceTypography) { - // OneKey patch: React Native CustomStyleSpan disables hinting and preserves fractional advances. - selectorOriginalPaintFlags.putIfAbsent(view, view.paintFlags) - view.paintFlags = view.paintFlags or Paint.SUBPIXEL_TEXT_FLAG or Paint.LINEAR_TEXT_FLAG - val originalSize = selectorFontSizes.getOrPut(view) { view.textSize } - val sourceSize = originalSize * resources.displayMetrics.density / resources.displayMetrics.scaledDensity - view.setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, kotlin.math.ceil(sourceSize.toDouble()).toFloat()) - view.letterSpacing = 0f - } - if (sourceTypography && view.text.isNotEmpty()) { - val text = SpannableStringBuilder(view.text) - text.getSpans(0, text.length, NativeListLineHeightSpan::class.java).forEach(text::removeSpan) - text.setSpan(NativeListLineHeightSpan(selectorLineHeight), 0, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - view.setLineSpacing(0f, 1f) - view.text = text - } - } - if (view is ViewGroup) for (index in 0 until view.childCount) visit(view.getChildAt(index)) - } - visit(this) - } - - private fun invalidateCurrentBinding() { - selectorImageRetries.forEach { (image, retry) -> image.removeCallbacks(retry) } - selectorImageRetries.clear() - if (boundKey == null) return - onBindingInvalidated?.invoke(this, bindingEpoch) - bindingEpoch += 1 - } - - /** - * docs/STYLE_SPEC.md section 7 rule 2: restore the bound template's actual text - * defaults before the normal reset and next binder. Shared constructor defaults - * cannot represent the alignment and metrics of every template and variant. - */ - private fun resetRowStyle() { - if (!styledViewsDirty) return - styledViewsDirty = false - styledBoxRestorations.asReversed().forEach { it() } - styledBoxRestorations.clear() - styledTextRestorations.values.forEach { it() } - styledTextRestorations.clear() - } - - private fun resetViews() { - resetRowStyle() - semanticBadgeLabels.clear() - semanticSubtitleLabels.clear() - semanticValueViews.clear() - walletBadgeLine = null - clipChildren = true - clipToPadding = true - selectorOriginalFontFeatures.forEach { (view, original) -> view.fontFeatureSettings = original } - selectorOriginalFontFeatures.clear() - selectorOriginalPaintFlags.forEach { (view, flags) -> view.paintFlags = flags } - selectorOriginalPaintFlags.clear() - marketOriginalPaintFlags.forEach { (view, flags) -> view.paintFlags = flags } - marketOriginalPaintFlags.clear() - selectorLineHeights.clear() - selectorFontSizes.clear() - selectorImages.forEach { (it.parent as? ViewGroup)?.removeView(it) } - // OneKey patch: selector-only views cannot survive a recycled binding. - selectorViews.forEach { (it.parent as? ViewGroup)?.removeView(it) } - selectorViews.clear() - selectorHeight = null - marketBadgeViews.forEachIndexed { index, badge -> - (badge.parent as? ViewGroup)?.removeView(badge) - badge.visibility = GONE - badge.background = null - badge.setPadding(0, 0, 0, 0) - badge.setOnClickListener(null) - badge.contentDescription = null - marketBadgeLabels[index].apply { - // OneKey patch: restore every optional Market badge typography property on reuse. - text = "" - layoutParams = wrap() - fontFeatureSettings = null - textSize = sp(11f) - typeface = NativeListFonts.medium(context) - setLineSpacing(0f, 1f) - includeFontPadding = false - maxLines = 1 - ellipsize = TextUtils.TruncateAt.END - gravity = Gravity.START or Gravity.CENTER_VERTICAL - setTextColor(color(null, "secondaryText", "#0000009B")) - } - marketBadgeImages[index].prepareForReuse() - marketBadgeImages[index].visibility = GONE - marketBadgeGlyphs[index].apply { - visibility = GONE - iconName = "" - tintColor = color(null, "secondaryText", "#0000009B") - } - } - title.setOnClickListener(null) - title.isClickable = false - walletGroupRows.forEach { it.invalidateCurrentBinding() } - walletGroupExpandAnimator?.removeAllListeners() - walletGroupExpandAnimator?.cancel() - walletGroupExpandAnimator = null - layoutParams?.takeIf { it.height != ViewGroup.LayoutParams.WRAP_CONTENT }?.let { params -> - params.height = ViewGroup.LayoutParams.WRAP_CONTENT - layoutParams = params - } - restoreRestingBackground() - walletGroupRows.forEach { row -> - row.visibility = VISIBLE - row.alpha = 1f - row.recycle() - } - removeAllViews() - // OneKey patch: the old animator is cancelled and old children are detached. - // Do not trim currently needed members when re-binding an expanded group. - val nextItem = tag as? NativeListItem - val required = if (nextItem?.type == "walletGroup") (nextItem.json.optJSONArray("children")?.length() ?: 0) + 1 else 0 - trimWalletGroupRows(required) - orientation = HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - minimumHeight = 0 - setPadding(dp(12), dp(8), dp(12), dp(8)) - walletGroupDragChildCount = 0 - walletGroupExpandedHeightPx = 0 - boundCheckboxData = null - mainColumn.orientation = VERTICAL - mainColumn.gravity = Gravity.CENTER_VERTICAL - // OneKey patch: remove the Market group before restoring shared labels. - marketSubtitleLine.removeAllViews() - mainColumn.removeView(marketSubtitleLine) - mainColumn.removeView(titleLine) - mainColumn.removeView(subtitle) - mainColumn.removeView(tertiary) - mainColumn.addView(titleLine, 0) - mainColumn.addView(subtitle, 1) - mainColumn.addView(tertiary, 2) - titleLine.packsChildrenAtStart = false - titleLine.removeView(headerTitleIcon) - trailingColumn.removeView(headerValueIcon) - title.gravity = Gravity.START - title.ellipsize = null - title.maxWidth = Int.MAX_VALUE - title.setHorizontallyScrolling(false) - title.text = "" - subtitle.text = "" - tertiary.text = "" - status.text = "" - badgeLine.text = "" - title.setLineSpacing(0f, 1f) - subtitle.setLineSpacing(0f, 1f) - tertiary.setLineSpacing(0f, 1f) - tertiary.maxWidth = Int.MAX_VALUE - tertiary.ellipsize = TextUtils.TruncateAt.END - tertiary.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) - title.letterSpacing = 0f - title.showsDottedUnderline = false - title.setPadding(0, 0, 0, 0) - subtitle.letterSpacing = 0f - status.letterSpacing = 0f - badgeLine.letterSpacing = 0f - status.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) - subtitle.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) - title.layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) - badgeLine.layoutParams = wrap() - titleLine.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) - title.visibility = GONE - subtitle.visibility = GONE - tertiary.visibility = GONE - status.visibility = GONE - badgeLine.visibility = GONE - badgeLine.background = null - badgeLine.setPadding(0, 0, 0, 0) - trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL - trailingColumn.orientation = VERTICAL - trailingColumn.layoutParams = wrap() - trailingColumn.clipChildren = true - trailingViews.forEach { - it.visibility = GONE - it.setTag(com.facebook.react.R.id.react_test_id, null) - it.gravity = Gravity.END - it.maxLines = 1 - it.layoutParams = wrap() - it.background = null - it.setPadding(0, 0, 0, 0) - it.setOnClickListener(null) - } - trailingIcons.forEach { - it.visibility = GONE - it.layoutParams = LayoutParams(dp(24), dp(24)).apply { gravity = Gravity.END } - it.glyphSizeDp = null - it.alpha = 1f - it.isEnabled = true - it.background = null - it.setPadding(0, 0, 0, 0) - it.setOnClickListener(null) - } - checkbox.visibility = GONE - checkbox.layoutParams = LayoutParams(dp(20), dp(20)).apply { gravity = Gravity.END } - checkbox.alpha = 1f - checkbox.setOnClickListener(null) - spinner.visibility = GONE - spinner.layoutParams = LayoutParams(dp(20), dp(20)).apply { gravity = Gravity.END } - spinner.alpha = 1f - marketLeadingUsesSourceClip = false - leadingFrame.visibility = GONE - leadingFrame.background = null - leadingFrame.clipChildren = false - leadingFrame.clipToPadding = false - secondaryImage.visibility = GONE - secondaryImage.foreground = null - unreadDot.visibility = GONE - leadingIcon.visibility = GONE - leadingIcon.iconName = "" - leadingIcon.layoutParams = FrameLayout.LayoutParams(dp(18), dp(18), Gravity.CENTER) - leadingIcon.glyphSizeDp = null - headerTitleIcon.visibility = GONE - headerTitleIcon.iconName = "" - headerValueIcon.visibility = GONE - headerValueIcon.iconName = "" - leadingActionIcon.visibility = GONE - leadingActionIcon.iconName = "" - leadingActionIcon.glyphSizeDp = 24 - leadingActionIcon.setOnClickListener(null) - leadingActionIcon.setTag(com.facebook.react.R.id.react_test_id, null) - leadingActionIcon.contentDescription = null - mainColumn.visibility = VISIBLE - - leadingOverlayBackground.visibility = GONE - leadingCornerIconFrame.visibility = GONE - leadingCornerIcon.iconName = "" - leadingImages.forEach { - it.visibility = GONE - it.clipToOutline = false - it.outlineProvider = ViewOutlineProvider.BACKGROUND - } - leadingFallback.text = "" - leadingFallback.textSize = sp(13f) - leadingFallback.typeface = NativeListFonts.bold(context) - leadingFallback.visibility = GONE - leadingFallback.background = null - showsSeparator = false - setOnClickListener { view -> - (view.tag as? NativeListItem)?.let { item -> - if (item.type == "market" && marketLongPressFired) { - marketLongPressFired = false - return@let - } - // OneKey patch: allow create-address accessories when whole-row press is gated. - if (item.isRowPressEnabled) onRowPress?.invoke(item, actionOrigin(view, "row")) - } - } - } - - private fun cancelMarketLongPress() { - marketLongPressRunnable?.let(marketLongPressHandler::removeCallbacks) - marketLongPressRunnable = null - } - - private fun restoreRestingBackground() { - background = if (reorderActive) pressedRowBackground else restingRowBackground - leadingFrame.alpha = 1f - } - - override fun setReorderActive(active: Boolean) { - if ((tag as? NativeListItem)?.type == "walletGroup" && reorderActive == active) return - reorderActive = active - if ((tag as? NativeListItem)?.type == "walletGroup") { - walletGroupExpandAnimator?.removeAllListeners() - walletGroupExpandAnimator?.cancel() - walletGroupExpandAnimator = null - walletGroupRows.forEachIndexed { index, row -> - row.alpha = 1f - row.visibility = if (active && index > 0) GONE else VISIBLE - if (index == 0) row.setReorderActive(active) - } - minimumHeight = if (active) dp(68) else walletGroupExpandedHeightPx - layoutParams = layoutParams?.apply { - height = ViewGroup.LayoutParams.WRAP_CONTENT - } - background = restingRowBackground - requestLayout() - invalidate() - return - } - restoreRestingBackground() - } - - override fun canStartWalletGroupReorder(localY: Float): Boolean { - if ((tag as? NativeListItem)?.type != "walletGroup") return true - walletGroupRows.forEach { row -> - if (localY >= row.top && localY < row.bottom) { - return (row.tag as? NativeListItem)?.json?.optBoolean("draggable", true) != false - } - } - return true - } - - override fun finishWalletGroupReorder( - durationMs: Long, - interpolator: TimeInterpolator, - completion: (() -> Unit)?, - ) { - if ((tag as? NativeListItem)?.type != "walletGroup") { - setReorderActive(false) - completion?.invoke() - return - } - reorderActive = false - walletGroupRows.firstOrNull()?.setReorderActive(false) - val startHeight = height.coerceAtLeast(dp(68)) - val targetHeight = walletGroupExpandedHeightPx - minimumHeight = startHeight - layoutParams = layoutParams?.apply { height = startHeight } - walletGroupRows.drop(1).forEach { row -> - row.visibility = VISIBLE - row.alpha = 0f - } - background = restingRowBackground - if (targetHeight <= startHeight) { - walletGroupRows.forEach { row -> - row.visibility = VISIBLE - row.alpha = 1f - } - minimumHeight = targetHeight - layoutParams = layoutParams?.apply { height = ViewGroup.LayoutParams.WRAP_CONTENT } - requestLayout() - invalidate() - completion?.invoke() - return - } - walletGroupExpandAnimator = ValueAnimator.ofInt(startHeight, targetHeight).apply { - duration = durationMs - this.interpolator = interpolator - addUpdateListener { animator -> - val progress = animator.animatedFraction - layoutParams = layoutParams?.apply { height = animator.animatedValue as Int } - walletGroupRows.drop(1).forEach { it.alpha = progress } - requestLayout() - invalidate() - } - addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - walletGroupExpandAnimator = null - walletGroupRows.forEach { row -> - row.visibility = VISIBLE - row.alpha = 1f - } - minimumHeight = targetHeight - layoutParams = layoutParams?.apply { height = ViewGroup.LayoutParams.WRAP_CONTENT } - requestLayout() - invalidate() - postOnAnimation { - if (!reorderActive) { - walletGroupRows.forEach { row -> - row.visibility = VISIBLE - row.alpha = 1f - } - requestLayout() - invalidate() - } - } - completion?.invoke() - } - }) - start() - } - } - - private fun bindWalletGroup( - item: NativeListItem, - theme: JSONObject?, - layout: String, - listOrientation: String, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - ) { - orientation = VERTICAL - gravity = Gravity.CENTER_HORIZONTAL - setPadding(0, 0, 0, 0) - isClickable = false - setOnClickListener(null) - val members = mutableListOf() - members.add(item.json.getJSONObject("parent")) - item.json.getJSONArray("children").let { children -> - for (index in 0 until children.length()) { - members.add(children.getJSONObject(index)) - } - } - if (members.first().has("height")) setPadding(dp(1), dp(1), dp(1), dp(1)) - val childCount = members.size - 1 - walletGroupDragChildCount = members.drop(1).count { - it.optBoolean("draggable", true) - } - walletGroupExpandedHeightPx = - // OneKey patch: expanded groups include individual wallet badge heights. - // dp(members.size * 68 + walletGroupDragChildCount * 12) - styledHeightPixels(item) ?: (members.sumOf { member -> - styledHeightPixels(NativeListItem.parse(member)) - ?: dp(member.optInt("height", if ((member.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68)) - } + dp(childCount * 12 + if (members.first().has("height")) 2 else 0)) - walletGroupDragBadgeBackgroundPaint.color = color( - theme, - "inverseBackground", - "#000000DF", - ) - walletGroupDragBadgeBorderPaint.color = color( - theme, - "rowBackground", - "#FFFFFF", - ) - walletGroupDragBadgeBorderPaint.strokeWidth = dp(1).toFloat() - walletGroupDragBadgeTextPaint.color = color( - theme, - "inverseText", - "#FCFCFC", - ) - walletGroupDragBadgeTextPaint.textSize = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_SP, - sp(12f), - resources.displayMetrics, - ) - while (walletGroupRows.size < members.size) { - walletGroupRows.add(NativeListRowView(reactContext)) - } - members.forEachIndexed { index, memberJson -> - val member = NativeListItem.parse(memberJson) - val memberRow = walletGroupRows[index] - memberRow.onRowPress = { item, origin -> onRowPress?.invoke(item, origin) } - memberRow.onAction = { source, actionKey, target, origin -> - onAction?.invoke(source, actionKey, target, origin) - } - memberRow.onBindingInvalidated = { row, epoch -> - onBindingInvalidated?.invoke(row, epoch) - } - memberRow.listStyle = listStyle - memberRow.bind( - member, - theme, - layout, - listOrientation, - null, - memberJson.optBoolean("selected", false), - checkboxState, - useSourceScale = selectorUsesSourceScale, - ) - // OneKey patch: member geometry matches the outer group height calculation. - // memberRow.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp(68)).apply { - memberRow.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, styledHeightPixels(member) ?: dp(memberJson.optInt("height", if ((memberJson.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68))).apply { - if (index > 0) topMargin = dp(12) - } - addView(memberRow) - } - val fill = color(theme, "subduedBackground", "#F9F9F9") - val stroke = color(theme, "separator", "#0000001F") - restingRowBackground = GradientDrawable().apply { - setColor(fill) - setStroke(dp(1), stroke) - cornerRadius = dp(12).toFloat() - } - pressedRowBackground = restingRowBackground - background = restingRowBackground - } - - private fun bindIdentity( - item: NativeListItem, - theme: JSONObject?, - selected: Boolean, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - ) { - if (item.json.optString("presentation") == "walletSidebar") { - orientation = VERTICAL - gravity = Gravity.CENTER - setPadding(dp(4), dp(4), dp(4), dp(4)) - addLeading(item.json.optJSONObject("leading"), 40, spacingDp = 0) - leadingFallback.textSize = sp(28f) - leadingFallback.typeface = NativeListFonts.regular(context) - mainColumn.gravity = Gravity.CENTER - titleLine.gravity = Gravity.CENTER - titleLine.packsChildrenAtStart = false - titleLine.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) - // OneKey patch: the original wallet title ellipsizes within the full inner row width. - title.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) - title.gravity = Gravity.CENTER - // OneKey patch: this branch returns before the common identity ellipsis setup. - if (item.json.has("height")) title.ellipsize = TextUtils.TruncateAt.END - showText(title, item.json.optString("title"), 1) - title.setTextColor( - color( - theme, - if (selected) "primaryText" else "secondaryText", - if (selected) "#FFFFFFED" else "#FFFFFFAF", - ), - ) - addView( - mainColumn, - LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { - // OneKey patch: snap the source 4 + 40 + 4 sequence once instead of rounding each gap. - topMargin = if (item.json.has("height")) dp(48) - dp(44) else dp(4) - }, - ) - // OneKey patch: wallet tags are a centered line below the wallet name. - item.json.optJSONArray("badges")?.takeIf { it.length() > 0 }?.let { badges -> - val isSelector = item.json.has("height") - val badgeLineHeight = if (isSelector) 14 else 16 - val badgeHeight = badgeLineHeight + 4 - val line = LinearLayout(context).apply { orientation = HORIZONTAL; gravity = Gravity.CENTER } - for (index in 0 until badges.length()) { - val badge = NativeListTextView(context).apply { - text = badges.getJSONObject(index).optString("text") - textSize = sp(if (isSelector) 11f else 12f) - typeface = NativeListFonts.regular(context) - includeFontPadding = false - maxLines = 1 - ellipsize = TextUtils.TruncateAt.END - val warning = isSelector && badges.getJSONObject(index).optString("tone") == "warning" - setTextColor(color(theme, if (warning) "caution" else "secondaryText", if (warning) "#AB6400" else "#0000009B")) - background = roundedFill(color(theme, if (warning) "cautionBackground" else if (isSelector) "subduedBackground" else "strongBackground", if (warning) "#FFF4D5" else "#00000006"), 4f) - setPadding(dp(if (isSelector) 6 else 4), dp(2), dp(if (isSelector) 6 else 4), dp(2)) - } - TextViewCompat.setLineHeight(badge, dp(badgeLineHeight)) - semanticBadgeLabels.add(badge) - line.addView(badge, LayoutParams(LayoutParams.WRAP_CONTENT, dp(badgeHeight)).apply { if (index > 0) marginStart = dp(4) }) - } - walletBadgeLine = line - mainColumn.addView(line, LayoutParams(LayoutParams.WRAP_CONTENT, dp(badgeHeight)).apply { topMargin = dp(4) }) - selectorViews.add(line) - } - return - } - if (item.json.optString("presentation") == "networkSelector") { - // OneKey patch: the explicit 48-point row centers its 32-point network icon. - // setPadding(dp(12), dp(7), dp(12), dp(8)) - setPadding(dp(12), dp(if (item.json.has("height")) 8 else 7), dp(12), dp(8)) - } - val leading = item.json.optJSONObject("leading") - item.json.optJSONObject("leadingAction")?.let { action -> - leadingActionIcon.visibility = VISIBLE - leadingActionIcon.iconName = action.optString("name") - leadingActionIcon.glyphSizeDp = 24 - leadingActionIcon.tintColor = safeColor( - action.optString("tintColor"), - color(theme, "icon", "#0000009B"), - ) - leadingActionIcon.isEnabled = !action.optBoolean("disabled", false) - leadingActionIcon.alpha = if (leadingActionIcon.isEnabled) 1f else 0.4f - leadingActionIcon.setOnClickListener { - emitAction( - item, - action.optString("actionKey"), - null, - leadingActionIcon, - "leadingAction", - ) - } - // ListItem.IconButton is 36dp with 6dp inner padding and m=-7. Keep the - // full button frame but absorb its leading negative margin into the row - // padding and its trailing margin into the following gap. - setPadding(dp(5), paddingTop, paddingRight, paddingBottom) - addView(leadingActionIcon, LayoutParams(dp(36), dp(36)).apply { marginEnd = dp(5) }) - } - addLeading( - leading, - if ( - leading?.optString("kind") == "network" || - item.json.optString("presentation") == "accountSelector" - ) 32 else 40, - ) - // OneKey patch: custom network initials retain LetterAvatar typography. - if (item.json.optString("presentation") == "networkSelector" && leading?.optJSONObject("image") == null && leading?.optJSONObject("fallbackIcon") == null && !leading?.optString("fallbackText").isNullOrEmpty()) { - leadingFallback.textSize = sp(19f) - leadingFallback.typeface = NativeListFonts.semibold(context) - leadingFallback.setTextColor(color(theme, "inverseText", "#FCFCFC")) - TextViewCompat.setLineHeight(leadingFallback, dp(27)) - } - addView(mainColumn, weighted()) - titleLine.packsChildrenAtStart = true - title.ellipsize = TextUtils.TruncateAt.END - showText(title, item.json.optString("title"), item.json.optInt("titleLines", 1)) - if (item.json.optString("presentation") == "accountSelector") { - title.typeface = NativeListFonts.regular(context) - } - showText(subtitle, item.json.optString("subtitle"), item.json.optInt("subtitleLines", 2)) - // OneKey patch: use separate labels for independently truncated balance/address. - item.json.optJSONArray("subtitleSegments")?.takeIf { it.length() > 0 }?.let { segments -> - subtitle.visibility = GONE - val line = SelectorSubtitleLayout(context).apply { orientation = HORIZONTAL; gravity = Gravity.CENTER_VERTICAL } - for (index in 0 until segments.length()) { - val segment = segments.getJSONObject(index) - if (segment.optBoolean("separatorBefore", false)) { - val dot = View(context).apply { background = roundedFill(color(theme, "disabledText", "#00000072"), 2f) } - line.addView(dot, LayoutParams(dp(4), dp(4)).apply { marginStart = dp(6); marginEnd = dp(6) }) - } - val label = NativeListTextView(context).apply { - text = segment.optString("text") - typeface = NativeListFonts.regular(context) - textSize = sp(14f) - includeFontPadding = false - maxLines = 1 - ellipsize = TextUtils.TruncateAt.END - val toneKey = when (segment.optString("tone")) { "primary" -> "primaryText"; "disabled" -> "disabledText"; "caution" -> "caution"; "positive" -> "positive"; "negative" -> "negative"; else -> "secondaryText" } - setTextColor(color(theme, toneKey, if (toneKey == "caution") "#AB6400" else "#0000009B")) - } - TextViewCompat.setLineHeight(label, dp(20)) - applyValueSegments(label, segment.optJSONArray("textSegments"), 14, 20, false) - semanticSubtitleLabels.add(label) - line.addView(label, LayoutParams(LayoutParams.WRAP_CONTENT, dp(20))) - } - mainColumn.addView(line, 2, LayoutParams(LayoutParams.MATCH_PARENT, dp(20))) - selectorViews.add(line) - } - item.json.optJSONArray("titleMatch")?.takeIf { it.length() > 0 }?.let { matches -> - val highlighted = SpannableStringBuilder(title.text) - for (index in 0 until matches.length()) { - val match = matches.getJSONObject(index) - val start = match.optInt("start") - val end = match.optInt("end") - if (start >= 0 && end > start && end <= highlighted.length) highlighted.setSpan(ForegroundColorSpan(color(theme, "info", "#0D74CE")), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - } - title.text = highlighted - } - showText(tertiary, item.json.optString("tertiary"), 1) - tertiary.setTextColor( - color( - theme, - if (item.json.optString("tertiaryTone") == "info") "info" else "secondaryText", - if (item.json.optString("tertiaryTone") == "info") "#006DCBF2" else "#0000009B", - ), - ) - val badges = item.json.optJSONArray("badges") - if (badges != null && badges.length() > 0) { - val texts = (0 until minOf(2, badges.length())).map { badges.getJSONObject(it).optString("text") } - showText(badgeLine, texts.joinToString(" "), 1) - badgeLine.layoutParams = wrap().apply { marginStart = dp(8) } - badgeLine.setTextColor(color(theme, "secondaryText", "#0000009B")) - badgeLine.background = roundedFill(color(theme, "strongBackground", "#0000000F"), 4f) - badgeLine.setPadding(dp(8), dp(2), dp(8), dp(2)) - TextViewCompat.setLineHeight(badgeLine, dp(16)) - } - addView(trailingColumn, wrap()) - val accessories = item.json.optJSONArray("trailing") - if (item.json.has("height") && item.json.optString("presentation") == "networkSelector" && accessories.hasAccessory("checkbox")) { - // OneKey patch: retain ListItem's title-to-accessory gap when measuring truncation. - (mainColumn.layoutParams as LayoutParams).marginEnd = dp(12) - } - if (accessories.hasAccessory("checkbox") && accessories.hasAccessory("value")) { - trailingColumn.orientation = HORIZONTAL - trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL - checkbox.layoutParams = LayoutParams(dp(20), dp(20)).apply { marginStart = dp(12) } - } else if (accessories.isValuePairMenu()) { - trailingColumn.orientation = HORIZONTAL - trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL - } else if (accessories.isBookmarkEditActions()) { - trailingColumn.orientation = HORIZONTAL - trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL - } - bindAccessories(item, accessories, theme, checkboxState) - } - - - - private fun marketTypeface(weight: String, fallback: String): Typeface = when ( - weight.ifEmpty { fallback } - ) { - "regular" -> NativeListFonts.regular(context) - "semibold" -> NativeListFonts.semibold(context) - "bold" -> NativeListFonts.bold(context) - else -> NativeListFonts.medium(context) - } - - private fun applyMarketTextStyle( - view: TextView, - style: JSONObject?, - defaultSize: Float, - defaultLineHeight: Int, - defaultWeight: String, - defaultColor: Int, - defaultAlignment: String, - ) { - view.includeFontPadding = false - view.fontFeatureSettings = "tnum" - view.textSize = sp(style?.optDouble("fontSize", defaultSize.toDouble())?.toFloat() ?: defaultSize) - view.typeface = marketTypeface(style?.optString("fontWeight").orEmpty(), defaultWeight) - view.setTextColor(safeColor(style?.optString("color"), defaultColor)) - val alignment = style?.optString("alignment", defaultAlignment) ?: defaultAlignment - view.gravity = Gravity.CENTER_VERTICAL or when (alignment) { - "center" -> Gravity.CENTER_HORIZONTAL - "end" -> Gravity.END - else -> Gravity.START - } - view.maxLines = style?.optInt("lines", 1)?.coerceIn(1, 3) ?: 1 - view.ellipsize = TextUtils.TruncateAt.END - TextViewCompat.setLineHeight( - view, - dp(style?.optDouble("lineHeight", defaultLineHeight.toDouble())?.roundToInt() ?: defaultLineHeight), - ) - } - - // OneKey patch: opt-in Market text uses the same pixel rounding and line box as RN. - private fun applyMarketTextMetrics(view: TextView, style: JSONObject?) { - if (style == null || view.visibility != VISIBLE || view.text.isEmpty()) return - marketOriginalPaintFlags.putIfAbsent(view, view.paintFlags) - view.paintFlags = view.paintFlags or Paint.SUBPIXEL_TEXT_FLAG or Paint.LINEAR_TEXT_FLAG - if (style.has("fontSize")) { - val sourceSize = style.optDouble("fontSize").toFloat() * resources.displayMetrics.density - view.setTextSize(TypedValue.COMPLEX_UNIT_PX, kotlin.math.ceil(sourceSize.toDouble()).toFloat()) - } - val text = SpannableStringBuilder(view.text) - if (style.has("fontSize")) text.getSpans(0, text.length, AbsoluteSizeSpan::class.java).forEach(text::removeSpan) - text.getSpans(0, text.length, NativeListLineHeightSpan::class.java).forEach(text::removeSpan) - if (style.has("lineHeight")) { - val lineHeight = styleDp(style.optDouble("lineHeight")) - text.setSpan(NativeListLineHeightSpan(lineHeight), 0, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - view.setLineSpacing(0f, 1f) - } - view.text = text - val layout = JSONObject() - listOf("lines", "truncate", "verticalAlignment", "offsetY").forEach { key -> if (style.has(key)) layout.put(key, style.get(key)) } - if (layout.length() > 0) { - styledViewsDirty = true - applyStyledText(view, layout) - } - } - - private fun marketText(value: String, segments: JSONArray?, fontSize: Float): CharSequence { - if (segments == null || segments.length() == 0) return value - val result = SpannableStringBuilder() - for (index in 0 until segments.length()) { - val segment = segments.getJSONObject(index) - val start = result.length - result.append(segment.optString("text")) - if (segment.optString("style") == "subscript") { - result.setSpan( - AbsoluteSizeSpan(sp(kotlin.math.ceil(fontSize * 0.6f)).roundToInt(), true), - start, - result.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - } - } - return result - } - - private fun bindMarket(item: NativeListItem, theme: JSONObject?) { - // Network badges extend beyond the leading image's frame. - clipChildren = false - val variant = item.json.optString("variant") - val style = item.json.optJSONObject("style") - val imageStyle = style?.optJSONObject("image") - val imageWidth = imageStyle?.optDouble("width", if (variant == "stock") 40.0 else 32.0)?.roundToInt() - ?: if (variant == "stock") 40 else 32 - val imageHeight = imageStyle?.optDouble("height", if (variant == "stock") 40.0 else 32.0)?.roundToInt() - ?: if (variant == "stock") 40 else 32 - val horizontalPadding = style?.optDouble("horizontalPadding", if (variant == "perp") 16.0 else 20.0)?.roundToInt() - ?: if (variant == "perp") 16 else 20 - val verticalPadding = style?.optDouble("verticalPadding", 12.0)?.roundToInt() ?: 12 - val leadingGap = style?.optDouble("leadingGap", if (variant == "perp") 8.0 else 14.0)?.roundToInt() - ?: if (variant == "perp") 8 else 14 - setPadding(dp(horizontalPadding), dp(verticalPadding), dp(horizontalPadding), dp(verticalPadding)) - - item.json.optJSONObject("leadingAction")?.let { action -> - leadingActionIcon.visibility = VISIBLE - leadingActionIcon.iconName = action.optString("name") - leadingActionIcon.glyphSizeDp = 24 - leadingActionIcon.tintColor = safeColor( - action.optString("tintColor"), - color(theme, "icon", "#0000009B"), - ) - leadingActionIcon.isEnabled = !action.optBoolean("disabled", false) - leadingActionIcon.alpha = if (leadingActionIcon.isEnabled) 1f else 0.4f - leadingActionIcon.setTag( - com.facebook.react.R.id.react_test_id, - action.optString("testID").takeIf(String::isNotEmpty), - ) - leadingActionIcon.contentDescription = action.optString("accessibilityLabel") - leadingActionIcon.setOnClickListener { - emitAction( - item, - action.optString("actionKey"), - null, - leadingActionIcon, - "leadingAction", - ) - } - addView(leadingActionIcon, LayoutParams(dp(36), dp(36)).apply { marginEnd = dp(5) }) - } - - val leading = JSONObject(item.json.getJSONObject("leading").toString()) - imageStyle?.optString("shape")?.takeIf(String::isNotEmpty)?.let { leading.put("shape", it) } - imageStyle?.optString("contentFit")?.takeIf(String::isNotEmpty)?.let { contentFit -> - leading.optJSONObject("image")?.put("contentFit", contentFit) - } - val shape = imageStyle?.optString("shape", leading.optString("shape", "circle")) - ?: leading.optString("shape", "circle") - val cornerRadius = imageStyle?.takeIf { it.has("cornerRadius") }?.optDouble("cornerRadius")?.toFloat() - ?: when (shape) { - "square" -> 0f - "rounded" -> 8f - else -> minOf(imageWidth, imageHeight) / 2f - } - addLeading( - leading, - sizeDp = imageWidth, - spacingDp = leadingGap, - heightDp = imageHeight, - cornerRadiusDp = cornerRadius, - ) - - // OneKey patch: retain the source gap without changing other templates. - // addView(mainColumn, weighted()) - addView(mainColumn, weighted().apply { - marginEnd = dp(style?.optDouble("contentTrailingGap", 0.0)?.roundToInt() ?: 0) - }) - titleLine.packsChildrenAtStart = true - showText(title, item.json.optString("title"), style?.optJSONObject("title")?.optInt("lines", 1) ?: 1) - applyMarketTextStyle( - title, - style?.optJSONObject("title"), - 16f, - 24, - "medium", - color(theme, "primaryText", "#202020"), - "start", - ) - val badges = item.json.optJSONArray("badges") - val titleBadgeGap = style?.optDouble("titleBadgeGap", 4.0)?.roundToInt() ?: 4 - if (badges != null) { - for (index in 0 until minOf(marketBadgeViews.size, badges.length())) { - val badge = badges.getJSONObject(index) - val badgeView = marketBadgeViews[index] - val badgeLabel = marketBadgeLabels[index] - val badgeStyle = badge.optJSONObject("style") - val hasGlyph = badge.optString("iconName") == "verified" - val remoteIcon = badge.optJSONObject("icon") - val hasIcon = hasGlyph || remoteIcon != null - val text = badge.optString("text") - val toneColor = when (badge.optString("tone")) { - "success" -> color(theme, "positive", "#218358") - "danger" -> color(theme, "negative", "#CE2C31") - "info" -> color(theme, "info", "#0D74CE") - "warning" -> color(theme, "primaryText", "#202020") - else -> color(theme, "secondaryText", "#646464") - } - val foreground = safeColor(badge.optString("textColor"), toneColor) - badgeView.visibility = VISIBLE - val iconOnly = hasIcon && text.isEmpty() - // OneKey patch: Market callers can request the original badge padding. - val padding = badgeStyle?.optDouble("horizontalPadding", 5.0)?.roundToInt() ?: 5 - val leftPadding = if (badgeStyle?.has("horizontalPadding") == true) padding else if (hasIcon) 2 else 5 - // badgeView.setPadding(dp(if (iconOnly) 0 else if (hasIcon) 2 else 5), 0, dp(if (iconOnly) 0 else 5), 0) - badgeView.setPadding(dp(if (iconOnly) 0 else leftPadding), 0, dp(if (iconOnly) 0 else padding), 0) - badgeView.background = roundedFill( - safeColor( - badge.optString("backgroundColor"), - if (hasIcon && text.isEmpty()) Color.TRANSPARENT else color(theme, "strongBackground", "#0000000F"), - ), - 4f, - ).apply { - if (badgeStyle != null) this.cornerRadius = 4f * resources.displayMetrics.density - } - badgeLabel.text = text - // OneKey patch: match SizableText's tabular numerals only for explicit Market metrics. - badgeLabel.fontFeatureSettings = if (badgeStyle != null) "tnum" else null - badgeLabel.textSize = sp(badgeStyle?.optDouble("fontSize", 11.0)?.toFloat() ?: 11f) - badgeLabel.typeface = marketTypeface(badgeStyle?.optString("fontWeight").orEmpty(), "medium") - if (badgeStyle?.has("lineHeight") == true) { - TextViewCompat.setLineHeight(badgeLabel, dp(badgeStyle.optDouble("lineHeight").roundToInt())) - } - badgeLabel.setTextColor(foreground) - badgeLabel.visibility = if (text.isEmpty()) GONE else VISIBLE - if (hasGlyph) { - marketBadgeGlyphs[index].apply { - iconName = "BadgeVerifiedSolid" - tintColor = foreground - visibility = VISIBLE - } - } - remoteIcon?.let { icon -> - marketBadgeImages[index].visibility = VISIBLE - marketBadgeImages[index].outlineProvider = circleOutlineProvider - marketBadgeImages[index].clipToOutline = true - bindImage(icon, marketBadgeImages[index], item.key, 20 + index, "generic") - } - val actionKey = badge.optString("actionKey") - badgeView.isClickable = actionKey.isNotEmpty() - if (actionKey.isNotEmpty()) { - badgeView.setOnClickListener { - emitAction(item, actionKey, null, badgeView, "marketBadge", index) - } - } - badgeView.contentDescription = badge.optString("accessibilityLabel", text) - titleLine.addView( - badgeView, - // OneKey patch: preserve 18dp unless the Market row opts into source metrics. - // LayoutParams(LayoutParams.WRAP_CONTENT, dp(18)).apply { marginStart = dp(titleBadgeGap) }, - LayoutParams(LayoutParams.WRAP_CONTENT, dp(badgeStyle?.optDouble("height", 18.0)?.roundToInt() ?: 18)).apply { marginStart = dp(titleBadgeGap) }, - ) - } - } - subtitle.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { - topMargin = dp(style?.optDouble("lineGap", 0.0)?.roundToInt() ?: 0) - } - val subtitleText = item.json.optString("subtitle") - val subtitleSegments = item.json.optJSONArray("subtitleSegments") - if (subtitleText.isNotEmpty() || (subtitleSegments?.length() ?: 0) > 0) { - subtitle.visibility = VISIBLE - applyMarketTextStyle( - subtitle, - style?.optJSONObject("subtitle"), - 14f, - 20, - "regular", - color(theme, "secondaryText", "#646464"), - "start", - ) - subtitle.text = marketText( - subtitleText, - subtitleSegments, - style?.optJSONObject("subtitle")?.optDouble("fontSize", 14.0)?.toFloat() ?: 14f, - ) - } - // OneKey patch: preserve independent name metrics, truncation, and volume. - val subtitlePrefix = item.json.optJSONObject("subtitlePrefix") - val subtitlePadding = style?.optDouble("subtitleTrailingPadding", 0.0)?.roundToInt() ?: 0 - if (subtitlePrefix != null || subtitlePadding > 0) { - mainColumn.removeView(subtitle) - mainColumn.removeView(tertiary) - marketSubtitleLine.orientation = HORIZONTAL - marketSubtitleLine.gravity = Gravity.CENTER_VERTICAL - marketSubtitleLine.packsChildrenAtStart = true - marketSubtitleLine.leadingTextMaxWidth = subtitlePrefix?.takeIf { it.has("maxWidth") } - ?.optDouble("maxWidth")?.roundToInt()?.let(::dp) ?: Int.MAX_VALUE - marketSubtitleLine.setPadding(0, 0, dp(subtitlePadding), 0) - showText(tertiary, subtitlePrefix?.optString("text") ?: "", 1) - applyMarketTextStyle(tertiary, subtitlePrefix?.optJSONObject("style"), 12f, 16, "regular", color(theme, "secondaryText", "#646464"), "start") - marketSubtitleLine.addView(tertiary, wrap()) - marketSubtitleLine.addView(subtitle, wrap().apply { - if (tertiary.visibility == VISIBLE && subtitle.visibility == VISIBLE) { - marginStart = dp(subtitlePrefix?.optDouble("gap", 4.0)?.roundToInt() ?: 4) - } - }) - mainColumn.addView(marketSubtitleLine, 1, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { - topMargin = dp(style?.optDouble("lineGap", 0.0)?.roundToInt() ?: 0) - }) - marketSubtitleLine.visibility = if (tertiary.visibility == VISIBLE || subtitle.visibility == VISIBLE) VISIBLE else GONE - } - trailingColumn.orientation = HORIZONTAL - trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL - addView(trailingColumn, wrap()) - bindMarketQuote(item, theme) - item.json.optJSONObject("diagnostics")?.optString("imageBindActionKey") - ?.takeIf(String::isNotEmpty) - ?.takeIf { leading.optJSONObject("image") != null || leading.optJSONObject("networkImage") != null } - ?.let { actionKey -> onAction?.invoke(item, actionKey, null, null) } - } - - override fun bindMarketQuote(item: NativeListItem, theme: JSONObject?) { - if (boundKey != item.key || item.type != "market") return - tag = item - val style = item.json.optJSONObject("style") - val priceStyle = style?.optJSONObject("price") - val price = trailingViews[0] - price.isClickable = false - price.isLongClickable = false - price.visibility = VISIBLE - price.maxWidth = dp(112) - applyMarketTextStyle( - price, - priceStyle, - 16f, - 24, - "medium", - color(theme, "primaryText", "#202020"), - "end", - ) - price.text = marketText( - item.json.optString("price"), - item.json.optJSONArray("priceSegments"), - priceStyle?.optDouble("fontSize", 16.0)?.toFloat() ?: 16f, - ) - val trailingGap = style?.optDouble("trailingGap", 8.0)?.roundToInt() ?: 8 - price.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { - marginEnd = dp(trailingGap) - } - - val changeData = item.json.getJSONObject("change") - val changeStyle = style?.optJSONObject("change") - val change = trailingViews[1] - change.isClickable = false - change.isLongClickable = false - val toneColor = when (changeData.optString("tone")) { - "positive" -> color(theme, "positive", "#218358") - "negative" -> color(theme, "negative", "#CE2C31") - else -> color(theme, "secondaryText", "#8D8D8D") - } - val textColor = safeColor( - changeData.optString("textColor"), - color(theme, "inverseText", "#FFFFFF"), - ) - change.visibility = VISIBLE - applyMarketTextStyle(change, changeStyle, 14f, 20, "medium", textColor, "center") - change.text = marketText( - changeData.optString("text"), - changeData.optJSONArray("textSegments"), - changeStyle?.optDouble("fontSize", 14.0)?.toFloat() ?: 14f, - ) - change.background = roundedFill( - safeColor(changeData.optString("backgroundColor"), toneColor), - style?.optDouble("changeCornerRadius", 8.0)?.toFloat() ?: 8f, - ) - change.layoutParams = LayoutParams( - dp(style?.optDouble("changeWidth", 80.0)?.roundToInt() ?: 80), - dp(style?.optDouble("changeHeight", 32.0)?.roundToInt() ?: 32), - ) - applyMarketTextMetrics(title, style?.optJSONObject("title")) - applyMarketTextMetrics(subtitle, style?.optJSONObject("subtitle")) - applyMarketTextMetrics(tertiary, item.json.optJSONObject("subtitlePrefix")?.optJSONObject("style")) - applyMarketTextMetrics(price, priceStyle) - applyMarketTextMetrics(change, changeStyle) - val badges = item.json.optJSONArray("badges") - marketBadgeLabels.forEachIndexed { index, label -> - val badge = badges?.optJSONObject(index) - val badgeStyle = badge?.optJSONObject("style") - applyMarketTextMetrics(label, badgeStyle) - if (badge != null && label.visibility == VISIBLE && badgeStyle?.has("horizontalPadding") == true && - badge.optJSONObject("icon") == null && badge.optString("iconName").isEmpty()) { - // OneKey patch: match RN's intrinsic text width and one rounded padding pair. - label.layoutParams = wrap().apply { width = label.paint.measureText(label.text.toString()).roundToInt() } - val padding = badgeStyle.optDouble("horizontalPadding") * resources.displayMetrics.density - marketBadgeViews[index].setPadding(kotlin.math.floor(padding).toInt(), 0, (padding * 2).roundToInt() - kotlin.math.floor(padding).toInt(), 0) - } - } - contentDescription = item.json.optString( - "accessibilityLabel", - listOf( - item.json.optString("title"), - item.json.optString("subtitle"), - item.json.optString("price"), - changeData.optString("text"), - ).filter(String::isNotEmpty).joinToString(", "), - ) - } - - private fun dataTextColor(tone: String, theme: JSONObject?): Int = when (tone.ifEmpty { "primary" }) { - "secondary" -> color(theme, "secondaryText", "#0000009B") - "positive" -> color(theme, "positive", "#00713FDE") - "negative" -> color(theme, "negative", "#C40006D3") - else -> color(theme, "primaryText", "#000000DF") - } - - private fun bindSectionHeader( - item: NativeListItem, - theme: JSONObject?, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - ) { - val variant = item.json.optString("variant") - val isSummary = variant == "summary" - val isGallery = variant == "gallery" - val isTable = currentLayout == "table" - // OneKey patch: title help emits its own frame instead of toggling the section. - item.json.optString("titleActionKey").takeIf { it.isNotEmpty() }?.let { key -> - title.setOnClickListener { emitAction(item, key, null, title, "leadingAction") } - } - val isNetworkSelector = item.json.optString("presentation") == "networkSelector" - val isHistory = variant == "history" || item.sectionKey?.startsWith("history-") == true - val isTokenManager = item.sectionKey in setOf("linear-tokens", "action-tokens") - val isExplicitNetworkHeader = isNetworkSelector && item.json.has("height") - val hasDottedTitle = isSummary || (isNetworkSelector && (!isExplicitNetworkHeader || item.json.optString("titleActionKey").isNotEmpty())) || - (item.json.optString("value").isNotEmpty() && item.json.optJSONObject("checkbox") != null) - // Linear/sectioned snapshots reserve the ListItem mx=8 at RecyclerView - // level, so header-local insets below are source px minus that outer inset. - val headerHorizontalInset = 12 - addView(mainColumn, weighted()) - showText(title, item.json.optString("title"), 1) - if (hasDottedTitle) { - title.showsDottedUnderline = true - title.dottedUnderlineColor = color(theme, "secondaryText", "#0000009B") - title.setPadding(0, 0, 0, dp(3)) - } - title.setTextColor( - color( - theme, - if (isSummary || isGallery) "primaryText" else "secondaryText", - if (isSummary || isGallery) "#000000DF" else "#0000009B", - ), - ) - if (isNetworkSelector) { - val verticalInset = if (isExplicitNetworkHeader && item.json.optString("titleActionKey").isEmpty()) 8 else 12 - setPadding(dp(headerHorizontalInset), dp(verticalInset), dp(headerHorizontalInset), dp(verticalInset)) - title.textSize = sp(14f) - title.typeface = NativeListFonts.medium(context) - TextViewCompat.setLineHeight(title, dp(20)) - } else if (isHistory) { - setPadding(0, 0, 0, 0) - title.text = item.json.optString("title").uppercase() - title.textSize = sp(12f) - title.typeface = NativeListFonts.semibold(context) - title.letterSpacing = 0.8f / sp(12f) - TextViewCompat.setLineHeight(title, dp(16)) - } else if (isTokenManager) { - setPadding(dp(headerHorizontalInset), dp(10), dp(headerHorizontalInset), 0) - title.textSize = sp(14f) - title.typeface = NativeListFonts.regular(context) - TextViewCompat.setLineHeight(title, dp(20)) - } else if (!isTable && !isGallery && !isSummary) { - // SectionList.SectionHeader: h=36, px=20, headingSm 14/20 semibold. - setPadding(dp(headerHorizontalInset), dp(8), dp(headerHorizontalInset), dp(8)) - TextViewCompat.setLineHeight(title, dp(20)) - } else if (isTable) { - setPadding(dp(16), dp(12), dp(16), dp(2)) - title.includeFontPadding = false - title.layoutParams = wrap() - titleLine.layoutParams = wrap() - item.json.optJSONObject("titleIcon")?.let { icon -> - headerTitleIcon.visibility = VISIBLE - headerTitleIcon.iconName = icon.optString("name") - headerTitleIcon.tintColor = safeColor( - icon.optString("tintColor"), - color(theme, "iconSubdued", "#00000072"), - ) - titleLine.addView(headerTitleIcon, LayoutParams(dp(12), dp(12)).apply { - marginStart = dp(4) - }) - if (icon.optString("name") != "ChevronGrabberVerOutline") { - title.setTextColor(color(theme, "primaryText", "#000000DF")) - } - } - } - showText(subtitle, item.json.optString("subtitle"), 1) - if (isGallery) { - setPadding(dp(12), 0, dp(12), dp(8)) - } else if (isSummary) { - addView(trailingColumn, wrap()) - // NetworkListHeader: outer mt=16/pb=12, inner XStack px=20/py=8. - setPadding(dp(headerHorizontalInset), dp(24), dp(headerHorizontalInset), dp(20)) - showTrailing( - 0, - item.json.optString("value"), - true, - item.json.optString("valueActionKey"), - color(theme, "secondaryText", "#0000009B"), - ) - trailingViews[0].setTag(com.facebook.react.R.id.react_test_id, item.json.optString("valueActionTestID").takeIf { it.isNotEmpty() }) - trailingViews[0].accessibilityDelegate = selectorAccessibilityDelegate - trailingViews[0].textSize = sp(16f) - trailingViews[0].typeface = NativeListFonts.medium(context) - TextViewCompat.setLineHeight(trailingViews[0], dp(24)) - // OneKey patch: the migrated media button shares the original 24-point text box. - if (isExplicitNetworkHeader) trailingViews[0].setPadding(0, 0, 0, 0) - else trailingViews[0].setPadding(dp(14), dp(6), dp(14), dp(6)) - trailingViews[0].layoutParams = wrap() - } else if (!isGallery && !isHistory && !isTokenManager) { - val value = item.json.optString("value") - val checkboxData = item.json.optJSONObject("checkbox") - if (isExplicitNetworkHeader && checkboxData != null) { - // OneKey patch: the asset section title reserves its original 8dp trailing margin. - (mainColumn.layoutParams as LayoutParams).marginEnd = dp(8) - } - if (checkboxData != null && value.isNotEmpty()) { - // Value and checkbox share the trailing edge as one compound accessory. - trailingColumn.orientation = HORIZONTAL - trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL - trailingViews[0].layoutParams = wrap() - checkbox.layoutParams = LayoutParams(dp(20), dp(20)).apply { marginStart = dp(12) } - } - addView(trailingColumn, wrap()) - showTrailing(0, value, true) - if (isTable) { - trailingColumn.orientation = HORIZONTAL - trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL - trailingViews[0].includeFontPadding = false - trailingViews[0].textSize = sp(11f) - trailingViews[0].typeface = NativeListFonts.regular(context) - trailingViews[0].setTextColor(color(theme, "secondaryText", "#0000009B")) - item.json.optJSONObject("valueIcon")?.let { icon -> - headerValueIcon.visibility = VISIBLE - headerValueIcon.iconName = icon.optString("name") - headerValueIcon.tintColor = safeColor( - icon.optString("tintColor"), - color(theme, "iconSubdued", "#00000072"), - ) - trailingColumn.addView( - headerValueIcon, - LayoutParams(dp(12), dp(12)).apply { marginStart = dp(4) }, - ) - if (icon.optString("name") != "ChevronGrabberVerOutline") { - trailingViews[0].setTextColor(color(theme, "primaryText", "#000000DF")) - } - } - } - checkboxData?.let { bindCheckbox(item, it, checkboxState) } - } - applyValueSegments(trailingViews[0], item.json.optJSONArray("valueSegments")) - } - - // OneKey patch: preserve compact zero-count digits without changing their baseline. - private fun applyValueSegments(view: TextView, segments: JSONArray?, fontSize: Int = 16, lineHeight: Int = 24, medium: Boolean = true) { - if (segments == null || segments.length() == 0) return - val value = SpannableStringBuilder() - for (index in 0 until segments.length()) { - val segment = segments.getJSONObject(index) - val start = value.length - value.append(segment.optString("text")) - if (segment.optString("style") == "subscript") { - val size = kotlin.math.ceil(fontSize * 0.6).toFloat() - val span = if (selectorUsesSourceScale) AbsoluteSizeSpan(kotlin.math.ceil((size * resources.displayMetrics.density).toDouble()).toInt(), false) else AbsoluteSizeSpan(sp(size).roundToInt(), true) - value.setSpan(span, start, value.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - } - } - view.textSize = sp(fontSize.toFloat()) - view.typeface = if (medium) NativeListFonts.medium(context) else NativeListFonts.regular(context) - TextViewCompat.setLineHeight(view, dp(lineHeight)) - view.text = value - } - - - - private fun addLeading( - visual: JSONObject?, - sizeDp: Int = 40, - secondaryVisual: JSONObject? = null, - spacingDp: Int = 12, - heightDp: Int = sizeDp, - cornerRadiusDp: Float? = null, - ) { - val imageStyle = (tag as? NativeListItem)?.json?.optJSONObject("style")?.optJSONObject("image") - val styledWidthDp = imageStyle?.optDouble("width", sizeDp.toDouble())?.roundToInt() ?: sizeDp - val styledHeightDp = imageStyle?.optDouble("height", heightDp.toDouble())?.roundToInt() ?: heightDp - leadingFrame.visibility = VISIBLE - leadingFrame.layoutParams = LayoutParams(dp(styledWidthDp), dp(styledHeightDp)).apply { - val item = tag as? NativeListItem - // OneKey patch: Yoga rounds cumulative selector edges, not each 12dp gap separately. - marginEnd = if (item?.json?.has("height") == true && item.json.optString("presentation") in setOf("accountSelector", "networkSelector")) dp(12 + styledWidthDp + spacingDp) - dp(12) - dp(styledWidthDp) else dp(spacingDp) - } - addView(leadingFrame) - leadingFallback.layoutParams = FrameLayout.LayoutParams(dp(styledWidthDp), dp(styledHeightDp)) - if (visual == null) return - val kind = visual.optString("kind") - val shape = visual.optString( - "shape", - if (kind == "image") "rounded" else "circle", - ) - val sources = visualSources(visual).toMutableList() - secondaryVisual?.let { sources.addAll(visualSources(it).take(1)) } - val isIcon = kind == "icon" - val cornerIconData = visual.optJSONObject("cornerIcon") - val fallback = visual.optString("fallbackText").take(2) - val fallbackIconData = visual.optJSONObject("fallbackIcon") - val fallbackIconSizeDp = fallbackIconData?.let { - val slotSizeDp = minOf(styledWidthDp, styledHeightDp) - if (it.optString("name") == "GlobusOutline") { - (slotSizeDp * 1.2f).roundToInt() - } else { - slotSizeDp - } - } - val sourceLoadingStrategy = sources.firstOrNull()?.first?.optString("loadingStrategy", "none") ?: "none" - val showsSourcePlaceholder = !isIcon && sources.isNotEmpty() && sourceLoadingStrategy != "none" - val handlesSourceFallback = - !isIcon && sources.isNotEmpty() && - showsSourcePlaceholder && - (visual.has("fallbackText") || fallbackIconData != null) - val sourceFallbackKey = if (handlesSourceFallback) { - sources.firstOrNull()?.first?.let(::sourceFallbackStateKey) - } else null - val restoresSourceFallback = - sourceFallbackKey?.let(NativeListSourceFallbackState::has) == true - leadingFallback.text = - if (handlesSourceFallback && !restoresSourceFallback) "" else fallback - leadingFallback.setTextColor(parseNativeListColor("#00000072")) - val visualBackground = safeColor( - visual.optString("backgroundColor"), - // OneKey patch: source-backed visuals default to no placeholder/background. - if (!isIcon && sources.isNotEmpty()) Color.TRANSPARENT else parseNativeListColor(imagePlaceholderColor), - ) - if (!isIcon && visual.optString("backgroundColor").isNotEmpty()) { - leadingFrame.background = roundedFill( - visualBackground, - cornerRadiusDp ?: leadingCornerRadius(shape, minOf(styledWidthDp, styledHeightDp)), - ) - } - leadingFallback.background = roundedFill( - if (handlesSourceFallback) parseNativeListColor(imagePlaceholderColor) else visualBackground, - cornerRadiusDp ?: leadingCornerRadius(shape, minOf(styledWidthDp, styledHeightDp)), - ) - leadingFallback.visibility = - if (!isIcon && (sources.isEmpty() || handlesSourceFallback)) VISIBLE else GONE - if (handlesSourceFallback && fallbackIconData != null) { - leadingIcon.iconName = fallbackIconData.optString("name") - leadingIcon.tintColor = safeColor( - fallbackIconData.optString("tintColor"), - parseNativeListColor("#0000009B"), - ) - leadingIcon.layoutParams = FrameLayout.LayoutParams( - dp(fallbackIconSizeDp ?: styledWidthDp), - dp(fallbackIconSizeDp ?: styledHeightDp), - Gravity.CENTER, - ) - leadingIcon.glyphSizeDp = fallbackIconSizeDp - // Keep the fallback measured while the source loads so an asynchronous - // failure can reveal it without waiting for another RecyclerView layout. - leadingFallback.visibility = if (restoresSourceFallback) GONE else VISIBLE - leadingIcon.visibility = if (restoresSourceFallback) VISIBLE else INVISIBLE - } - if (isIcon) { - leadingFrame.background = GradientDrawable().apply { - setColor(visualBackground) - setStroke(1, parseNativeListColor("#0000001F")) - cornerRadius = scaledDp(cornerRadiusDp ?: leadingCornerRadius(shape, minOf(styledWidthDp, styledHeightDp))) - } - leadingIcon.iconName = visual.optString("name") - leadingIcon.tintColor = safeColor( - visual.optString("tintColor"), - parseNativeListColor("#0000009B"), - ) - leadingIcon.visibility = VISIBLE - } else if (sources.isEmpty() && fallbackIconData != null) { - leadingFallback.visibility = GONE - leadingIcon.iconName = fallbackIconData.optString("name") - leadingIcon.tintColor = safeColor( - fallbackIconData.optString("tintColor"), - parseNativeListColor("#0000009B"), - ) - leadingIcon.layoutParams = FrameLayout.LayoutParams( - dp(fallbackIconSizeDp ?: styledWidthDp), - dp(fallbackIconSizeDp ?: styledHeightDp), - Gravity.CENTER, - ) - leadingIcon.glyphSizeDp = fallbackIconSizeDp - leadingIcon.visibility = VISIBLE - } - val visibleSources = sources.take(leadingImages.size) - val tokenPair = kind == "token" && visibleSources.size > 1 - val hasAccountSelectorBadge = - kind == "account" && - (tag as? NativeListItem)?.json?.optString("presentation") == "accountSelector" && - (visual.optJSONArray("overlays")?.length() ?: 0) > 0 - if (tokenPair || hasAccountSelectorBadge) { - // Network badges intentionally extend past the avatar frame. - clipChildren = false - } - if (tokenPair) { - leadingOverlayBackground.visibility = VISIBLE - leadingOverlayBackground.background = roundedFill(visualBackdropColor, 10f) - val item = tag as? NativeListItem - val marketPadding = if (item?.type == "market") item.json.optJSONObject("style")?.optDouble("horizontalPadding", 20.0) ?: 20.0 else null - val density = resources.displayMetrics.density - // Yoga rounds the absolute horizontal edges, including the avatar's fractional origin. - val badgeRight = marketPadding?.let { ((it + styledWidthDp + 4) * density).roundToInt() } - val badgeLeft = marketPadding?.let { ((it + styledWidthDp - 16) * density).roundToInt() } - val avatarRight = marketPadding?.let { ((it + styledWidthDp) * density).roundToInt() } - leadingOverlayBackground.layoutParams = FrameLayout.LayoutParams( - if (badgeRight != null && badgeLeft != null) badgeRight - badgeLeft else dp(20), - dp(20), - Gravity.END or Gravity.BOTTOM, - ).apply { - marginEnd = if (badgeRight != null && avatarRight != null) avatarRight - badgeRight else -dp(4) - bottomMargin = -dp(4) - } - } - if (cornerIconData != null) { - leadingCornerIconFrame.visibility = VISIBLE - leadingCornerIconFrame.background = roundedFill( - safeColor(cornerIconData.optString("backgroundColor"), visualBackdropColor), - 10f, - ) - leadingCornerIconFrame.layoutParams = FrameLayout.LayoutParams( - dp(20), - dp(20), - Gravity.END or Gravity.BOTTOM, - ).apply { - marginEnd = -dp(4) - bottomMargin = -dp(4) - } - leadingCornerIcon.iconName = cornerIconData.optString("name") - leadingCornerIcon.tintColor = safeColor( - cornerIconData.optString("tintColor"), - parseNativeListColor("#0000009B"), - ) - } - visibleSources.forEachIndexed { index, (source, variant) -> - val image = leadingImages[index] - // OneKey patch: Decorative badges and source-owned fallbacks stay hidden until loaded. - // image.visibility = VISIBLE - val ownsSourceFallback = index == 0 && handlesSourceFallback - image.visibility = if (index > 0 || ownsSourceFallback) INVISIBLE else VISIBLE - image.layoutParams = leadingImageLayout( - index = index, - count = visibleSources.size, - sizeDp = styledWidthDp, - heightDp = styledHeightDp, - tokenPair = tokenPair, - ) - image.outlineProvider = when { - tokenPair && index == 1 -> circleOutlineProvider - cornerRadiusDp != null -> roundedOutlineProvider(cornerRadiusDp) - else -> leadingOutlineProvider(shape) - } - image.clipToOutline = true - if ((tag as? NativeListItem)?.type == "market" && index == 0 && visual.optString("borderColor").isNotEmpty()) { - val inset = dp(1) - val density = resources.displayMetrics.density - val sourceRadius = cornerRadiusDp ?: leadingCornerRadius(shape, minOf(styledWidthDp, styledHeightDp)) - // OneKey patch: retain the source's fractional border and background rendering. - leadingFrame.background = null - BackgroundStyleApplicator.setBackgroundColor(leadingFrame, visualBackground) - // Match the source's physical edges: React Native rasterizes ALL as a different stroke path. - for (edge in arrayOf(LogicalEdge.LEFT, LogicalEdge.TOP, LogicalEdge.RIGHT, LogicalEdge.BOTTOM)) { - BackgroundStyleApplicator.setBorderWidth(leadingFrame, edge, 1f) - BackgroundStyleApplicator.setBorderColor(leadingFrame, edge, safeColor(visual.optString("borderColor"), Color.TRANSPARENT)) - } - BackgroundStyleApplicator.setBorderRadius(leadingFrame, BorderRadiusProp.BORDER_RADIUS, LengthPercentage(sourceRadius, LengthPercentageType.POINT)) - // OneKey patch: round the source image's absolute edges inside its border. - val sourceLeft = ((tag as? NativeListItem)?.json?.optJSONObject("style")?.optDouble("horizontalPadding", 20.0) ?: 20.0) * density - val imageWidth = (sourceLeft + (styledWidthDp - 1) * density).roundToInt() - (sourceLeft + density).roundToInt() - image.layoutParams = FrameLayout.LayoutParams(imageWidth, dp(styledHeightDp - 2)).apply { - leftMargin = inset - topMargin = inset - } - marketLeadingUsesSourceClip = true - image.clipToOutline = false - } - val fallbackIcon = if (index == 0) fallbackIconData else null - val expectedEpoch = bindingEpoch - bindImage(source, image, boundKey ?: "", index, variant, - round = shape == "circle" && (imageStyle == null || (!imageStyle.has("shape") && !imageStyle.has("cornerRadius"))), - onLoad = if (!ownsSourceFallback) null else ({ - if (bindingEpoch == expectedEpoch) { - sourceFallbackKey?.let(NativeListSourceFallbackState::forget) - image.visibility = VISIBLE - leadingFallback.visibility = GONE - leadingIcon.visibility = GONE - } - }), - onError = if (!ownsSourceFallback) null else ({ - if (bindingEpoch == expectedEpoch) { - sourceFallbackKey?.let(NativeListSourceFallbackState::remember) - image.visibility = GONE - if (fallbackIcon != null) { - leadingFallback.visibility = GONE - leadingIcon.iconName = fallbackIcon.optString("name") - leadingIcon.tintColor = safeColor(fallbackIcon.optString("tintColor"), parseNativeListColor("#0000009B")) - leadingIcon.visibility = VISIBLE - } else { - leadingIcon.visibility = GONE - leadingFallback.text = fallback - leadingFallback.visibility = VISIBLE - } - } - }), - hideUntilLoaded = index > 0 || ownsSourceFallback, - ) - } - // OneKey patch: wallet overlays retain source images, provider colors and QR text. - visual.optJSONArray("overlays")?.let { overlays -> - for (index in 0 until overlays.length()) { - val overlay = overlays.getJSONObject(index) - val size = overlay.optInt("size", 20) - val inset = dp(overlay.optInt("padding", 0)) - val isWalletText = selectorUsesSourceScale && (tag as? NativeListItem)?.json?.optString("presentation") == "walletSidebar" && overlay.optString("text").isNotEmpty() && overlay.optJSONObject("image") == null && overlay.optString("name").isEmpty() - val width = overlay.optInt("width", size) - val height = overlay.optInt("height", if (isWalletText) 16 else size) - val offsetX = dp(overlay.optInt("offsetX", overlay.optInt("offset", 2))) - val offsetY = dp(overlay.optInt("offsetY", overlay.optInt("offset", 2))) - val frame = FrameLayout(context).apply { - setPadding(if (isWalletText) dp(2) else inset, if (isWalletText) 0 else inset, if (isWalletText) dp(2) else inset, if (isWalletText) 0 else inset) - background = roundedFill(safeColor(overlay.optString("backgroundColor"), Color.TRANSPARENT), minOf(width, height) / 2f) - outlineProvider = ViewOutlineProvider.BACKGROUND - clipToOutline = true - } - val image = overlay.optJSONObject("image") - val view = when { - image != null -> (selectorImages.getOrNull(index) ?: OneKeyImageReusableView(reactContext).also(selectorImages::add)).also { - bindImage(image, it, boundKey ?: "", 10 + index, "generic", hideUntilLoaded = true) - } - overlay.optString("text").isNotEmpty() -> TextView(context).apply { - text = overlay.optString("text") - textSize = sp(if (isWalletText) 12f else 10f) - typeface = if (isWalletText) NativeListFonts.regular(context) else NativeListFonts.medium(context) - includeFontPadding = false - gravity = Gravity.CENTER - if (isWalletText) TextViewCompat.setLineHeight(this, dp(16)) - setTextColor(safeColor(overlay.optString("tintColor"), color(null, "secondaryText", "#0000009B"))) - } - else -> OneKeyIconView(context).apply { - iconName = overlay.optString("name") - tintColor = safeColor(overlay.optString("tintColor"), parseNativeListColor("#0000009B")) - } - } - frame.addView(view, FrameLayout.LayoutParams(if (isWalletText && !overlay.has("width")) FrameLayout.LayoutParams.WRAP_CONTENT else FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) - val topLeft = overlay.optString("position") == "topLeft" - leadingFrame.addView(frame, FrameLayout.LayoutParams(if (isWalletText && !overlay.has("width")) FrameLayout.LayoutParams.WRAP_CONTENT else dp(width), dp(height), if (topLeft) Gravity.START or Gravity.TOP else Gravity.END or Gravity.BOTTOM).apply { - if (topLeft) { marginStart = -offsetX; topMargin = -offsetY } else { marginEnd = -offsetX; bottomMargin = -offsetY } - }) - selectorViews.add(frame) - } - } - visual.optJSONObject("fallbackIcon")?.takeIf { sources.isEmpty() }?.let { fallbackIcon -> - leadingFallback.visibility = GONE - leadingIcon.visibility = VISIBLE - leadingIcon.iconName = fallbackIcon.optString("name") - leadingIcon.tintColor = safeColor(fallbackIcon.optString("tintColor"), parseNativeListColor("#0000009B")) - if (selectorUsesSourceScale && (tag as? NativeListItem)?.json?.optString("presentation") == "walletSidebar" && leadingIcon.iconName == "PlusSmallOutline") { - leadingIcon.layoutParams = FrameLayout.LayoutParams(dp(24), dp(24), Gravity.CENTER) - leadingIcon.glyphSizeDp = 24 - } - if (selectorUsesSourceScale && (tag as? NativeListItem)?.json?.optString("presentation") == "walletSidebar" && leadingIcon.iconName == "LockSolid") { - leadingIcon.layoutParams = FrameLayout.LayoutParams(dp(40), dp(40), Gravity.CENTER) - leadingIcon.glyphSizeDp = 40 - } - } - if (visual.optString("borderStyle") == "dashed") { - leadingFrame.background = GradientDrawable().apply { - setColor(visualBackground) - cornerRadius = dp(styledWidthDp / 2).toFloat() - setStroke(dp(if (selectorUsesSourceScale && (tag as? NativeListItem)?.json?.optString("presentation") == "walletSidebar") 1 else 2), safeColor(visual.optString("borderColor"), parseNativeListColor("#00000072")), dp(4).toFloat(), dp(4).toFloat()) - } - leadingFallback.background = null - } - } - - private fun visualSources(visual: JSONObject): List> { - return when (val kind = visual.optString("kind")) { - "stackedImages" -> { - val images = visual.optJSONArray("images") ?: return emptyList() - (0 until minOf(3, images.length())).mapNotNull { index -> - images.optJSONObject(index)?.let { it to "generic" } - } - } - "icon" -> emptyList() - else -> buildList { - visual.optJSONObject("image")?.let { - val variant = when (kind) { - "token" -> "token" - "network" -> "network" - "account", "wallet" -> "avatar" - else -> "generic" - } - add(it to variant) - } - if (kind == "token") { - visual.optJSONObject("networkImage")?.let { add(it to "network") } - } - } - } - } - - private fun sourceFallbackStateKey(source: JSONObject): String? = - nativeListSourceFallbackStateKey( - uri = source.optString("uri"), - headers = source.optJSONObject("headers")?.let { headers -> - headers.keys().asSequence().associateWith { headers.optString(it) } - }.orEmpty(), - ) - - private fun leadingImageLayout( - index: Int, - count: Int, - sizeDp: Int, - heightDp: Int, - tokenPair: Boolean, - ): FrameLayout.LayoutParams { - if (count == 1 || tokenPair && index == 0) { - return FrameLayout.LayoutParams(dp(sizeDp), dp(heightDp)) - } - if (tokenPair && index == 1) { - return FrameLayout.LayoutParams(dp(16), dp(16), Gravity.END or Gravity.BOTTOM).apply { - marginEnd = -dp(2) - bottomMargin = -dp(2) - } - } - val imageSize = (sizeDp * 0.72f).toInt() - return FrameLayout.LayoutParams(dp(imageSize), dp(imageSize), Gravity.START or Gravity.CENTER_VERTICAL).apply { - marginStart = dp(index * (sizeDp - imageSize) / maxOf(1, count - 1)) - } - } - - private fun bindAccessories( - item: NativeListItem, - accessories: JSONArray?, - theme: JSONObject?, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - ) { - if (accessories == null) return - var textIndex = 0 - for (index in 0 until minOf(2, accessories.length())) { - val accessory = accessories.getJSONObject(index) - when (accessory.optString("kind")) { - "value" -> { - showTrailing(textIndex, accessory.optString("text"), !accessory.optBoolean("secondary", false)) - semanticValueViews.add(trailingViews[textIndex]) - applyValueSegments(trailingViews[textIndex], accessory.optJSONArray("textSegments")) - textIndex++ - } - "valuePair" -> { - showTrailingValuePair(textIndex, accessory, theme) - semanticValueViews.add(trailingViews[textIndex++]) - } - "checkbox" -> bindCheckbox(item, accessory, checkboxState) - "radio" -> showTrailing( - textIndex++, - if (accessory.optBoolean("checked")) "●" else "○", - true, - accessory.optString("actionKey").takeUnless { accessory.optBoolean("disabled", false) }, - ) - "switch" -> showTrailing( - textIndex++, - if (accessory.optBoolean("value")) "ON" else "OFF", - true, - accessory.optString("actionKey").takeUnless { accessory.optBoolean("disabled", false) }, - ) - "chevron" -> showTrailingIcon( - textIndex++, - item, - JSONObject(accessory.toString()).apply { - put("name", "ChevronRightSmallOutline") - if (optString("actionKey").isEmpty()) put("actionKey", "press") - }, - theme, - ) - "menu" -> showTrailingMenu(textIndex++, accessory.optString("actionKey")) - "drag" -> showTrailingIcon( - textIndex++, - item, - JSONObject(accessory.toString()).apply { put("name", "DragOutline") }, - theme, - ) - "icon" -> showTrailingIcon(textIndex++, item, accessory, theme) - "spinner" -> spinner.visibility = VISIBLE - "progress" -> showTrailing(textIndex++, "${(accessory.optDouble("value") * 100).toInt()}%", false) - } - } - } - - private fun bindCheckbox( - item: NativeListItem, - json: JSONObject, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - ) { - boundCheckboxData = json - val targetJson = json.optJSONObject("target") - val scope = targetJson?.optString("scope", "row") ?: "row" - val target = NativeSelectionTarget( - scope, - when (scope) { - "section" -> targetJson?.optString("sectionKey") - "row" -> item.key - else -> null - }, - ) - val state = checkboxState(item, target, json.optString("state", "unchecked")) - val accessoryDisabled = json.optBoolean("disabled", false) - if (json.optBoolean("loading", false)) { - checkbox.visibility = GONE - checkbox.setOnClickListener(null) - spinner.visibility = VISIBLE - spinner.alpha = if (!item.json.optBoolean("disabled", false) && accessoryDisabled) 0.5f else 1f - return - } - checkbox.visibility = VISIBLE - checkbox.setState(state, checkboxIconColor) - val usesSourceCheckboxGeometry = checkboxUsesSelectorStyle && item.json.has("height") - checkbox.usesSelectorGeometry = usesSourceCheckboxGeometry - if (usesSourceCheckboxGeometry) { - // OneKey patch: source Yoga children may round into padding; retain their complete border. - clipToPadding = false - // OneKey patch: even a transparent source border changes RN's background clipping path. - checkbox.background = null - BackgroundStyleApplicator.setBackgroundColor(checkbox, if (state == "unchecked") checkboxUncheckedColor else checkboxCheckedColor) - BackgroundStyleApplicator.setBorderWidth(checkbox, LogicalEdge.ALL, 2f) - BackgroundStyleApplicator.setBorderColor(checkbox, LogicalEdge.ALL, if (state == "unchecked") checkboxBorderColor else Color.TRANSPARENT) - BackgroundStyleApplicator.setBorderRadius(checkbox, BorderRadiusProp.BORDER_RADIUS, LengthPercentage(4f, LengthPercentageType.POINT)) - } else { - checkbox.background = if (state == "unchecked") { - if (checkboxUsesSelectorStyle) GradientDrawable().apply { setColor(checkboxUncheckedColor); setStroke(dp(2), checkboxBorderColor); cornerRadius = scaledDp(4f) } - else roundedStroke(checkboxBorderColor, checkboxUncheckedColor, 4f) - } else { - roundedFill(checkboxCheckedColor, 4f) - } - } - // Row-level disabled opacity already applies to this child. Only apply a - // local 0.5 when the accessory alone is disabled, never 0.5 * 0.5. - checkbox.alpha = if (!item.json.optBoolean("disabled", false) && accessoryDisabled) 0.5f else 1f - checkbox.isEnabled = - !item.json.optBoolean("disabled", false) && - !accessoryDisabled && - !json.optBoolean("loading", false) - checkbox.setOnClickListener { - emitAction( - item, - json.optString("actionKey", "selection"), - target, - checkbox, - "trailingAccessory", - ) - } - } - - private fun applySelectionState( - item: NativeListItem, - theme: JSONObject?, - layout: String, - itemIndex: Int?, - selected: Boolean, - ) { - var rowBackground = color( - theme, - if (selected) "rowSelectedBackground" else "rowBackground", - if (selected) "#0000000F" else "#FFFFFF", - ) - val groupPosition = when { - item.type == "identity" && item.json.optString("presentation") == "walletSidebar" -> "single" - // OneKey patch: explicit selector rows preserve the v1 ListItem corner radius. - item.type == "identity" && item.json.optString("presentation") in setOf("accountSelector", "networkSelector") && item.json.has("height") -> "single" - else -> item.json.optString("groupPosition") - } - var backgroundGroupPosition = groupPosition - if (layout == "sectioned" && !item.json.optBoolean("selected", false)) { - // Selection in sectioned lists is represented by the OneKey checkbox, - // matching iOS and the app-monorepo network selector. - rowBackground = color(theme, "rowBackground", "#FFFFFF") - if (item.type == "sectionHeader") backgroundGroupPosition = "" - } - // OneKey patch: section heading backgrounds are independent of list rows. - if (item.json.has("backgroundColor")) rowBackground = safeColor(item.json.optString("backgroundColor"), rowBackground) - item.json.optJSONObject("style")?.optJSONObject("container")?.takeIf { it.has("backgroundColor") }?.let { rowBackground = safeColor(it.optString("backgroundColor"), rowBackground) } - restingRowBackground = groupedBackground(backgroundGroupPosition, rowBackground) - background = restingRowBackground - } - - private fun showText(view: TextView, value: String, lines: Int) { - if (value.isEmpty()) return - view.text = value - view.maxLines = lines - view.visibility = VISIBLE - } - - private fun showTrailing( - index: Int, - value: String, - primary: Boolean, - actionKey: String? = null, - textColor: Int? = null, - ) { - if (index !in trailingViews.indices || value.isEmpty()) return - val view = trailingViews[index] - view.maxLines = 1 - view.text = value - view.textSize = sp(if (primary) 16f else 14f) - view.typeface = if (primary) NativeListFonts.medium(context) else NativeListFonts.regular(context) - view.includeFontPadding = false - view.fontFeatureSettings = "tnum" - TextViewCompat.setLineHeight(view, dp(if (primary) 24 else 20)) - textColor?.let(view::setTextColor) - view.visibility = VISIBLE - if (!actionKey.isNullOrEmpty()) { - view.setOnClickListener { - (tag as? NativeListItem) - ?.takeUnless { item -> item.json.optBoolean("disabled", false) } - ?.let { item -> - emitAction(item, actionKey, null, view, "trailingAccessory", index) - } - } - } - } - - private fun showTrailingValuePair(index: Int, accessory: JSONObject, theme: JSONObject?) { - if (index !in trailingViews.indices) return - val primary = accessory.optString("primary") - val secondary = accessory.optString("secondary") - if (primary.isEmpty() && secondary.isEmpty()) return - val value = SpannableStringBuilder() - val primaryStart = value.length - value.append(primary) - value.setSpan( - ForegroundColorSpan(accessoryTextColor(accessory.optString("primaryTone"), "primary", theme)), - primaryStart, - value.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - value.setSpan( - AbsoluteSizeSpan( - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp(16f), resources.displayMetrics).roundToInt(), - ), - primaryStart, - value.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - if (primary.isNotEmpty() && secondary.isNotEmpty()) value.append('\n') - val secondaryStart = value.length - value.append(secondary) - value.setSpan( - ForegroundColorSpan(accessoryTextColor(accessory.optString("secondaryTone"), "secondary", theme)), - secondaryStart, - value.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - value.setSpan( - AbsoluteSizeSpan( - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp(14f), resources.displayMetrics).roundToInt(), - ), - secondaryStart, - value.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - val view = trailingViews[index] - view.text = value - view.maxLines = 2 - view.gravity = Gravity.END - view.includeFontPadding = false - view.typeface = NativeListFonts.regular(context) - view.fontFeatureSettings = "tnum" - TextViewCompat.setLineHeight(view, dp(20)) - view.visibility = VISIBLE - } - - private fun showTrailingMenu(index: Int, actionKey: String) { - showTrailing(index, "⋮", true, actionKey) - if (index !in trailingViews.indices) return - trailingViews[index].apply { - gravity = Gravity.CENTER - textSize = sp(20f) - layoutParams = LayoutParams(dp(24), dp(24)).apply { - if (trailingColumn.orientation == HORIZONTAL && index > 0) marginStart = dp(8) - } - } - } - - private fun accessoryTextColor(tone: String, defaultTone: String, theme: JSONObject?): Int = - when (tone.ifEmpty { defaultTone }) { - "positive" -> color(theme, "positive", "#00713FDE") - "negative" -> color(theme, "negative", "#C40006D3") - "secondary" -> color(theme, "secondaryText", "#0000009B") - else -> color(theme, "primaryText", "#000000DF") - } - - private fun showTrailingIcon(index: Int, item: NativeListItem, data: JSONObject, theme: JSONObject?) { - if (index !in trailingIcons.indices) return - val icon = trailingIcons[index] - icon.iconName = data.optString("name") - icon.tintColor = safeColor(data.optString("tintColor"), iconSubduedColor) - // OneKey patch: preserve the original 38dp press target around its 24dp layout slot. - icon.setTag(com.facebook.react.R.id.react_test_id, data.optString("testID").takeIf { it.isNotEmpty() }) - icon.accessibilityDelegate = selectorAccessibilityDelegate - icon.contentDescription = data.optString("accessibilityLabel").takeIf { it.isNotEmpty() } - if (item.json.optString("presentation") == "accountSelector") { - icon.glyphSizeDp = 24 - val isSourceMenu = item.json.has("height") && icon.iconName == "DotHorOutline" - val size = if (item.json.has("height") && icon.iconName == "PlusSmallOutline") 36 else 38 - icon.layoutParams = LayoutParams(dp(size), dp(size)).apply { - gravity = Gravity.CENTER_VERTICAL - marginStart = -dp(7) - marginEnd = -dp(7) - } - // The frame overhangs its 24dp layout slot by 7dp on each side. The column - // clips children and the row clips to padding by default, which would cut - // the pressed circle down to a 24dp-wide pill. - trailingColumn.clipChildren = false - clipToPadding = false - if (isSourceMenu && data.optString("actionKey").isNotEmpty()) { - icon.background = StateListDrawable().apply { - addState( - intArrayOf(android.R.attr.state_pressed), - roundedFill(color(theme, "rowPressedBackground", "#00000017"), 19f), - ) - addState(intArrayOf(), roundedFill(Color.TRANSPARENT, 19f)) - } - } - } else if (icon.iconName == "ChevronRightSmallOutline") { - icon.glyphSizeDp = null - // ListItem.DrillIn is a 24dp icon with mx=-6, for a 12dp layout footprint. - icon.layoutParams = LayoutParams(dp(24), dp(24)).apply { - marginStart = -dp(6) - marginEnd = -dp(6) - } - } else { - // ListItem.IconButton medium keeps a 24dp glyph in a 36dp frame. Its - // m=-7 moves the last frame 7dp through the row's trailing padding. For - // the Bookmark XStack, gap=$6 plus both negative margins leaves 10dp - // between the physical frames (46dp between glyph centers). - icon.glyphSizeDp = 24 - setPadding(paddingLeft, paddingTop, dp(5), paddingBottom) - icon.layoutParams = LayoutParams(dp(36), dp(36)).apply { - gravity = Gravity.END - if (trailingColumn.orientation == HORIZONTAL && index > 0) { - marginStart = dp(10) - } - } - } - icon.visibility = VISIBLE - icon.isEnabled = !data.optBoolean("disabled", false) - icon.alpha = if (icon.isEnabled) 1f else 0.4f - val actionKey = data.optString("actionKey") - if (icon.isEnabled && actionKey.isNotEmpty()) { - icon.setOnClickListener { - emitAction(item, actionKey, null, icon, "trailingAccessory", index) - } - } - } - - private fun applySize(item: NativeListItem) { - if (item.type == "market") { - val style = item.json.optJSONObject("style") - val imageHeight = style?.optJSONObject("image")?.optDouble( - "height", - if (item.json.optString("variant") == "stock") 40.0 else 32.0, - ) ?: if (item.json.optString("variant") == "stock") 40.0 else 32.0 - val verticalPadding = style?.optDouble("verticalPadding", 12.0) ?: 12.0 - val defaultHeight = if (item.json.optString("variant") == "stock") 72.0 else 68.0 - // OneKey patch: preserve fractional DP until the final physical pixel edge. - val rowHeight = item.json.optDouble( - "height", - maxOf(defaultHeight, imageHeight + verticalPadding * 2), - ) - val sourceScale = if (selectorUsesSourceScale) 1f else NativeListScale.factor(resources) - minimumHeight = (rowHeight * sourceScale * resources.displayMetrics.density).roundToInt() - // OneKey patch: explicit Market row heights must not grow after child pixel rounding. - selectorHeight = if (item.json.has("height")) minimumHeight else null - return - } - val isWalletSidebar = - item.type == "identity" && item.json.optString("presentation") == "walletSidebar" - val isAccountSelectorIdentity = - item.type == "identity" && item.json.optString("presentation") == "accountSelector" - val isNetworkSelectorIdentity = - item.type == "identity" && item.json.optString("presentation") == "networkSelector" - val isNetworkSelectorSection = - item.type == "sectionHeader" && item.json.optString("presentation") == "networkSelector" - title.textSize = sp( - if (isWalletSidebar) { - 12f - } else { - when (item.type) { - "sectionHeader" -> when { - isNetworkSelectorSection && item.json.optString("variant") != "summary" -> 14f - item.json.optString("variant") == "gallery" -> 18f - item.json.optString("variant") == "summary" -> 16f - currentLayout == "table" -> 11f - item.json.optString("variant") == "history" || item.sectionKey?.startsWith("history-") == true -> 12f - else -> 14f - } - else -> 16f - } - }, - ) - title.typeface = if (isWalletSidebar || isAccountSelectorIdentity) { - NativeListFonts.regular(context) - } else { - when (item.type) { - "sectionHeader" -> when { - isNetworkSelectorSection && item.json.has("height") && item.json.optString("variant") != "summary" && (item.json.optJSONObject("checkbox") != null || item.json.optString("titleActionKey").isEmpty()) -> NativeListFonts.semibold(context) - isNetworkSelectorSection -> NativeListFonts.medium(context) - currentLayout == "table" -> NativeListFonts.regular(context) - item.json.optString("variant") == "summary" -> NativeListFonts.medium(context) - item.sectionKey in setOf("linear-tokens", "action-tokens") -> NativeListFonts.regular(context) - else -> NativeListFonts.semibold(context) - } - else -> NativeListFonts.medium(context) - } - } - subtitle.textSize = sp(when (item.type) { - else -> 14f - }) - tertiary.textSize = sp(14f) - if (isNetworkSelectorSection && item.json.optString("variant") != "summary") { - TextViewCompat.setLineHeight(title, dp(20)) - } else if (item.type == "sectionHeader" && item.json.optString("variant") == "gallery") { - TextViewCompat.setLineHeight(title, dp(24)) - } else if (item.type == "sectionHeader" && item.json.optString("variant") == "summary") { - TextViewCompat.setLineHeight(title, dp(24)) - } else if (item.type == "sectionHeader" && currentLayout == "table") { - TextViewCompat.setLineHeight(title, dp(14)) - TextViewCompat.setLineHeight(trailingViews[0], dp(14)) - } else if ( - item.type == "sectionHeader" && - (item.json.optString("variant") == "history" || item.sectionKey?.startsWith("history-") == true) - ) { - TextViewCompat.setLineHeight(title, dp(16)) - } else if (item.type == "sectionHeader" && item.sectionKey in setOf("linear-tokens", "action-tokens")) { - TextViewCompat.setLineHeight(title, dp(20)) - } else if (item.type == "identity" && item.json.optString("presentation") == "walletSidebar") { - TextViewCompat.setLineHeight(title, dp(16)) - } else if (item.type == "identity") { - TextViewCompat.setLineHeight(title, dp(24)) - TextViewCompat.setLineHeight(subtitle, dp(20)) - TextViewCompat.setLineHeight(tertiary, dp(20)) - - } - status.textSize = sp(12f) - badgeLine.textSize = sp(12f) - // OneKey patch: exact selector row dimensions override template minimums. - selectorHeight = if (item.json.has("height")) dp(item.json.optInt("height")) else null - // OneKey patch: React Native's section spacers and letter blocks truncate physical heights. - val isSelectorLetter = isNetworkSelectorSection && item.json.has("height") && - item.json.optString("variant") != "summary" && item.json.optString("titleActionKey").isEmpty() && - item.json.optJSONObject("checkbox") == null - if (isSelectorLetter) { - selectorHeight = (item.json.optInt("height") * resources.displayMetrics.density).toInt() - } - if (isSelectorLetter) { - val inset = (20 * resources.displayMetrics.density).toInt() - dp(8) - // OneKey patch: SectionHeader has a fixed height and centered text, without vertical padding. - setPadding(inset, 0, inset, 0) - } - if (item.json.has("height") && isNetworkSelectorSection && item.json.optString("variant") != "summary" && item.json.optString("titleActionKey").isNotEmpty() && item.json.optJSONObject("checkbox") == null) { - // OneKey patch: the text-and-underline header's fractional measured height rounds up in React Native. - selectorHeight = kotlin.math.ceil((item.json.optInt("height") * (if (selectorUsesSourceScale) 1f else NativeListScale.factor(resources)) * resources.displayMetrics.density).toDouble()).toInt() - } - // OneKey patch: an explicit per-row policy preserves each source list's measured heights. - if (item.json.has("height")) { - when (item.json.optString("heightRounding")) { - "floor" -> selectorHeight = (item.json.optInt("height") * resources.displayMetrics.density).toInt() - "nearest" -> selectorHeight = (item.json.optInt("height") * resources.displayMetrics.density).roundToInt() - } - } - val baseHeight = when { - item.json.has("height") -> item.json.optInt("height") - item.type == "walletGroup" -> { - val childCount = item.json.optJSONArray("children")?.length() ?: 0 - // OneKey patch: include badge heights in the group layout. - // (childCount + 1) * 68 + childCount * 12 - val members = listOf(item.json.getJSONObject("parent")) + (0 until childCount).map { item.json.getJSONArray("children").getJSONObject(it) } - members.sumOf { it.optInt("height", if ((it.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68) } + childCount * 12 + if (members.first().has("height")) 2 else 0 - } - isNetworkSelectorIdentity -> 47 - else -> when (item.type) { - "sectionHeader" -> when { - isNetworkSelectorSection -> 44 - currentLayout == "table" -> 28 - item.json.optString("variant") == "summary" -> 80 - item.json.optString("variant") == "gallery" -> 32 - item.json.optString("variant") == "history" || item.sectionKey?.startsWith("history-") == true -> 16 - item.sectionKey in setOf("linear-tokens", "action-tokens") -> 30 - item.json.optString("value").isNotEmpty() && item.json.optJSONObject("checkbox") != null -> 40 - else -> 36 - } - else -> when { - item.type == "identity" && item.json.optString("presentation") == "walletSidebar" -> if ((item.json.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68 - item.type == "identity" && item.json.optString("tertiary").isNotEmpty() -> 72 - item.type == "identity" && item.json.optString("subtitle").isNotEmpty() -> 60 - else -> 56 - } - } - } - val modifier = if (item.json.has("height") || isNetworkSelectorIdentity) { - 0 - } else if ( - item.type == "sectionHeader" && item.json.optString("variant") in listOf("summary", "gallery") - ) { - 0 - } else { - when (item.json.optString("size", "medium")) { "small" -> -8; "large" -> 12; else -> 0 } - } - val sectionSpacing = 0 - minimumHeight = dp((baseHeight + modifier + sectionSpacing).coerceAtLeast(0)) - } - - - private fun applyListOrientation(item: NativeListItem, listOrientation: String) { - val params = layoutParams ?: return - if (listOrientation == "horizontal") { - params.width = when (item.type) { - else -> dp(280) - } - params.height = ViewGroup.LayoutParams.WRAP_CONTENT - } else { - params.width = ViewGroup.LayoutParams.MATCH_PARENT - params.height = ViewGroup.LayoutParams.WRAP_CONTENT - } - layoutParams = params - } - - private fun leadingCornerRadius(shape: String, sizeDp: Int): Float = when (shape) { - "square" -> 0f - "rounded" -> minOf(10f, sizeDp / 4f) - else -> sizeDp / 2f - } - - private fun leadingOutlineProvider(shape: String) = object : ViewOutlineProvider() { - override fun getOutline(view: View, outline: Outline) { - when (shape) { - "square" -> outline.setRect(0, 0, view.width, view.height) - "rounded" -> { - // OneKey patch: the account avatar has an 8-point radius at its 32-point size. - // outline.setRoundRect(0, 0, view.width, view.height, dp(10).toFloat()) - val radius = if ((tag as? NativeListItem)?.json?.optString("presentation") == "accountSelector" && (tag as? NativeListItem)?.json?.has("height") == true) dp(8).toFloat() else dp(10).toFloat() - outline.setRoundRect(0, 0, view.width, view.height, radius) - } - else -> outline.setOval(0, 0, view.width, view.height) - } - } - } - - private fun roundedOutlineProvider(radiusDp: Float) = object : ViewOutlineProvider() { - override fun getOutline(view: View, outline: Outline) { - outline.setRoundRect(0, 0, view.width, view.height, scaledDp(radiusDp)) - } - } - - private fun JSONArray?.hasAccessory(kind: String): Boolean { - if (this == null) return false - return (0 until length()).any { optJSONObject(it)?.optString("kind") == kind } - } - - private fun JSONArray?.isBookmarkEditActions(): Boolean = - this != null && length() == 2 && - optJSONObject(0)?.optString("kind") == "icon" && - optJSONObject(0)?.optString("name") == "PencilOutline" && - optJSONObject(1)?.optString("kind") == "icon" && - optJSONObject(1)?.optString("name") == "DragOutline" - - private fun JSONArray?.isValuePairMenu(): Boolean = - this != null && length() == 2 && - optJSONObject(0)?.optString("kind") == "valuePair" && - optJSONObject(1)?.optString("kind") == "menu" - - private fun bindImage( - source: JSONObject, - imageView: OneKeyImageReusableView, - token: String, - slot: Int, - variant: String, - round: Boolean = false, - onLoad: (() -> Unit)? = null, - onError: (() -> Unit)? = null, - hideUntilLoaded: Boolean = false, - retryAttempt: Int = 0, - ) { - selectorImageRetries.remove(imageView)?.let(imageView::removeCallbacks) - val expectedEpoch = bindingEpoch - val retryLimit = source.optInt("retryTimes", 0).coerceAtLeast(0) - val uri = source.optString("uri").trim().takeIf(String::isNotEmpty) - val sourceHeadersJson = source.optJSONObject("headers")?.toString() - val recyclingKey = if (retryAttempt == 0) "$token:$slot" else "$token:$slot:retry:$retryAttempt" - if (hideUntilLoaded) { - imageView.visibility = if ( - imageView.isDisplaying(uri, sourceHeadersJson, recyclingKey) - ) VISIBLE else INVISIBLE - } - val handleLoad: (() -> Unit)? = if (hideUntilLoaded) ({ - if (bindingEpoch == expectedEpoch) { - imageView.visibility = VISIBLE - onLoad?.invoke() - } - }) else onLoad - val handleError: (() -> Unit)? = if (hideUntilLoaded) ({ - if (bindingEpoch == expectedEpoch) { - imageView.visibility = INVISIBLE - onError?.invoke() - } - }) else onError - imageView.configure( - sourceUri = uri, - sourceHeadersJson = sourceHeadersJson, - variant = variant, - contentFit = if (imageView === leadingImages[0]) (tag as? NativeListItem)?.json?.optJSONObject("style")?.optJSONObject("image")?.optString("contentFit")?.takeIf(String::isNotEmpty) ?: source.optString("contentFit", "cover") else source.optString("contentFit", "cover"), - cachePolicy = source.optString("cachePolicy", "memory-disk"), - autoplay = source.optBoolean("autoplay", false), - recyclingKey = recyclingKey, - optimizeTos = retryAttempt == 0 && source.optBoolean("optimizeTos", true), - round = round, - overscan = source.optDouble("overscan", 1.1), - loadingStrategy = source.optString("loadingStrategy", "none"), - placeholderColor = imagePlaceholderColor, - onLoad = if (retryLimit == 0) handleLoad else ({ - if (bindingEpoch == expectedEpoch) { - selectorImageRetries.remove(imageView)?.let(imageView::removeCallbacks) - handleLoad?.invoke() - } - }), - onError = if (retryLimit == 0) handleError else ({ - if (bindingEpoch == expectedEpoch) { - handleError?.invoke() - if (retryAttempt < retryLimit && !selectorImageRetries.containsKey(imageView)) { - val retry = Runnable { - if (bindingEpoch == expectedEpoch) { - selectorImageRetries.remove(imageView) - bindImage( - source = source, - imageView = imageView, - token = token, - slot = slot, - variant = variant, - round = round, - onLoad = onLoad, - onError = onError, - hideUntilLoaded = hideUntilLoaded, - retryAttempt = retryAttempt + 1, - ) - } - } - selectorImageRetries[imageView] = retry - imageView.postDelayed(retry, kotlin.random.Random.nextLong(3) * 1000L) - } - } - }), - ) - } - - private fun weighted() = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) - private fun wrap() = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) - private fun dp(value: Int): Int = if (selectorUsesSourceScale) (value * resources.displayMetrics.density).roundToInt() else NativeListScale.dp(resources, value) - private fun scaledDp(value: Float): Float = if (selectorUsesSourceScale) (value * resources.displayMetrics.density).roundToInt().toFloat() else NativeListScale.dp(resources, value) - private fun sp(value: Float): Float = if (selectorUsesSourceScale) value else NativeListScale.font(resources, value) - - private fun color(theme: JSONObject?, key: String, fallback: String): Int = - safeColor(theme?.optString(key, fallback), parseNativeListColor(fallback)) - - private fun safeColor(value: String?, fallback: Int): Int = try { - if (value.isNullOrEmpty()) fallback else parseNativeListColor(value) - } catch (_: IllegalArgumentException) { - fallback - } - - private fun roundedFill(color: Int, radiusDp: Float) = GradientDrawable().apply { - setColor(color) - cornerRadius = scaledDp(radiusDp) - } - - private fun roundedStroke(stroke: Int, fill: Int, radiusDp: Float) = GradientDrawable().apply { - setColor(fill) - setStroke(dp(2), stroke) - cornerRadius = scaledDp(radiusDp) - } - - private fun roundedHairlineStroke(stroke: Int, radiusDp: Float) = GradientDrawable().apply { - setColor(Color.TRANSPARENT) - setStroke(1, stroke) - cornerRadius = scaledDp(radiusDp) - } - - private fun groupedBackground(position: String, color: Int) = GradientDrawable().apply { - setColor(color) - val radius = listStyle?.takeIf { - it.has("groupCornerRadius") && !(tag as? NativeListItem)?.json?.optString("groupId").isNullOrEmpty() - } - ?.let { scaledDp(it.optDouble("groupCornerRadius").toFloat()) } - ?: scaledDp(12f) - cornerRadii = when (position) { - "first" -> floatArrayOf(radius, radius, radius, radius, 0f, 0f, 0f, 0f) - "last" -> floatArrayOf(0f, 0f, 0f, 0f, radius, radius, radius, radius) - "single" -> FloatArray(8) { radius } - else -> FloatArray(8) - } - } - -} - -internal class OneKeyIconView(context: android.content.Context) : View(context) { - var useSourceScale = false - var iconName: String = "" - set(value) { - field = value - invalidate() - } - var tintColor: Int = Color.rgb(100, 100, 100) - set(value) { - field = value - invalidate() - } - var glyphSizeDp: Int? = null - set(value) { - field = value - invalidate() - } - - private val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - val pathData = iconPaths[iconName] ?: return - val drawSize = minOf( - minOf(width, height).toFloat(), - glyphSizeDp?.let { if (useSourceScale) (it * resources.displayMetrics.density).roundToInt().toFloat() else NativeListScale.dp(resources, it).toFloat() } ?: Float.MAX_VALUE, - ) - // OneKey patch: custom account-error artwork uses an 18-point viewBox. - // val scale = drawSize / 24f - val scale = drawSize / (selectorIconViewBoxes[iconName] ?: 24f) - fill.color = tintColor - canvas.save() - canvas.translate((width - drawSize) / 2f, (height - drawSize) / 2f) - canvas.scale(scale, scale) - val sourceFillTypes = actionIconFillTypes[iconName] - pathData.forEachIndexed { index, data -> - PathParser.createPathFromPathData(data)?.let { path -> - path.fillType = sourceFillTypes?.getOrNull(index) ?: Path.FillType.EVEN_ODD - // OneKey patch: provider illustration colors are part of their source asset. - fill.color = selectorIconColors[iconName]?.getOrNull(index) ?: tintColor - canvas.drawPath(path, fill) - } - } - canvas.restore() - } - - companion object { - // OneKey patch: preserve provider colors and non-24 viewBoxes. - private val selectorIconColors: Map> = mapOf( - "GlobusOutline" to listOf(null), - "LockSolid" to listOf(null), - "GoogleIllus" to listOf(parseNativeListColor("#4285F4"), parseNativeListColor("#34A853"), parseNativeListColor("#FBBC05"), parseNativeListColor("#EA4335")), - "AppleBrand" to listOf(null), - "BotIllus" to listOf(parseNativeListColor("#8897A5"), parseNativeListColor("#3FA9F5"), parseNativeListColor("#8897A5"), parseNativeListColor("#8897A5"), parseNativeListColor("#10243E"), parseNativeListColor("#10243E"), parseNativeListColor("#10243E")), - "AllNetworksSolid" to listOf(null, null), - "CrossedSmallSolid" to listOf(null), - "AccountErrorCustom" to listOf(Color.argb(0x72, 0, 0, 0), Color.argb(0x72, 0, 0, 0)), - "Circle" to listOf(null), - ) - private val selectorIconViewBoxes = mapOf( - "GlobusOutline" to 24f, - "LockSolid" to 24f, - "GoogleIllus" to 24f, - "AppleBrand" to 16f, - "BotIllus" to 24f, - "AllNetworksSolid" to 24f, - "CrossedSmallSolid" to 24f, - "AccountErrorCustom" to 18f, - "Circle" to 24f, - ) - // Keep the SVG fill-rule used by each Action-row source path. React Native - // SVG defaults to nonzero (WINDING); only paths declaring fillRule="evenodd" - // use EVEN_ODD. Other existing icons retain their prior rendering behavior. - private val actionIconFillTypes = mapOf( - "GlobusOutline" to listOf(Path.FillType.EVEN_ODD), - "LockSolid" to listOf(Path.FillType.EVEN_ODD), - "GoogleIllus" to listOf(Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING), - "AppleBrand" to listOf(Path.FillType.WINDING), - "BotIllus" to listOf(Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING), - "AllNetworksSolid" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), - "CrossedSmallSolid" to listOf(Path.FillType.WINDING), - "AccountErrorCustom" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), - "Circle" to listOf(Path.FillType.WINDING), - "BadgeVerifiedSolid" to listOf(Path.FillType.EVEN_ODD), - "ChevronRightSmallOutline" to listOf(Path.FillType.WINDING), - "MinusCircleOutline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), - "PlusCircleOutline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), - "PlusSmallOutline" to listOf(Path.FillType.WINDING), - "DotHorOutline" to listOf(Path.FillType.WINDING), - "MinusCircleSolid" to listOf(Path.FillType.EVEN_ODD), - "PencilOutline" to listOf(Path.FillType.EVEN_ODD), - "DragOutline" to listOf(Path.FillType.WINDING), - "StarSolid" to listOf(Path.FillType.WINDING), - "ChevronGrabberVerOutline" to listOf(Path.FillType.WINDING), - "ChevronBottomOutline" to listOf(Path.FillType.WINDING), - "ChevronTopOutline" to listOf(Path.FillType.WINDING), - "ImageSquareWavesOutline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), - ) - - // Exact 24x24 paths from app-monorepo packages/components Icon sources. - private val iconPaths = mapOf( - // OneKey patch: official selector SVG path geometry. - "GlobusOutline" to listOf("M12 2c5.185 0 9.448 3.947 9.95 9H22v2h-.05c-.502 5.053-4.765 9-9.95 9s-9.448-3.947-9.95-9H2v-2h.05C2.552 5.947 6.815 2 12 2M9.523 13c.09 1.982.438 3.726.934 5.002.29.746.612 1.282.917 1.614.304.331.517.384.626.384s.322-.053.626-.384c.305-.332.627-.868.917-1.614.496-1.276.845-3.02.934-5.002zm-5.459 0a8 8 0 0 0 4.8 6.36 10 10 0 0 1-.271-.633C7.994 17.187 7.61 15.189 7.52 13zm12.416 0c-.09 2.189-.474 4.187-1.073 5.727a10 10 0 0 1-.271.633 8 8 0 0 0 4.8-6.36zM8.863 4.639A8 8 0 0 0 4.064 11h3.457c.09-2.189.473-4.187 1.072-5.727q.127-.327.27-.634M12 4c-.109 0-.322.053-.626.384-.305.332-.627.868-.917 1.614-.496 1.276-.844 3.02-.934 5.002h4.954c-.09-1.982-.438-3.726-.934-5.002-.29-.746-.612-1.282-.917-1.614C12.322 4.053 12.109 4 12 4m3.136.639q.144.307.271.634c.599 1.54.982 3.538 1.073 5.727h3.456a8 8 0 0 0-4.8-6.361"), - "LockSolid" to listOf("M12 2a5 5 0 0 1 5 5v2h3v13H4V9h3V7a5 5 0 0 1 5-5m-1 11v5h2v-5zm1-9a3 3 0 0 0-3 3v2h6V7a3 3 0 0 0-3-3"), - "GoogleIllus" to listOf("M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09", "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23", "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22z", "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53"), - "AppleBrand" to listOf("M11.67.834c.117 1.074-.315 2.153-.955 2.928-.64.773-1.692 1.378-2.718 1.298-.14-1.054.38-2.151.971-2.836C9.63 1.45 10.746.872 11.67.834M14.994 7.093c-.176.108-1.992 1.224-1.972 3.482.025 2.769 2.428 3.693 2.46 3.705l-.004.015a10.1 10.1 0 0 1-1.264 2.593c-.764 1.116-1.556 2.229-2.806 2.254-.598.011-1-.162-1.416-.343-.437-.19-.891-.386-1.609-.386-.751 0-1.226.203-1.683.398-.397.169-.78.333-1.32.354-1.208.047-2.124-1.207-2.895-2.32C.909 14.57-.294 10.414 1.322 7.612c.803-1.395 2.237-2.275 3.794-2.298.671-.014 1.32.244 1.89.47.434.172.821.326 1.135.326.282 0 .659-.149 1.099-.323.692-.273 1.539-.607 2.41-.518.599.026 2.276.24 3.354 1.818z"), - "BotIllus" to listOf("M11 2a1 1 0 1 1 2 0v1.8l1.6 1.6a1 1 0 1 1-1.4 1.4L12 5.6l-1.2 1.2a1 1 0 0 1-1.4-1.4L11 3.8z", "M8.0 6.0h8.0a5.0 5.0 0 0 1 5.0 5.0v4.0a5.0 5.0 0 0 1 -5.0 5.0h-8.0a5.0 5.0 0 0 1 -5.0 -5.0v-4.0a5.0 5.0 0 0 1 5.0 -5.0z", "M3.0 10.0h0.0a1.5 1.5 0 0 1 1.5 1.5v3.0a1.5 1.5 0 0 1 -1.5 1.5h0.0a1.5 1.5 0 0 1 -1.5 -1.5v-3.0a1.5 1.5 0 0 1 1.5 -1.5z", "M21.0 10.0h0.0a1.5 1.5 0 0 1 1.5 1.5v3.0a1.5 1.5 0 0 1 -1.5 1.5h0.0a1.5 1.5 0 0 1 -1.5 -1.5v-3.0a1.5 1.5 0 0 1 1.5 -1.5z", "M7.5 12.0a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0", "M13.5 12.0a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0", "M8.5 15.4c.9.8 2.08 1.2 3.5 1.2s2.6-.4 3.5-1.2c.24-.2.6-.18.8.06.2.23.17.6-.06.8-1.14.98-2.58 1.46-4.24 1.46s-3.1-.48-4.24-1.46a.58.58 0 0 1-.06-.8c.2-.24.56-.26.8-.06"), - "AllNetworksSolid" to listOf("M15.333 13.998a1.335 1.335 0 1 1 0 2.67 1.335 1.335 0 0 1 0-2.67", "M12 0c6.627 0 12 5.373 12 12s-5.373 12-12 12S0 18.627 0 12 5.373 0 12 0M8 12.668A2 2 0 0 0 6 14.666V16c0 1.103.895 1.997 1.998 1.998h1.334A2 2 0 0 0 11.33 16v-1.334a2 2 0 0 0-1.998-1.998zm7.333 0a2.665 2.665 0 1 0 0 5.33 2.665 2.665 0 0 0 0-5.33M7.999 6.001A2 2 0 0 0 6.001 8v1.334c0 1.103.895 1.998 1.998 1.998h1.334a2 2 0 0 0 1.998-1.998V7.999a2 2 0 0 0-1.998-1.998zm6.667 0A2 2 0 0 0 12.668 8v1.334c0 1.103.895 1.998 1.998 1.998H16a2 2 0 0 0 1.998-1.998V7.999A2 2 0 0 0 16 6.001z"), - "CrossedSmallSolid" to listOf("M17.87 8.25 14.12 12l3.75 3.75-2.12 2.121-3.75-3.75-3.75 3.75-2.121-2.121L9.879 12l-3.75-3.75 2.12-2.121L12 9.879l3.75-3.75 2.122 2.121Z"), - "AccountErrorCustom" to listOf("M12.5 12.75a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5", "M0 3.5A3.5 3.5 0 0 1 3.5 0h8.088A2.41 2.41 0 0 1 14 2.412V5h1a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H4a4 4 0 0 1-4-4zm2 3.163V14a2 2 0 0 0 2 2h11a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H3.5c-.537 0-1.045-.12-1.5-.337M2 3.5A1.5 1.5 0 0 0 3.5 5H12V2.412A.41.41 0 0 0 11.588 2H3.5A1.5 1.5 0 0 0 2 3.5"), - "Circle" to listOf("M0 12a12 12 0 1 0 24 0a12 12 0 1 0 -24 0"), - "BadgeVerifiedSolid" to listOf("M9.483 11.458v3.5h-1v-3.5z M10.467 2.698a2.03 2.03 0 0 1 3.065 0l1.358 1.564a.03.03 0 0 0 .028.01l2.046-.325a2.03 2.03 0 0 1 2.347 1.971l.037 2.07q0 .016.014.026l1.776 1.066a2.03 2.03 0 0 1 .532 3.019l-1.304 1.609a.03.03 0 0 0-.005.03l.675 1.956a2.03 2.03 0 0 1-1.533 2.656l-2.033.394a.03.03 0 0 0-.023.019l-.741 1.933a2.03 2.03 0 0 1-2.88 1.05l-1.811-1.006a.03.03 0 0 0-.03 0l-1.811 1.005a2.03 2.03 0 0 1-2.88-1.049l-.742-1.933a.03.03 0 0 0-.023-.019l-2.033-.394a2.03 2.03 0 0 1-1.532-2.656l.675-1.957a.03.03 0 0 0-.005-.029l-1.304-1.61a2.03 2.03 0 0 1 .532-3.018l1.776-1.066a.03.03 0 0 0 .014-.026l.035-2.07a2.03 2.03 0 0 1 2.349-1.97l2.045.324a.03.03 0 0 0 .028-.01zm1.516 3.76a.5.5 0 0 0-.447.276l-1.861 3.724H8.483a1 1 0 0 0-1 1v3.5a1 1 0 0 0 1 1h6.692a2 2 0 0 0 1.981-1.73l.341-2.5a2 2 0 0 0-1.982-2.27h-1.939l.197-1.269a1.5 1.5 0 0 0-1.481-1.731z"), - "ArrowBottomOutline" to listOf("m13 17.586 5-5L19.414 14 12 21.414 4.586 14 6 12.586l5 5V3h2z"), - "ArrowTopOutline" to listOf("M19.414 10 18 11.414l-5-5V21h-2V6.414l-5 5L4.586 10 12 2.586z"), - "ChartTrendingUpOutline" to listOf("M22 13h-2V9.414l-7 7-4-4-6 6L1.586 17 9 9.586l4 4L18.586 8H15V6h7z"), - "SwapHorOutline" to listOf("M21 16H6.914l2.293 2.293-1.414 1.414L2.086 14H21zm.914-6H3V8h14.086l-2.293-2.293 1.414-1.414z"), - "ShieldExclamationOutline" to listOf( - "M12 12.25a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5m0-4.75a1 1 0 0 1 1 1v2a1 1 0 0 1-2 0v-2a1 1 0 0 1 1-1", - "M11.352 2.223c.42-.145.876-.145 1.296 0l6.98 2.4a1.995 1.995 0 0 1 1.347 1.886v5.432c0 2.799-1.146 4.817-2.805 6.387-1.61 1.525-3.735 2.652-5.696 3.71a1 1 0 0 1-.947 0c-1.961-1.058-4.086-2.185-5.697-3.71-1.659-1.57-2.805-3.588-2.805-6.386V6.508c0-.852.542-1.61 1.347-1.887l6.98-2.4ZM5.02 6.509v5.432c0 2.16.848 3.676 2.18 4.938 1.272 1.204 2.957 2.149 4.799 3.145 1.842-.996 3.527-1.94 4.799-3.145 1.332-1.262 2.181-2.778 2.181-4.938V6.51L12 4.109z", - ), - "InfoCircleOutline" to listOf( - "M13 17h-2v-5h-1v-2h3zm0-8h-2V7h2z", - "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2m0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16", - ), - "SpeakerPromoteOutline" to listOf("M21.996 11a4 4 0 0 1-3 3.874v5.495l-5.361-1.711A3.998 3.998 0 0 1 5.996 17v-.779l-4-1.276v-7.89l17-5.424v5.495c1.725.444 3 2.01 3 3.874m-2 0c0-.74-.402-1.385-1-1.731v3.46c.597-.345 1-.989 1-1.729M7.998 7.24v7.521l8.998 2.871V4.368l-8.998 2.87ZM3.996 8.516v4.967l2.002.638V7.877zm4 8.484a2 2 0 0 0 3.706 1.041L7.996 16.86z"), - "PlusCircleOutline" to listOf( - "M13 11h4v2h-4v4h-2v-4l-4 .001v-2L11 11V7h2z", - "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2m0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16", - ), - "PlusSmallOutline" to listOf("M13 11h5v2h-5v5h-2v-5H6v-2h5V6h2z"), - "DotHorOutline" to listOf("M6 14H2v-4h4zm8 0h-4v-4h4zm8 0h-4v-4h4z"), - "MinusCircleOutline" to listOf( - "M17 13H7v-2h10z", - "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2m0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16", - ), - "MinusCircleSolid" to listOf("M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2M7 13h10v-2H7z"), - "ErrorSolid" to listOf("M23.256 20H.742L12 1.041zM11 15v2h2v-2zm0-6v5h2V9z"), - "QuestionmarkSolid" to listOf("M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2m-1 15h2v-2h-2zM9 7v3h2V9h2v1l-2 1.5V14h2v-1.5l2-1.5V7z"), - "PencilOutline" to listOf("M22.414 7.5 7.914 22H2v-5.914l14.5-14.5zM4 16.914V20h3.086l9.5-9.5L13.5 7.414zM14.914 6 18 9.086 19.586 7.5 16.5 4.414z"), - "ChevronRightSmallOutline" to listOf("M15.414 12 10 17.414 8.586 16l4-4-4-4L10 6.586z"), - "DragOutline" to listOf("M11 21H7v-4h4zm6 0h-4v-4h4zm-6-7H7v-4h4zm6 0h-4v-4h4zm-6-7H7V3h4zm6 0h-4V3h4z"), - "StarOutline" to listOf("m15.455 7.243 7.729 1.123-5.592 5.45 1.32 7.698L12 17.879l-6.911 3.635 1.32-7.698-5.592-5.45 7.728-1.123L12 .24zM9.872 9.071l-4.759.69 3.444 3.358-.814 4.738L12 15.62l.465.245 3.791 1.993-.813-4.739 3.443-3.357-4.758-.69L12 4.758z"), - "StarSolid" to listOf("m15.405 7.313 7.84 1.034-5.735 5.443 1.44 7.774L12 17.793l-6.948 3.771 1.44-7.774L.756 8.347l7.839-1.034L12 .178z"), - "ChevronGrabberVerOutline" to listOf("M17.414 15 12 20.414 6.586 15 8 13.586l4 4 4-4zm0-6L16 10.414l-4-4-4 4L6.586 9 12 3.586z"), - "ChevronBottomOutline" to listOf("M20.707 9.707 12 18.414 3.293 9.707l1.414-1.414L12 15.586l7.293-7.293z"), - "ChevronTopOutline" to listOf("m20.707 14.293-1.414 1.414L12 8.414l-7.293 7.293-1.414-1.414L12 5.586z"), - "ImageSquareWavesOutline" to listOf( - "M14.25 7a2 2 0 1 1 0 4 2 2 0 0 1 0-4", - "M21 21H3V3h18zM5 16.414V19h12.586L14 15.414l-2 2-4-4zm0-2.828 3-3 4 4 2-2 5 5V5H5z", - ), - ) - } -} - -internal class OneKeyCheckboxView(context: android.content.Context) : View(context) { - var usesSelectorGeometry = false - private val glyphPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } - private var state = "unchecked" - - fun setState(value: String, color: Int) { - state = value - glyphPaint.color = color - invalidate() - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - val pathData = when (state) { - "checked" -> "M12.204 5.043a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414 0l-2-2a1 1 0 1 1 1.414-1.414l1.293 1.293 3.793-3.793a1 1 0 0 1 1.414 0" - "indeterminate" -> "M4 8a1 1 0 0 1 1-1h6a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1" - else -> return - } - // OneKey patch: the source icon is a 16dp child after the 2dp border, not 80% of a rounded frame. - val drawSize = if (usesSelectorGeometry) (16 * resources.displayMetrics.density).roundToInt().toFloat() else minOf(width, height) * 0.8f - val borderOffset = (2 * resources.displayMetrics.density).roundToInt().toFloat() - canvas.save() - canvas.translate(if (usesSelectorGeometry) borderOffset else (width - drawSize) / 2f, if (usesSelectorGeometry) borderOffset else (height - drawSize) / 2f) - canvas.scale(drawSize / 16f, drawSize / 16f) - PathParser.createPathFromPathData(pathData)?.let { canvas.drawPath(it, glyphPaint) } - canvas.restore() - } -} - -internal class NativeListViewHolder(val rowView: NativeListRowHost) : - androidx.recyclerview.widget.RecyclerView.ViewHolder(rowView) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt index 6ff10fe7c..ac6d86ebe 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt @@ -226,8 +226,8 @@ class NativeListView( mass = REORDER_SPRING_MASS, durationSeconds = REORDER_SPRING_DURATION_MS / 1_000.0, ) - private var footerView: NativeListRowHost = NativeListRendererRegistry.create(reactContext, NativeListRendererKey.LEGACY.ordinal) - private var footerRendererKey = NativeListRendererKey.LEGACY + private var footerView: NativeListRowHost = NativeListRendererRegistry.create(reactContext, NativeListRendererKey.ACTION.ordinal) + private var footerRendererKey = NativeListRendererKey.ACTION private val sectionIndexView = NativeListSectionIndexView(context) private val sectionIndexPreview = TextView(context) private var config: NativeListConfig? = null diff --git a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt deleted file mode 100644 index 4abcd117f..000000000 --- a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.margelo.nitro.nativelist - -import org.junit.Assert.assertEquals -import org.junit.Test - -class NativeListStyleSlotTest { - private data class Case( - val type: String, - val field: String, - val variant: String = "", - val expected: String?, - ) - - @Test - fun styleKeyResolvesToTheViewThatRendersThatModelField() { - val cases = listOf( - // identity keeps its own names. - Case(type = "identity", field = "title", expected = "title"), - Case(type = "identity", field = "valueSecondary", expected = "valueSecondary"), - // Legacy status views only serve unmigrated templates. - Case(type = "rail", field = "status", expected = null), - Case(type = "message", field = "time", expected = null), - // Migrated templates no longer resolve slots through the legacy pool. - Case(type = "system", field = "title", variant = "retry", expected = null), - Case(type = "sectionHeader", field = "value", expected = "value"), - - Case(type = "system", field = "message", variant = "warning", expected = null), - Case(type = "system", field = "message", variant = "noMatch", expected = null), - // A field the template does not render is ignored, never remapped onto - // whichever view happens to be free. - Case(type = "identity", field = "price", expected = null), - Case(type = "rail", field = "subtitle", expected = null), - Case(type = "market", field = "title", expected = null), - Case(type = "walletGroup", field = "title", expected = null), - ) - - assertEquals( - cases.map(Case::expected), - cases.map { nativeListStyleSlot(it.type, it.variant, it.field) }, - ) - } - - @Test - fun noTemplateMapsTwoStyleKeysOntoOneView() { - // A collision would make one of the two keys silently win. - val templates = listOf( - Triple("identity", "", listOf("title", "subtitle", "tertiary", "badge", "value", "valueSecondary")), - Triple("sectionHeader", "", listOf("title", "subtitle", "value")), - - ) - - templates.forEach { (type, variant, fields) -> - val slots = fields.mapNotNull { nativeListStyleSlot(type, variant, it) } - assertEquals( - "$type maps two style keys onto one view: $slots", - slots.size, - slots.toSet().size, - ) - } - } -} diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index 205175a6c..f8d81ccbc 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -82,7 +82,7 @@ separate workload and measurements. | 3. Simple templates | Complete (7/7): Rail, MediaTile, Action, System, Activity, DataRow and MetricCard migrated on all three platforms | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | | 4. Complex templates | Complete (3/3): Market, Identity and SectionHeader migrated on all three platforms | Migrate Market, Identity and SectionHeader while preserving quote, selection and header behavior | | 5. WalletGroup | Complete: registered composites and independent keyed Identity members on all three platforms | Independent member renderers; verify member events/styles, compact dragging and height restoration | -| 6. Cleanup | Not started | Remove migrated key inference and global style maps; container no longer manipulates template internals | +| 6. Cleanup | In progress: remove obsolete hosts/registries, then key-based styling and container fallbacks | Remove migrated key inference and global style maps; container no longer manipulates template internals | The closed internal registry selects all twelve renderers by template type. No supported template dispatches to the legacy native tree. WalletGroup owns diff --git a/native-views/react-native-native-list/ios/NativeListAccessoryStack.swift b/native-views/react-native-native-list/ios/NativeListAccessoryStack.swift index 5c805cca8..66ab252e2 100644 --- a/native-views/react-native-native-list/ios/NativeListAccessoryStack.swift +++ b/native-views/react-native-native-list/ios/NativeListAccessoryStack.swift @@ -1,5 +1,98 @@ import UIKit +// OneKey patch: explicit summary actions use the source text's physical-pixel line box. +final class NativeListAccessoryButton: UIButton { + var pressedBackgroundColor: UIColor? + var rowTextOffsetY: CGFloat = 0 + + override var isHighlighted: Bool { + didSet { + guard let pressedBackgroundColor else { return } + backgroundColor = isHighlighted ? pressedBackgroundColor : .clear + } + } + + var selectorSummaryLineHeight: CGFloat? { + didSet { + invalidateIntrinsicContentSize() + setNeedsLayout() + } + } + + var marketLineHeight: CGFloat? { + didSet { + invalidateIntrinsicContentSize() + setNeedsLayout() + } + } + + private var sourcePixelScale: CGFloat { + max(1, window?.screen.scale ?? traitCollection.displayScale) + } + + override var intrinsicContentSize: CGSize { + var size = super.intrinsicContentSize + guard selectorSummaryLineHeight != nil || marketLineHeight != nil, + let title = attributedTitle(for: .normal) + else { return size } + let width = title.boundingRect( + with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + context: nil + ).width + size.width = ceil(width * sourcePixelScale) / sourcePixelScale + return size + } + + override func layoutSubviews() { + super.layoutSubviews() + defer { + if let titleLabel { + var frame = titleLabel.frame + if contentVerticalAlignment == .top { + frame.origin.y = contentEdgeInsets.top + } else if contentVerticalAlignment == .bottom { + frame.origin.y = bounds.height - contentEdgeInsets.bottom - frame.height + } + frame.origin.y += rowTextOffsetY + titleLabel.frame = frame + } + } + if let lineHeight = marketLineHeight, let titleLabel, let title = attributedTitle(for: .normal) + { + let measured = title.boundingRect( + with: CGSize( + width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil + ).width + let width = min(bounds.width, ceil(measured * sourcePixelScale) / sourcePixelScale) + let x = + contentHorizontalAlignment == .trailing + ? bounds.width - width + : contentHorizontalAlignment == .leading ? 0 : (bounds.width - width) / 2 + let textHeight = + titleLabel.numberOfLines > 1 + ? min( + CGFloat(titleLabel.numberOfLines) * lineHeight, + titleLabel.sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude)) + .height) + : lineHeight + titleLabel.frame = CGRect( + x: floor(x * sourcePixelScale) / sourcePixelScale, + y: (bounds.height - textHeight) / 2, + width: width, height: textHeight + ) + return + } + guard let lineHeight = selectorSummaryLineHeight, let titleLabel else { return } + // OneKey patch: position the final source line box after UIKit has measured the button. + let top = ceil((bounds.height - lineHeight) / 2 * sourcePixelScale) / sourcePixelScale + var frame = titleLabel.frame + frame.origin.y = top + titleLabel.frame = frame + } +} + // Bounded trailing controls shared by migrated templates. The owning host supplies // action epochs; selection-only updates touch only the existing checkbox. final class NativeListAccessoryStack: UIStackView { diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift deleted file mode 100644 index 1b4eca50e..000000000 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ /dev/null @@ -1,3444 +0,0 @@ -import Foundation -// OneKey patch: preserve native font faces while enabling tabular number features. -import CoreText -import CryptoKit -import OneKeyImage -import UIKit - -final class NativeListActionOrigin { - weak var sourceView: UIView? - weak var ownerCell: NativeListRowHost? - let bindingEpoch: Int - let source: String - let slot: Int? - // OneKey patch: expose the layout slot while preserving the larger hit target. - let anchorInset: CGFloat - var windowPoint: CGPoint? - - init( - sourceView: UIView, - ownerCell: NativeListRowHost, - bindingEpoch: Int, - source: String, - slot: Int? = nil, - anchorInset: CGFloat = 0 - ) { - self.sourceView = sourceView - self.ownerCell = ownerCell - self.bindingEpoch = bindingEpoch - self.source = source - self.slot = slot - self.anchorInset = anchorInset - } -} - -// OneKey patch: explicit summary actions use the source text's physical-pixel line box. -final class NativeListAccessoryButton: UIButton { - var pressedBackgroundColor: UIColor? - var rowTextOffsetY: CGFloat = 0 - - override var isHighlighted: Bool { - didSet { - guard let pressedBackgroundColor else { return } - backgroundColor = isHighlighted ? pressedBackgroundColor : .clear - } - } - - var selectorSummaryLineHeight: CGFloat? { - didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } - } - - var marketLineHeight: CGFloat? { - didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } - } - - private var sourcePixelScale: CGFloat { - max(1, window?.screen.scale ?? traitCollection.displayScale) - } - - override var intrinsicContentSize: CGSize { - var size = super.intrinsicContentSize - guard selectorSummaryLineHeight != nil || marketLineHeight != nil, let title = attributedTitle(for: .normal) else { return size } - let width = title.boundingRect( - with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin, .usesFontLeading], - context: nil - ).width - size.width = ceil(width * sourcePixelScale) / sourcePixelScale - return size - } - - override func layoutSubviews() { - super.layoutSubviews() - defer { - if let titleLabel { - var frame = titleLabel.frame - if contentVerticalAlignment == .top { frame.origin.y = contentEdgeInsets.top } - else if contentVerticalAlignment == .bottom { frame.origin.y = bounds.height - contentEdgeInsets.bottom - frame.height } - frame.origin.y += rowTextOffsetY - titleLabel.frame = frame - } - } - if let lineHeight = marketLineHeight, let titleLabel, let title = attributedTitle(for: .normal) { - let measured = title.boundingRect( - with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil - ).width - let width = min(bounds.width, ceil(measured * sourcePixelScale) / sourcePixelScale) - let x = contentHorizontalAlignment == .trailing ? bounds.width - width - : contentHorizontalAlignment == .leading ? 0 : (bounds.width - width) / 2 - let textHeight = titleLabel.numberOfLines > 1 - ? min(CGFloat(titleLabel.numberOfLines) * lineHeight, titleLabel.sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude)).height) - : lineHeight - titleLabel.frame = CGRect( - x: floor(x * sourcePixelScale) / sourcePixelScale, - y: (bounds.height - textHeight) / 2, - width: width, height: textHeight - ) - return - } - guard let lineHeight = selectorSummaryLineHeight, let titleLabel else { return } - // OneKey patch: position the final source line box after UIKit has measured the button. - let top = ceil((bounds.height - lineHeight) / 2 * sourcePixelScale) / sourcePixelScale - var frame = titleLabel.frame - frame.origin.y = top - titleLabel.frame = frame - } -} - -final class NativeListDottedUnderlineLabel: NativeListTextLabel { - var showsDottedUnderline = false { - didSet { setNeedsLayout() } - } - - // OneKey patch: migrated section titles include the source 3-point underline box. - var reservesDottedUnderlineSpace = false { - didSet { invalidateIntrinsicContentSize(); setNeedsLayout(); setNeedsDisplay() } - } - - override var intrinsicContentSize: CGSize { - var size = super.intrinsicContentSize - if reservesDottedUnderlineSpace && showsDottedUnderline { size.height += 3 } - return size - } - - override func drawText(in rect: CGRect) { - let textRect = reservesDottedUnderlineSpace && showsDottedUnderline - ? CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: max(0, rect.height - 3)) - : rect - super.drawText(in: textRect) - } - - var dottedUnderlineColor: UIColor = .clear { - didSet { - dottedUnderlineLayer.strokeColor = dottedUnderlineColor.cgColor - setNeedsLayout() - } - } - - var dottedUnderlineVerticalOffset: CGFloat = 0 { - didSet { setNeedsLayout() } - } - - private let dottedUnderlineLayer = CAShapeLayer() - - override init(frame: CGRect) { - super.init(frame: frame) - dottedUnderlineLayer.fillColor = UIColor.clear.cgColor - dottedUnderlineLayer.lineWidth = 1.5 - dottedUnderlineLayer.lineCap = .round - dottedUnderlineLayer.lineDashPattern = [0, 4] - layer.addSublayer(dottedUnderlineLayer) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - super.layoutSubviews() - CATransaction.begin() - CATransaction.setDisableActions(true) - defer { CATransaction.commit() } - guard showsDottedUnderline, let attributedText else { - dottedUnderlineLayer.path = nil - dottedUnderlineLayer.isHidden = true - return - } - let textWidth = ceil(attributedText.boundingRect( - with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: 24), - options: [.usesLineFragmentOrigin, .usesFontLeading], - context: nil - ).width) - dottedUnderlineLayer.frame = CGRect( - x: 0, - y: 0, - width: bounds.width, - height: bounds.height + 2 + dottedUnderlineVerticalOffset - ) - // OneKey patch: explicit header underline occupies the reserved final two points. - // let y = bounds.height + 1 + dottedUnderlineVerticalOffset - let y = reservesDottedUnderlineSpace ? bounds.height - 1 : bounds.height + 1 + dottedUnderlineVerticalOffset - let path = UIBezierPath() - path.move(to: CGPoint(x: 1, y: y)) - path.addLine(to: CGPoint(x: max(1, textWidth - 1), y: y)) - dottedUnderlineLayer.path = path.cgPath - dottedUnderlineLayer.isHidden = false - } -} - -final class NativeListCell: NativeListRowHost { - private let rootStack = UIStackView() - private var styledCircleViews: [UIView] = [] - private let leadingImages = (0..<3).map { _ in OneKeyImageReusableView(frame: .zero) } - private let leadingOverlayBackground = UIView() - private let leadingCornerIconBackground = UIView() - private let leadingCornerIconImageView = UIImageView() - // OneKey patch: selector-only views are reset on every native cell binding. - private var selectorViews: [UIView] = [] - private var selectorConstraints: [NSLayoutConstraint] = [] - private var selectorImages: [OneKeyImageReusableView] = [] - private var selectorBorder: CAShapeLayer? - private let selectorFullWidthBackground = CALayer() - private lazy var selectorTitleTap = UITapGestureRecognizer(target: self, action: #selector(selectorTitlePressed)) - private let secondaryImage = OneKeyImageReusableView(frame: .zero) - private let fallbackLabel = UILabel() - private let leadingIconImageView = UIImageView() - private let headerTitleIconImageView = UIImageView() - private let headerValueIconImageView = UIImageView() - private let leadingActionButton = UIButton(type: .system) - private let leadingContainer = UIView() - private let unreadDot = UIView() - private let mainStack = UIStackView() - private let titleRowStack = UIStackView() - private let titleLabel = NativeListDottedUnderlineLabel() - private let subtitleLabel = NativeListTextLabel() - // OneKey patch: keep Market name and volume in independent line boxes. - private let marketSubtitleStack = UIStackView() - private let marketSubtitleSpacer = UIView() - private let tertiaryLabel = NativeListTextLabel() - private let statusLabel = NativeListInsetLabel() - private let badgeLabel = NativeListInsetLabel() - // OneKey patch: reuse the existing explicit line-box layout for styled Market badges. - // private let marketBadgeButtons = (0..<3).map { _ in UIButton(type: .system) } - private let marketBadgeButtons = (0..<3).map { _ in NativeListAccessoryButton(type: .system) } - private let marketBadgeImages = (0..<3).map { _ in OneKeyImageReusableView(frame: .zero) } - private let trailingStack = UIStackView() - // OneKey patch: summary actions opt into source typography while other buttons keep UIKit layout. - // private let accessoryButtons = (0..<2).map { _ in UIButton(type: .system) } - private let accessoryButtons = (0..<2).map { _ in NativeListAccessoryButton(type: .system) } - private let checkboxButton = UIButton(type: .system) - private let spinner = UIActivityIndicatorView(style: .medium) - private var walletGroupCells: [NativeListCell] = [] - private var walletGroupMembers: [NativeListItem] = [] - private let walletGroupCompactContainer = UIView() - private var walletGroupCompactCell: NativeListCell? - private let walletGroupDragBadge = NativeListInsetLabel() - private var walletGroupCompactAppearanceActive = false - private lazy var walletGroupTap = UITapGestureRecognizer( - target: self, - action: #selector(walletGroupPressed(_:)) - ) - private let separatorView = UIView() - private var separatorLeadingConstraint: NSLayoutConstraint! - private var leadingWidth: NSLayoutConstraint! - private var leadingHeight: NSLayoutConstraint! - private var leadingIconWidth: NSLayoutConstraint! - private var leadingIconHeight: NSLayoutConstraint! - private var secondaryWidth: NSLayoutConstraint! - private var secondaryHeight: NSLayoutConstraint! - private var rootLeadingConstraint: NSLayoutConstraint! - private var rootTrailingConstraint: NSLayoutConstraint! - private var rootTopConstraint: NSLayoutConstraint! - private var rootBottomConstraint: NSLayoutConstraint! - private var leadingSlotConstraints: [NSLayoutConstraint] = [] - private var accessorySizeConstraints: [NSLayoutConstraint] = [] - // OneKey patch: restore selector-only font features before a cell is reused. - private var selectorTypographyRestorers: [() -> Void] = [] - private var currentItem: NativeListItem? - private var accessoryActions: [(String, NativeSelectionTarget?)] = [] - private var checkboxAction: (String, NativeSelectionTarget?)? - private var boundCheckboxData: [String: Any]? - private var boundCheckboxTarget: NativeSelectionTarget? - private var leadingActionKey: String? - private var marketBadgeActionKeys: [String?] = [] - private var restingBackgroundColor: UIColor = .clear - private var pressedBackgroundColor = UIColor( - nativeListHex: "#E8E8E8", - fallback: .lightGray - ) - private var checkboxCheckedColor = UIColor(nativeListHex: "#202020", fallback: .black) - private var checkboxIconColor = UIColor.white - private var checkboxUncheckedColor = UIColor(nativeListHex: "#FCFCFC", fallback: .white) - private var checkboxBorderColor = UIColor(nativeListHex: "#CECECE", fallback: .lightGray) - private var visualBackdropColor = UIColor.white - private var currentLayout = "linear" - // OneKey patch: a row style wrote view properties that reset() does not restore. - private var styledTextViews = Set() - private var styledViewsDirty = false - private var styledTextRestorations: [() -> Void] = [] - private var semanticBadgeLabels: [UILabel] = [] - private var semanticSubtitleLabels: [UILabel] = [] - private var semanticValueButtons: [UIButton] = [] - private weak var walletBadgeLine: UIStackView? - private var currentTheme: [String: Any]? - private var currentItemIndex: Int? - // OneKey patch: delayed image retries belong to the current reusable cell binding. - private var selectorImageRetries: [ObjectIdentifier: DispatchWorkItem] = [:] - override var isHighlighted: Bool { - didSet { updateBackgroundColor() } - } - - override init(frame: CGRect) { - super.init(frame: frame) - contentView.addSubview(rootStack) - contentView.addSubview(separatorView) - contentView.addSubview(walletGroupCompactContainer) - rootStack.translatesAutoresizingMaskIntoConstraints = false - separatorView.translatesAutoresizingMaskIntoConstraints = false - walletGroupCompactContainer.translatesAutoresizingMaskIntoConstraints = false - rootLeadingConstraint = rootStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12) - rootTrailingConstraint = rootStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12) - rootTopConstraint = rootStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8) - rootBottomConstraint = rootStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8) - leadingIconWidth = leadingIconImageView.widthAnchor.constraint(equalToConstant: 18) - leadingIconHeight = leadingIconImageView.heightAnchor.constraint(equalToConstant: 18) - separatorLeadingConstraint = separatorView.leadingAnchor.constraint( - equalTo: contentView.leadingAnchor - ) - NSLayoutConstraint.activate([ - rootLeadingConstraint, - rootTrailingConstraint, - rootTopConstraint, - rootBottomConstraint, - separatorLeadingConstraint, - separatorView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - separatorView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - separatorView.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale), - walletGroupCompactContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - walletGroupCompactContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - walletGroupCompactContainer.topAnchor.constraint(equalTo: contentView.topAnchor), - walletGroupCompactContainer.heightAnchor.constraint(equalToConstant: 68), - ]) - walletGroupCompactContainer.isHidden = true - walletGroupCompactContainer.isUserInteractionEnabled = false - headerTitleIconImageView.translatesAutoresizingMaskIntoConstraints = false - headerTitleIconImageView.contentMode = .scaleAspectFit - headerValueIconImageView.translatesAutoresizingMaskIntoConstraints = false - headerValueIconImageView.contentMode = .scaleAspectFit - leadingActionButton.translatesAutoresizingMaskIntoConstraints = false - leadingActionButton.adjustsImageWhenDisabled = false - leadingActionButton.tintAdjustmentMode = .normal - leadingActionButton.addTarget(self, action: #selector(leadingActionPressed), for: .touchUpInside) - NSLayoutConstraint.activate([ - headerTitleIconImageView.widthAnchor.constraint(equalToConstant: 12), - headerTitleIconImageView.heightAnchor.constraint(equalToConstant: 12), - headerValueIconImageView.widthAnchor.constraint(equalToConstant: 12), - headerValueIconImageView.heightAnchor.constraint(equalToConstant: 12), - leadingActionButton.widthAnchor.constraint(equalToConstant: 36), - leadingActionButton.heightAnchor.constraint(equalToConstant: 36), - ]) - separatorView.isHidden = true - walletGroupTap.isEnabled = false - addGestureRecognizer(walletGroupTap) - rootStack.axis = .horizontal - rootStack.alignment = .center - rootStack.spacing = 12 - rootStack.setCustomSpacing(12, after: leadingActionButton) - rootStack.distribution = .fill - rootLeadingConstraint.constant = 12 - rootTrailingConstraint.constant = -12 - rootTopConstraint.constant = 8 - rootBottomConstraint.constant = -8 - mainStack.alignment = .fill - - leadingContainer.addSubview(fallbackLabel) - fallbackLabel.translatesAutoresizingMaskIntoConstraints = false - leadingContainer.addSubview(leadingIconImageView) - leadingIconImageView.translatesAutoresizingMaskIntoConstraints = false - leadingImages.enumerated().forEach { index, image in - image.translatesAutoresizingMaskIntoConstraints = false - if index == 1 { - leadingOverlayBackground.translatesAutoresizingMaskIntoConstraints = false - leadingContainer.addSubview(leadingOverlayBackground) - } - leadingContainer.addSubview(image) - } - leadingCornerIconBackground.translatesAutoresizingMaskIntoConstraints = false - leadingCornerIconImageView.translatesAutoresizingMaskIntoConstraints = false - leadingCornerIconBackground.addSubview(leadingCornerIconImageView) - leadingContainer.addSubview(leadingCornerIconBackground) - leadingCornerIconBackground.layer.cornerRadius = 10 - leadingCornerIconBackground.isHidden = true - NSLayoutConstraint.activate([ - leadingCornerIconBackground.widthAnchor.constraint(equalToConstant: 20), - leadingCornerIconBackground.heightAnchor.constraint(equalToConstant: 20), - leadingCornerIconBackground.trailingAnchor.constraint( - equalTo: leadingContainer.trailingAnchor, - constant: 4 - ), - leadingCornerIconBackground.bottomAnchor.constraint( - equalTo: leadingContainer.bottomAnchor, - constant: 4 - ), - leadingCornerIconImageView.widthAnchor.constraint(equalToConstant: 18), - leadingCornerIconImageView.heightAnchor.constraint(equalToConstant: 18), - leadingCornerIconImageView.centerXAnchor.constraint( - equalTo: leadingCornerIconBackground.centerXAnchor - ), - leadingCornerIconImageView.centerYAnchor.constraint( - equalTo: leadingCornerIconBackground.centerYAnchor - ), - ]) - leadingOverlayBackground.layer.cornerRadius = 10 - leadingOverlayBackground.isHidden = true - leadingCornerIconBackground.isHidden = true - leadingCornerIconImageView.image = nil - NSLayoutConstraint.activate([ - fallbackLabel.leadingAnchor.constraint(equalTo: leadingContainer.leadingAnchor), - fallbackLabel.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor), - fallbackLabel.topAnchor.constraint(equalTo: leadingContainer.topAnchor), - fallbackLabel.bottomAnchor.constraint(equalTo: leadingContainer.bottomAnchor), - leadingIconWidth, - leadingIconHeight, - leadingIconImageView.centerXAnchor.constraint(equalTo: leadingContainer.centerXAnchor), - leadingIconImageView.centerYAnchor.constraint(equalTo: leadingContainer.centerYAnchor), - ]) - leadingWidth = leadingContainer.widthAnchor.constraint(equalToConstant: 40) - leadingHeight = leadingContainer.heightAnchor.constraint(equalToConstant: 40) - leadingWidth.isActive = true - leadingHeight.isActive = true - fallbackLabel.textAlignment = .center - fallbackLabel.font = nativeListFont(ofSize: 13, weight: .bold) - fallbackLabel.textColor = UIColor(nativeListHex: "#8D8D8D", fallback: .gray) - secondaryImage.clipsToBounds = true - secondaryImage.layer.cornerRadius = 6 - secondaryImage.translatesAutoresizingMaskIntoConstraints = false - secondaryWidth = secondaryImage.widthAnchor.constraint(equalToConstant: 56) - secondaryHeight = secondaryImage.heightAnchor.constraint(equalToConstant: 56) - leadingContainer.addSubview(unreadDot) - unreadDot.translatesAutoresizingMaskIntoConstraints = false - unreadDot.layer.cornerRadius = 4 - NSLayoutConstraint.activate([ - unreadDot.widthAnchor.constraint(equalToConstant: 8), - unreadDot.heightAnchor.constraint(equalToConstant: 8), - unreadDot.topAnchor.constraint(equalTo: leadingContainer.topAnchor), - unreadDot.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor), - ]) - - mainStack.axis = .vertical - mainStack.alignment = .fill - mainStack.spacing = 2 - mainStack.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal) - mainStack.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - titleRowStack.axis = .horizontal - titleRowStack.alignment = .center - titleRowStack.spacing = 8 - titleRowStack.setContentHuggingPriority(.defaultLow, for: .horizontal) - titleRowStack.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - titleRowStack.addArrangedSubview(titleLabel) - titleRowStack.addArrangedSubview(badgeLabel) - marketBadgeButtons.enumerated().forEach { index, button in - button.tag = index - button.addTarget(self, action: #selector(marketBadgePressed(_:)), for: .touchUpInside) - button.titleLabel?.font = nativeListFont(ofSize: 11, weight: .medium) - button.layer.cornerRadius = 4 - button.clipsToBounds = true - let image = marketBadgeImages[index] - image.isUserInteractionEnabled = false - image.translatesAutoresizingMaskIntoConstraints = false - button.addSubview(image) - NSLayoutConstraint.activate([ - image.centerYAnchor.constraint(equalTo: button.centerYAnchor), - image.widthAnchor.constraint(equalToConstant: 14), - image.heightAnchor.constraint(equalToConstant: 14), - ]) - titleRowStack.addArrangedSubview(button) - } - titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) - badgeLabel.setContentHuggingPriority(.required, for: .horizontal) - badgeLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - titleLabel.font = nativeListFont(ofSize: 16, weight: .medium) - subtitleLabel.font = nativeListFont(ofSize: 14) - tertiaryLabel.font = nativeListFont(ofSize: 14) - statusLabel.font = nativeListFont(ofSize: 12) - badgeLabel.font = nativeListFont(ofSize: 12, weight: .medium) - [titleRowStack, subtitleLabel, tertiaryLabel, statusLabel] - .forEach(mainStack.addArrangedSubview) - - trailingStack.axis = .vertical - trailingStack.alignment = .trailing - trailingStack.spacing = 2 - trailingStack.setContentHuggingPriority(.required, for: .horizontal) - trailingStack.setContentCompressionResistancePriority(.required, for: .horizontal) - accessoryButtons.enumerated().forEach { index, button in - button.adjustsImageWhenDisabled = false - button.tintAdjustmentMode = .normal - button.titleLabel?.font = nativeListFont( - ofSize: index == 0 ? 16 : 14, - weight: index == 0 ? .medium : .regular - ) - button.tag = index - button.addTarget(self, action: #selector(accessoryPressed(_:)), for: .touchUpInside) - trailingStack.addArrangedSubview(button) - } - checkboxButton.layer.borderWidth = 2 - checkboxButton.layer.cornerRadius = 4 - checkboxButton.tintColor = checkboxUncheckedColor - checkboxButton.imageView?.contentMode = .center - checkboxButton.addTarget(self, action: #selector(checkboxPressed), for: .touchUpInside) - NSLayoutConstraint.activate([ - checkboxButton.widthAnchor.constraint(equalToConstant: 20), - checkboxButton.heightAnchor.constraint(equalToConstant: 20), - ]) - trailingStack.addArrangedSubview(checkboxButton) - trailingStack.addArrangedSubview(spinner) - - - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - super.layoutSubviews() - - // OneKey patch: extend only the background across the section list outer inset. - if currentItem?.data.bool("backgroundFullWidth") == true { - selectorFullWidthBackground.frame = CGRect(x: -frame.minX, y: 0, width: superview?.bounds.width ?? bounds.width, height: bounds.height) - } - - for view in styledCircleViews { - (view.layer.mask as? CAShapeLayer)?.path = UIBezierPath(ovalIn: view.bounds).cgPath - } - if currentItem?.type == "identity", currentItem?.data["height"] != nil, - currentItem?.data.dictionary("style")?.dictionary("container")?["contentVerticalAlignment"] == nil, - currentItem?.data.string("presentation") == "accountSelector", - let accessory = currentItem?.data.dictionaries("trailing").first, - accessory.string("kind") == "icon", accessory.string("name") == "PlusSmallOutline" { - // OneKey patch: PlusButton's fixed top18 slot and negative7 margin place its frame at11. - let button = accessoryButtons[0] - button.transform = .identity - let origin = button.convert(button.bounds, to: contentView).minY - button.transform = CGAffineTransform(translationX: 0, y: 11 - origin) - } - } - - // OneKey patch: trim only detached/recycled members, never a live compact - // drag proxy or an expanding group. Small groups retain eight reusable cells. - private func trimWalletGroupCells(keeping required: Int) { - let retained = max(8, required) - while walletGroupCells.count > retained { - let cell = walletGroupCells.removeLast() - rootStack.removeArrangedSubview(cell) - cell.removeFromSuperview() - cell.prepareForReuse() - cell.onAction = nil - cell.onBindingInvalidated = nil - } - } - - override func prepareForReuse() { - let canTrimMembers = !walletGroupCompactAppearanceActive && (rootStack.layer.animationKeys()?.isEmpty ?? true) - super.prepareForReuse() - invalidateCurrentBinding() - isHighlighted = false - restingBackgroundColor = .clear - contentView.backgroundColor = restingBackgroundColor - currentItem = nil - walletGroupCompactAppearanceActive = false - walletGroupCompactContainer.isHidden = true - walletGroupCompactContainer.alpha = 1 - walletGroupCompactCell?.prepareForReuse() - walletGroupCells.forEach { $0.prepareForReuse() } - walletGroupMembers.removeAll() - if canTrimMembers { trimWalletGroupCells(keeping: 0) } - leadingImages.forEach { $0.prepareForReuse() } - secondaryImage.prepareForReuse() - } - - override func bind( - item: NativeListItem, - theme: [String: Any]?, - layout: String, - itemIndex: Int?, - selected: Bool, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String - ) { - // OneKey patch: a same-row snapshot refresh must not clear a touch that is still held. - let shouldRestoreHighlight = isHighlighted && currentItem?.key == item.key - invalidateCurrentBinding() - bindingEpoch &+= 1 - currentLayout = layout - currentTheme = theme - currentItemIndex = itemIndex - currentItem = item - leadingImages.forEach { $0.prepareForReuse() } - secondaryImage.prepareForReuse() - reset() - - let primary = nativeListColor(theme, "primaryText", "#202020") - let secondary = nativeListColor(theme, "secondaryText", "#646464") - let accent = nativeListColor(theme, "accent", "#108303") - checkboxCheckedColor = primary - checkboxUncheckedColor = nativeListColor(theme, "inverseText", "#FCFCFC") - // Checkbox uses the literal neutral7 alpha token. Applying opacity to the - // opaque primary text color produces a different RGB result. - checkboxBorderColor = UIColor(nativeListHex: "#00000031", fallback: .lightGray) - checkboxIconColor = checkboxUncheckedColor - if item.data.string("presentation") == "networkSelector" { - checkboxCheckedColor = nativeListColor(theme, "checkboxBackground", "#202020") - checkboxBorderColor = nativeListColor(theme, "checkboxBorder", "#00000031") - checkboxIconColor = nativeListColor(theme, "checkboxIcon", "#FFFFFF") - // OneKey patch: the V1 checkbox fills even its unchecked body with iconInverse. - checkboxUncheckedColor = checkboxIconColor - } - visualBackdropColor = nativeListColor(theme, "rowBackground", "#FFFFFF") - titleLabel.textColor = primary - subtitleLabel.textColor = secondary - tertiaryLabel.textColor = secondary - statusLabel.textColor = secondary - badgeLabel.textColor = accent - accessoryButtons.forEach { $0.setTitleColor(primary, for: .normal) } - unreadDot.backgroundColor = UIColor(nativeListHex: "#E5484D", fallback: .systemRed) - let separatorStyle = listStyle?.dictionary("separator") - let separatorColor = nativeListColor(theme, "separator", "#E0E0E0") - separatorView.backgroundColor = (separatorStyle?["color"] as? String) - .map { UIColor(nativeListHex: $0, fallback: separatorColor) } - ?? separatorColor - separatorLeadingConstraint.constant = separatorStyle?["inset"] == nil - ? (item.type == "identity" ? 60 : 12) - : CGFloat(separatorStyle?.double("inset") ?? 0) - separatorView.isHidden = !item.data.bool("separator") - restingBackgroundColor = selectionBackgroundColor( - item: item, - theme: theme, - layout: layout, - itemIndex: itemIndex, - selected: selected - ) - pressedBackgroundColor = nativeListColor(theme, "rowPressedBackground", "#E8E8E8") - updateBackgroundColor() - if item.data.bool("backgroundFullWidth"), let background = (item.data.dictionary("style")?.dictionary("container")?["backgroundColor"] ?? item.data["backgroundColor"]) as? String { - selectorFullWidthBackground.backgroundColor = UIColor(nativeListHex: background, fallback: .clear).cgColor - contentView.layer.insertSublayer(selectorFullWidthBackground, at: 0) - clipsToBounds = false - } - if layout == "table" { - rootLeadingConstraint.constant = 16 - rootTrailingConstraint.constant = -16 - } - applyGroupPosition(item.data.string("groupPosition")) - isUserInteractionEnabled = !item.data.bool("disabled") - // OneKey patch: deprecated wallets remain interactive while dimmed. - // contentView.alpha = isUserInteractionEnabled ? 1 : 0.5 - contentView.alpha = CGFloat(item.data.dictionary("style")?.dictionary("container")?.double("opacity", default: item.data.double("opacity", default: 1)) ?? item.data.double("opacity", default: 1)) * (isUserInteractionEnabled ? 1 : 0.5) - accessibilityLabel = item.data.string("accessibilityLabel", default: item.data.string("title")) - // OneKey patch: keep existing selector automation identifiers. - accessibilityIdentifier = item.data["testID"] as? String - - switch item.type { - case "walletGroup": bindWalletGroup(item, theme: theme, layout: layout, checkboxState) - case "identity": bindIdentity(item, theme: theme, selected: selected, checkboxState) - case "market": bindMarket(item, theme: theme) - case "sectionHeader": bindSectionHeader(item, theme: theme, layout: layout, checkboxState) - default: break - } - applyRowStyle(item) - applySelectorTypography(item) - if shouldRestoreHighlight && isUserInteractionEnabled { - isHighlighted = true - } - if item.type == "sectionHeader", item.data.string("presentation") == "networkSelector", item.data["height"] != nil, item.data.dictionary("checkbox") != nil, !item.data.string("value").isEmpty { - // OneKey patch: UIKit must reserve only the total's intrinsic width before the checkbox. - let valueWidth = accessoryButtons[0].intrinsicContentSize.width - let width = trailingStack.widthAnchor.constraint(equalToConstant: valueWidth + 12 + 20) - width.isActive = true - selectorConstraints.append(width) - } - } - - override func updateSelection( - item: NativeListItem, - selected: Bool, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String - ) { - guard currentItem?.key == item.key else { return } - restoreSelectorTypography() - resetRowStyle() - defer { - applyRowStyle(item) - applySelectorTypography(item) - } - if item.type == "walletGroup" { - currentItem = item - let memberData = [item.data.dictionary("parent")].compactMap { $0 } - + item.data.dictionaries("children") - walletGroupMembers = memberData.compactMap { try? NativeListItem(data: $0) } - walletGroupMembers.enumerated().forEach { index, member in - if walletGroupCells.indices.contains(index) { - walletGroupCells[index].updateSelection( - item: member, - selected: member.data.bool("selected"), - checkboxState: checkboxState - ) - } - } - if let parent = walletGroupMembers.first { - walletGroupCompactCell?.updateSelection( - item: parent, - selected: parent.data.bool("selected"), - checkboxState: checkboxState - ) - } - restingBackgroundColor = nativeListColor(currentTheme, "subduedBackground", "#F9F9F9") - pressedBackgroundColor = restingBackgroundColor - updateBackgroundColor() - return - } - if item.type == "sectionHeader", item.data.string("variant") == "summary" { - updateSummaryText(item) - } - currentItem = item - restingBackgroundColor = selectionBackgroundColor( - item: item, - theme: currentTheme, - layout: currentLayout, - itemIndex: currentItemIndex, - selected: selected - ) - updateBackgroundColor() - if item.type == "identity", item.data.string("presentation") == "walletSidebar" { - titleLabel.textColor = nativeListColor( - currentTheme, - selected ? "primaryText" : "secondaryText", - selected ? "#FFFFFFED" : "#FFFFFFAF" - ) - } - // OneKey patch: retain the latest descriptor when a controlled echo avoids full binding. - if boundCheckboxData != nil { - let latestCheckbox = item.type == "identity" - ? item.data.dictionaries("trailing").last { $0.string("kind") == "checkbox" } - : item.data.dictionary("checkbox") - boundCheckboxData = latestCheckbox ?? boundCheckboxData - } - guard let data = boundCheckboxData, let target = boundCheckboxTarget else { return } - updateCheckboxPresentation( - item, - data, - target: target, - state: checkboxState(item, target, data.string("state", default: "unchecked")) - ) - } - - // OneKey patch: match SizableText TABULAR_NUMS on every selector text run, retaining its face and size. - private func selectorTabularFont(_ font: UIFont) -> UIFont { - var settings = font.fontDescriptor.fontAttributes[.featureSettings] as? [[UIFontDescriptor.FeatureKey: Int]] ?? [] - settings.removeAll { $0[.type] == kNumberSpacingType } - settings.append([.type: kNumberSpacingType, .selector: kMonospacedNumbersSelector]) - return UIFont(descriptor: font.fontDescriptor.addingAttributes([.featureSettings: settings]), size: font.pointSize) - } - - private func selectorTabularText(_ original: NSAttributedString) -> NSAttributedString { - let result = NSMutableAttributedString(attributedString: original) - // OneKey patch: body typography explicitly supplies letterSpacing=0 in Tamagui. - result.addAttribute(.kern, value: 0, range: NSRange(location: 0, length: result.length)) - original.enumerateAttribute(.font, in: NSRange(location: 0, length: original.length)) { value, range, _ in - if let font = value as? UIFont { result.addAttribute(.font, value: self.selectorTabularFont(font), range: range) } - } - return result - } - - private func restoreSelectorTypography() { - selectorTypographyRestorers.reversed().forEach { $0() } - selectorTypographyRestorers.removeAll() - } - - private func applySelectorTypography(_ item: NativeListItem) { - guard ["accountSelector", "networkSelector", "walletSidebar"].contains(item.data.string("presentation")) else { return } - func visit(_ view: UIView) { - if let button = view as? UIButton { - if let original = button.attributedTitle(for: .normal) { - selectorTypographyRestorers.append { button.setAttributedTitle(original, for: .normal) } - button.setAttributedTitle(selectorTabularText(original), for: .normal) - } - } else if let label = view as? UILabel, let font = label.font { - let original = label.attributedText - selectorTypographyRestorers.append { label.font = font; label.attributedText = original } - label.font = selectorTabularFont(font) - if let original { label.attributedText = selectorTabularText(original) } - } - for child in view.subviews { visit(child) } - } - visit(contentView) - } - - private func updateSummaryText(_ item: NativeListItem) { - let title = item.data.string("title") - titleLabel.isHidden = title.isEmpty - setLineHeight(titleLabel, text: title, lineHeight: 24) - - let value = item.data.string("value") - let isExplicitNetworkHeader = item.data.string("presentation") == "networkSelector" && item.data["height"] != nil - let valueButton = accessoryButtons[0] - valueButton.isHidden = value.isEmpty - if value.isEmpty { - valueButton.setTitle(nil, for: .normal) - valueButton.setAttributedTitle(nil, for: .normal) - } else { - setButtonLine( - valueButton, - text: value, - font: nativeListFont(ofSize: 16, weight: isExplicitNetworkHeader ? .medium : .regular), - color: nativeListColor(currentTheme, "secondaryText", "#646464"), - lineHeight: 24 - ) - } - } - - private func selectionBackgroundColor( - item: NativeListItem, - theme: [String: Any]?, - layout: String, - itemIndex: Int?, - selected: Bool - ) -> UIColor { - var color = nativeListColor( - theme, - selected ? "rowSelectedBackground" : "rowBackground", - selected ? "#F0F0F0" : "#FFFFFF" - ) - if layout == "sectioned", !item.data.bool("selected") { - color = nativeListColor(theme, "rowBackground", "#FFFFFF") - } - // OneKey patch: portfolio group headers retain their source background. - if let backgroundColor = item.data["backgroundColor"] as? String { - return UIColor(nativeListHex: backgroundColor, fallback: color) - } - return color - } - - /** - docs/STYLE_SPEC.md section 7 rule 2: reset() restores the fonts of the labels - every template uses, but not those of the data, metric and media labels, nor any - text alignment. A style that wrote one of those would leak into the next row that - reuses the cell, so put them back before the binder runs. - */ - private func resetRowStyle() { - guard styledViewsDirty else { return } - styledViewsDirty = false - styledTextViews.removeAll(keepingCapacity: true) - styledCircleViews.removeAll() - styledTextRestorations.reversed().forEach { $0() } - styledTextRestorations.removeAll(keepingCapacity: true) - } - - private func reset() { - restoreSelectorTypography() - resetRowStyle() - semanticBadgeLabels.removeAll(keepingCapacity: true) - semanticSubtitleLabels.removeAll(keepingCapacity: true) - semanticValueButtons.removeAll(keepingCapacity: true) - walletBadgeLine = nil - titleLabel.transform = .identity - // OneKey patch: remove selector decorations before rebinding recycled cells. - selectorViews.forEach { $0.removeFromSuperview() } - selectorViews.removeAll() - NSLayoutConstraint.deactivate(selectorConstraints) - selectorConstraints.removeAll() - selectorImages.forEach { $0.prepareForReuse() } - selectorImages.removeAll() - marketBadgeImages.forEach { $0.prepareForReuse(); $0.isHidden = true } - selectorFullWidthBackground.removeFromSuperlayer() - selectorBorder?.removeFromSuperlayer() - selectorBorder = nil - titleLabel.removeGestureRecognizer(selectorTitleTap) - titleLabel.isUserInteractionEnabled = false - walletGroupCompactCell?.invalidateCurrentBinding() - walletGroupCells.forEach { $0.invalidateCurrentBinding() } - isHighlighted = false - rootStack.arrangedSubviews.forEach { - rootStack.removeArrangedSubview($0) - $0.removeFromSuperview() - } - walletGroupMembers.removeAll() - // OneKey patch: reset has detached the old hierarchy, including on direct - // large-to-small binds that do not pass through UICollectionView reuse. - let requiredMembers = currentItem?.type == "walletGroup" ? (currentItem?.data.dictionaries("children").count ?? 0) + 1 : 0 - trimWalletGroupCells(keeping: requiredMembers) - walletGroupCompactAppearanceActive = false - walletGroupCompactContainer.isHidden = true - walletGroupCompactContainer.alpha = 1 - walletGroupDragBadge.text = nil - rootStack.isHidden = false - rootStack.alpha = 1 - walletGroupTap.isEnabled = false - contentView.layer.borderWidth = 0 - contentView.layer.borderColor = nil - rootStack.axis = .horizontal - rootStack.alignment = .center - rootStack.spacing = 12 - rootStack.setCustomSpacing(12, after: leadingActionButton) - rootStack.distribution = .fill - rootLeadingConstraint.constant = 12 - rootTrailingConstraint.constant = -12 - rootTopConstraint.constant = 8 - rootBottomConstraint.constant = -8 - mainStack.axis = .vertical - mainStack.alignment = .fill - mainStack.spacing = 2 - mainStack.isHidden = false - mainStack.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal) - mainStack.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - titleRowStack.isHidden = false - titleRowStack.spacing = 8 - trailingStack.isHidden = false - trailingStack.axis = .vertical - trailingStack.alignment = .trailing - trailingStack.spacing = 2 - headerTitleIconImageView.removeFromSuperview() - headerTitleIconImageView.image = nil - headerTitleIconImageView.isHidden = true - headerValueIconImageView.removeFromSuperview() - headerValueIconImageView.image = nil - headerValueIconImageView.isHidden = true - // OneKey patch: restore the shared labels before any recycled row binds. - marketSubtitleStack.arrangedSubviews.forEach { - marketSubtitleStack.removeArrangedSubview($0) - $0.removeFromSuperview() - } - mainStack.removeArrangedSubview(marketSubtitleStack) - marketSubtitleStack.removeFromSuperview() - mainStack.removeArrangedSubview(tertiaryLabel) - tertiaryLabel.removeFromSuperview() - mainStack.removeArrangedSubview(titleRowStack) - titleRowStack.removeFromSuperview() - mainStack.removeArrangedSubview(subtitleLabel) - subtitleLabel.removeFromSuperview() - mainStack.insertArrangedSubview(titleRowStack, at: 0) - mainStack.insertArrangedSubview(subtitleLabel, at: 1) - mainStack.insertArrangedSubview(tertiaryLabel, at: 2) - leadingWidth.constant = 40 - leadingHeight.constant = 40 - leadingIconWidth.constant = 18 - leadingIconHeight.constant = 18 - leadingWidth.isActive = true - leadingHeight.isActive = true - secondaryWidth.isActive = false - secondaryHeight.isActive = false - NSLayoutConstraint.deactivate(leadingSlotConstraints) - leadingSlotConstraints.removeAll() - NSLayoutConstraint.deactivate(accessorySizeConstraints) - accessorySizeConstraints.removeAll() - leadingImages.forEach { - $0.isHidden = true - $0.alpha = 1 - $0.layer.cornerRadius = 0 - $0.layer.mask = nil - } - leadingContainer.clipsToBounds = true - leadingContainer.layer.borderWidth = 0 - leadingContainer.layer.borderColor = nil - leadingOverlayBackground.isHidden = true - leadingIconImageView.isHidden = true - leadingIconImageView.image = nil - leadingCornerIconBackground.isHidden = true - leadingCornerIconImageView.image = nil - leadingActionButton.isHidden = true - leadingActionButton.setImage(nil, for: .normal) - leadingActionButton.setImage(nil, for: .disabled) - leadingActionButton.accessibilityIdentifier = nil - leadingActionButton.accessibilityLabel = nil - unreadDot.isHidden = true - fallbackLabel.isHidden = false - fallbackLabel.text = nil - fallbackLabel.font = nativeListFont(ofSize: 13, weight: .bold) - secondaryImage.isHidden = false - secondaryImage.layer.borderWidth = 0 - secondaryImage.layer.borderColor = nil - titleLabel.text = nil - titleLabel.font = nativeListFont(ofSize: 16, weight: .medium) - titleLabel.lineBreakMode = .byTruncatingTail - titleRowStack.setContentHuggingPriority(.defaultLow, for: .horizontal) - titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) - titleLabel.textAlignment = .natural - subtitleLabel.text = nil - subtitleLabel.lineBreakMode = .byTruncatingTail - subtitleLabel.attributedText = nil - subtitleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - subtitleLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - tertiaryLabel.attributedText = nil - tertiaryLabel.lineBreakMode = .byTruncatingTail - tertiaryLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - tertiaryLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - tertiaryLabel.text = nil - statusLabel.text = nil - statusLabel.topInset = 0 - statusLabel.bottomInset = 0 - badgeLabel.text = nil - badgeLabel.horizontalInset = 0 - badgeLabel.topInset = 0 - badgeLabel.bottomInset = 0 - subtitleLabel.font = nativeListFont(ofSize: 14) - tertiaryLabel.font = nativeListFont(ofSize: 14) - statusLabel.font = nativeListFont(ofSize: 12) - badgeLabel.font = nativeListFont(ofSize: 12, weight: .medium) - badgeLabel.backgroundColor = .clear - badgeLabel.layer.cornerRadius = 0 - badgeLabel.clipsToBounds = false - marketBadgeButtons.forEach { - $0.isHidden = true - $0.isEnabled = true - $0.isUserInteractionEnabled = false - // OneKey patch: a recycled badge must not retain an attributed title or font. - $0.setAttributedTitle(nil, for: .normal) - $0.marketLineHeight = nil - $0.selectorSummaryLineHeight = nil - $0.titleLabel?.font = nativeListFont(ofSize: 11, weight: .medium) - $0.titleLabel?.numberOfLines = 1 - $0.contentHorizontalAlignment = .center - $0.accessibilityLabel = nil - $0.accessibilityTraits = .staticText - $0.setTitle(nil, for: .normal) - $0.setImage(nil, for: .normal) - $0.setTitleColor(nil, for: .normal) - $0.tintColor = nil - $0.backgroundColor = .clear - $0.contentEdgeInsets = .zero - $0.imageEdgeInsets = .zero - $0.titleEdgeInsets = .zero - } - [titleLabel, subtitleLabel, tertiaryLabel, statusLabel, badgeLabel] - .forEach { $0.isHidden = true } - separatorView.isHidden = true - separatorLeadingConstraint.constant = 0 - accessoryButtons.enumerated().forEach { index, button in - button.isUserInteractionEnabled = true - button.selectorSummaryLineHeight = nil - button.marketLineHeight = nil - button.titleLabel?.font = nativeListFont( - ofSize: index == 0 ? 16 : 14, - weight: index == 0 ? .medium : .regular - ) - button.isHidden = true - button.accessibilityIdentifier = nil - button.setTitle(nil, for: .normal) - button.setAttributedTitle(nil, for: .normal) - button.setImage(nil, for: .normal) - button.setImage(nil, for: .disabled) - button.isEnabled = true - button.alpha = 1 - button.pressedBackgroundColor = nil - button.titleLabel?.numberOfLines = 1 - button.contentHorizontalAlignment = .center - button.backgroundColor = .clear - button.layer.cornerRadius = 0 - button.contentEdgeInsets = .zero - button.transform = .identity - } - checkboxButton.isHidden = true - checkboxButton.alpha = 1 - checkboxButton.backgroundColor = .clear - checkboxButton.isEnabled = true - checkboxButton.setImage(nil, for: .normal) - spinner.stopAnimating() - spinner.alpha = 1 - - accessoryActions = [] - checkboxAction = nil - boundCheckboxData = nil - boundCheckboxTarget = nil - leadingActionKey = nil - marketBadgeActionKeys = [] - layer.maskedCorners = [] - layer.cornerRadius = 0 - layer.cornerCurve = .circular - contentView.layer.cornerRadius = 0 - contentView.layer.borderWidth = 0 - contentView.layer.borderColor = nil - contentView.layer.cornerCurve = .circular - contentView.clipsToBounds = false - leadingContainer.alpha = 1 - titleLabel.showsDottedUnderline = false - titleLabel.reservesDottedUnderlineSpace = false - titleLabel.dottedUnderlineVerticalOffset = 0 - } - - private func bindWalletGroup( - _ item: NativeListItem, - theme: [String: Any]?, - layout: String, - _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String - ) { - let memberData = [item.data.dictionary("parent")].compactMap { $0 } - + item.data.dictionaries("children") - walletGroupMembers = memberData.compactMap { try? NativeListItem(data: $0) } - if walletGroupCompactCell == nil { - let compactCell = NativeListCell(frame: .zero) - compactCell.translatesAutoresizingMaskIntoConstraints = false - walletGroupCompactContainer.addSubview(compactCell) - walletGroupCompactContainer.addSubview(walletGroupDragBadge) - walletGroupDragBadge.translatesAutoresizingMaskIntoConstraints = false - walletGroupDragBadge.horizontalInset = 6 - walletGroupDragBadge.textAlignment = .center - walletGroupDragBadge.font = nativeListTabularFont(ofSize: 12, weight: .semibold) - walletGroupDragBadge.layer.cornerRadius = 12 - walletGroupDragBadge.layer.masksToBounds = true - walletGroupDragBadge.layer.borderWidth = 1 - NSLayoutConstraint.activate([ - compactCell.leadingAnchor.constraint(equalTo: walletGroupCompactContainer.leadingAnchor), - compactCell.trailingAnchor.constraint(equalTo: walletGroupCompactContainer.trailingAnchor), - compactCell.topAnchor.constraint(equalTo: walletGroupCompactContainer.topAnchor), - compactCell.bottomAnchor.constraint(equalTo: walletGroupCompactContainer.bottomAnchor), - walletGroupDragBadge.trailingAnchor.constraint( - equalTo: walletGroupCompactContainer.trailingAnchor, - constant: -4 - ), - walletGroupDragBadge.bottomAnchor.constraint( - equalTo: walletGroupCompactContainer.bottomAnchor, - constant: -4 - ), - walletGroupDragBadge.heightAnchor.constraint(equalToConstant: 24), - walletGroupDragBadge.widthAnchor.constraint(greaterThanOrEqualToConstant: 24), - ]) - walletGroupCompactCell = compactCell - } - while walletGroupCells.count < walletGroupMembers.count { - let memberCell = NativeListCell(frame: .zero) - memberCell.translatesAutoresizingMaskIntoConstraints = false - // OneKey patch: each member's current height is applied when bound. - // memberCell.heightAnchor.constraint(equalToConstant: 68).isActive = true - walletGroupCells.append(memberCell) - } - rootStack.axis = .vertical - rootStack.alignment = .fill - rootStack.spacing = 12 - rootLeadingConstraint.constant = 0 - rootTrailingConstraint.constant = 0 - rootTopConstraint.constant = 0 - rootBottomConstraint.constant = 0 - if memberData.first?["height"] != nil { - // OneKey patch: the source group's one-point border occupies layout space. - rootLeadingConstraint.constant = 1 - rootTrailingConstraint.constant = -1 - rootTopConstraint.constant = 1 - rootBottomConstraint.constant = -1 - } - walletGroupMembers.enumerated().forEach { index, member in - let memberCell = walletGroupCells[index] - // OneKey patch: badges add a second line within their logical wallet group. - memberCell.constraints.filter { $0.firstAttribute == .height && $0.secondItem == nil }.forEach { $0.isActive = false } - memberCell.heightAnchor.constraint(equalToConstant: member.styledHeight ?? CGFloat(member.data.double("height", default: member.data.dictionaries("badges").isEmpty ? 68 : 92))).isActive = true - memberCell.onAction = { [weak self] source, action, target, origin in - self?.onAction?(source, action, target, origin) - } - memberCell.onBindingInvalidated = { [weak self] cell, epoch in - self?.onBindingInvalidated?(cell, epoch) - } - memberCell.listStyle = listStyle - memberCell.bind( - item: member, - theme: theme, - layout: layout, - itemIndex: nil, - selected: member.data.bool("selected"), - checkboxState: checkboxState - ) - rootStack.addArrangedSubview(memberCell) - } - if let parent = walletGroupMembers.first { - walletGroupCompactCell?.listStyle = listStyle - walletGroupCompactCell?.bind( - item: parent, - theme: theme, - layout: layout, - itemIndex: nil, - selected: parent.data.bool("selected"), - checkboxState: checkboxState - ) - } - let childCount = item.data.dictionaries("children").filter { - ($0["draggable"] as? Bool) != false - }.count - walletGroupDragBadge.text = childCount > 0 ? "+\(childCount)" : nil - walletGroupDragBadge.isHidden = childCount == 0 - walletGroupDragBadge.backgroundColor = nativeListColor( - theme, - "inverseBackground", - "#202020" - ) - walletGroupDragBadge.textColor = nativeListColor(theme, "inverseText", "#FCFCFC") - walletGroupDragBadge.layer.borderColor = nativeListColor( - theme, - "rowBackground", - "#FFFFFF" - ).cgColor - restingBackgroundColor = nativeListColor(theme, "subduedBackground", "#F9F9F9") - pressedBackgroundColor = restingBackgroundColor - contentView.backgroundColor = restingBackgroundColor - contentView.layer.borderWidth = 1 - contentView.layer.borderColor = nativeListColor(theme, "separator", "#E0E0E0").cgColor - contentView.layer.cornerRadius = 20 - contentView.layer.cornerCurve = .continuous - contentView.clipsToBounds = true - walletGroupTap.isEnabled = true - } - - @objc private func walletGroupPressed(_ gesture: UITapGestureRecognizer) { - let point = gesture.location(in: rootStack) - for (index, cell) in walletGroupCells.prefix(walletGroupMembers.count).enumerated() - where cell.frame.contains(point) { - // OneKey patch: group member press gating must not disable accessory controls. - if !walletGroupMembers[index].data.bool("pressDisabled") { - onAction?(walletGroupMembers[index], "press", nil, cell.rowActionOrigin()) - } - return - } - } - - override func setPressed(_ pressed: Bool) { - if currentItem?.type == "walletGroup", walletGroupCompactAppearanceActive { - walletGroupCompactCell?.setPressed(pressed) - isHighlighted = false - return - } - isHighlighted = pressed && isUserInteractionEnabled - } - - override func canStartWalletGroupReorder(at point: CGPoint) -> Bool { - guard currentItem?.type == "walletGroup" else { return true } - let groupPoint = rootStack.convert(point, from: self) - for (index, cell) in walletGroupCells.prefix(walletGroupMembers.count).enumerated() - where cell.frame.contains(groupPoint) { - return (walletGroupMembers[index].data["draggable"] as? Bool) != false - } - return true - } - - override func setWalletGroupReorderCompact(_ compact: Bool) { - guard currentItem?.type == "walletGroup" else { return } - walletGroupCompactAppearanceActive = compact - rootStack.isHidden = compact - rootStack.alpha = compact ? 0 : 1 - walletGroupCompactContainer.isHidden = !compact - walletGroupCompactContainer.alpha = compact ? 1 : 0 - if compact { - contentView.backgroundColor = .clear - contentView.layer.borderWidth = 0 - contentView.layer.borderColor = nil - } else { - restoreWalletGroupOuterAppearance() - } - } - - override func prepareWalletGroupReorderExpansion() { - guard currentItem?.type == "walletGroup" else { return } - walletGroupCompactAppearanceActive = false - rootStack.isHidden = false - rootStack.alpha = 0 - walletGroupCompactContainer.isHidden = false - walletGroupCompactContainer.alpha = 1 - restoreWalletGroupOuterAppearance() - } - - override func animateWalletGroupReorderExpansion() { - guard currentItem?.type == "walletGroup" else { return } - rootStack.alpha = 1 - walletGroupCompactContainer.alpha = 0 - } - - override func finishWalletGroupReorderExpansion() { - guard currentItem?.type == "walletGroup" else { return } - rootStack.isHidden = false - rootStack.alpha = 1 - walletGroupCompactContainer.isHidden = true - walletGroupCompactContainer.alpha = 1 - restoreWalletGroupOuterAppearance() - } - - private func restoreWalletGroupOuterAppearance() { - contentView.backgroundColor = nativeListColor(currentTheme, "subduedBackground", "#F9F9F9") - contentView.layer.borderWidth = 1 - contentView.layer.borderColor = nativeListColor( - currentTheme, - "separator", - "#E0E0E0" - ).cgColor - } - - private func applyContainerAppearance() { - guard let item = currentItem, let style = item.data.dictionary("style")?.dictionary("container") else { return } - if item.type == "walletGroup" && walletGroupCompactAppearanceActive { return } - let pressed = isHighlighted && isUserInteractionEnabled - if !pressed, let background = style["backgroundColor"] as? String { - contentView.backgroundColor = UIColor(nativeListHex: background, fallback: restingBackgroundColor) - } - if let opacity = style["opacity"] as? Double { contentView.alpha = CGFloat(opacity) * (item.data.bool("disabled") ? 0.5 : 1) } - if style["cornerRadius"] != nil { - let radius = CGFloat(style.double("cornerRadius")) - layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] - contentView.layer.maskedCorners = layer.maskedCorners - layer.cornerRadius = radius - contentView.layer.cornerRadius = radius - contentView.clipsToBounds = radius > 0 - } - if style["borderWidth"] != nil { - contentView.layer.borderWidth = CGFloat(style.double("borderWidth")) - contentView.layer.borderColor = UIColor(nativeListHex: style.string("borderColor", default: "#00000000"), fallback: .clear).cgColor - } else if let color = style["borderColor"] as? String { - contentView.layer.borderColor = UIColor(nativeListHex: color, fallback: .clear).cgColor - } - } - - private func applyContainerAlignment(_ style: [String: Any]) { - guard let alignment = style["contentVerticalAlignment"] as? String else { return } - let stack = rootStack - let topConstraint = rootTopConstraint! - let bottomConstraint = rootBottomConstraint! - let original = rootStack.alignment - if rootStack.axis == .horizontal { - rootStack.alignment = alignment == "top" ? .top : alignment == "bottom" ? .bottom : .center - styledTextRestorations.append { stack.alignment = original } - } else { - let top = rootTopConstraint.constant - let bottom = -rootBottomConstraint.constant - rootTopConstraint.isActive = false - rootBottomConstraint.isActive = false - let position: NSLayoutConstraint - if alignment == "top" { position = rootStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: top) } - else if alignment == "bottom" { position = rootStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -bottom) } - else { position = rootStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor, constant: (top - bottom) / 2) } - position.isActive = true - styledTextRestorations.append { - position.isActive = false - topConstraint.isActive = true - bottomConstraint.isActive = true - } - } - } - - private func updateBackgroundColor() { - defer { applyContainerAppearance() } - let pressed = isHighlighted && isUserInteractionEnabled - contentView.backgroundColor = pressed ? pressedBackgroundColor : restingBackgroundColor - if currentItem?.type == "walletGroup" { - if walletGroupCompactAppearanceActive { - contentView.backgroundColor = .clear - contentView.layer.borderWidth = 0 - contentView.layer.borderColor = nil - } else { - restoreWalletGroupOuterAppearance() - } - layer.maskedCorners = [ - .layerMinXMinYCorner, - .layerMaxXMinYCorner, - .layerMinXMaxYCorner, - .layerMaxXMaxYCorner, - ] - layer.cornerRadius = 20 - layer.cornerCurve = .continuous - layer.masksToBounds = true - contentView.layer.cornerRadius = 20 - contentView.layer.cornerCurve = .continuous - contentView.clipsToBounds = true - return - } - - if pressed { - let isWalletSidebar = currentItem?.type == "identity" - && currentItem?.data.string("presentation") == "walletSidebar" - let radius: CGFloat - if isWalletSidebar { - radius = 20 - } else { - radius = 12 - } - layer.maskedCorners = [ - .layerMinXMinYCorner, - .layerMaxXMinYCorner, - .layerMinXMaxYCorner, - .layerMaxXMaxYCorner, - ] - layer.cornerRadius = radius - layer.cornerCurve = isWalletSidebar ? .continuous : .circular - layer.masksToBounds = true - contentView.layer.cornerRadius = radius - contentView.layer.cornerCurve = isWalletSidebar ? .continuous : .circular - contentView.clipsToBounds = true - } else { - applyGroupPosition(currentItem?.data.string("groupPosition") ?? "") - // OneKey patch: explicit account and network selectors preserve ListItem radius while idle. - let isSelectorIdentity = currentItem?.type == "identity" && currentItem?.data["height"] != nil - let isAccountSelector = isSelectorIdentity && ["accountSelector", "networkSelector"].contains(currentItem?.data.string("presentation") ?? "") - let isWalletSidebar = isSelectorIdentity && currentItem?.data.string("presentation") == "walletSidebar" - let restingRadius: CGFloat = isWalletSidebar ? 20 : isAccountSelector ? 12 : 0 - contentView.layer.cornerRadius = restingRadius - contentView.layer.cornerCurve = isWalletSidebar ? .continuous : .circular - contentView.clipsToBounds = restingRadius > 0 - } - } - - private func bindIdentity( - _ item: NativeListItem, - theme: [String: Any]?, - selected: Bool, - _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String - ) { - if item.data.string("presentation") == "walletSidebar" { - rootStack.axis = .vertical - rootStack.alignment = .center - rootStack.spacing = 4 - rootLeadingConstraint.constant = 4 - rootTrailingConstraint.constant = -4 - rootTopConstraint.constant = 4 - rootBottomConstraint.constant = -4 - mainStack.alignment = .center - mainStack.spacing = 0 - titleRowStack.setContentHuggingPriority(.required, for: .horizontal) - titleLabel.setContentHuggingPriority(.required, for: .horizontal) - titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) - titleLabel.font = nativeListFont(ofSize: 12) - titleLabel.textAlignment = .center - fallbackLabel.font = nativeListFont(ofSize: 28) - addLeading(item.data.dictionary("leading"), key: item.key) - rootStack.addArrangedSubview(mainStack) - // OneKey patch: activate width constraints only after both stacks share an ancestor. - if item.data["height"] != nil { - titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - titleRowStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - let width = mainStack.widthAnchor.constraint(equalTo: rootStack.widthAnchor) - width.isActive = true - selectorConstraints.append(width) - let titleWidth = titleRowStack.widthAnchor.constraint(lessThanOrEqualTo: mainStack.widthAnchor) - titleWidth.isActive = true - selectorConstraints.append(titleWidth) - } - show(titleLabel, item.data.string("title"), lines: 1) - setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 16) - titleLabel.textColor = nativeListColor( - theme, - selected ? "primaryText" : "secondaryText", - selected ? "#FFFFFFED" : "#FFFFFFAF" - ) - // OneKey patch: wallet tags belong below the centered name. - let badges = item.data.dictionaries("badges") - if !badges.isEmpty { - let line = UIStackView() - line.axis = .horizontal - line.spacing = 4 - line.alignment = .center - for badge in badges { - let label = NativeListInsetLabel() - let isSelector = item.data["height"] != nil - let isWarning = badge.string("tone") == "warning" - label.font = nativeListFont(ofSize: isSelector ? 11 : 12) - label.textColor = nativeListColor(theme, isSelector && isWarning ? "caution" : "secondaryText", isSelector && isWarning ? "#AB6400" : "#646464") - label.backgroundColor = nativeListColor(theme, isSelector ? (isWarning ? "cautionBackground" : "subduedBackground") : "strongBackground", isSelector && isWarning ? "#FFF8C5" : "#F0F0F0") - label.horizontalInset = isSelector ? 6 : 4 - label.topInset = 2 - label.bottomInset = 2 - label.layer.cornerRadius = 4 - label.clipsToBounds = true - setLineHeight(label, text: badge.string("text"), lineHeight: isSelector ? 14 : 16) - semanticBadgeLabels.append(label) - line.addArrangedSubview(label) - } - walletBadgeLine = line - mainStack.spacing = 4 - mainStack.addArrangedSubview(line) - selectorViews.append(line) - } - return - } - if item.data.string("presentation") == "accountSelector" { - leadingWidth.constant = 32 - leadingHeight.constant = 32 - titleLabel.font = nativeListFont(ofSize: 16) - } - if item.data.dictionary("leading")?.string("kind") == "network" { - leadingWidth.constant = 32 - leadingHeight.constant = 32 - } - if let leadingAction = item.data.dictionary("leadingAction") { - leadingActionButton.isHidden = false - let tintColor = UIColor( - nativeListHex: leadingAction.string("tintColor", default: "#646464"), - fallback: .darkGray - ) - leadingActionButton.tintColor = tintColor - if let image = nativeListIcon(named: leadingAction.string("name")) { - leadingActionButton.setImage(image, for: .normal) - leadingActionButton.setImage( - image.withTintColor(tintColor, renderingMode: .alwaysOriginal), - for: .disabled - ) - } - leadingActionButton.isEnabled = !leadingAction.bool("disabled") - leadingActionButton.alpha = leadingActionButton.isEnabled ? 1 : 0.4 - leadingActionKey = leadingAction.string("actionKey") - rootStack.addArrangedSubview(leadingActionButton) - // ListItem.IconButton is a medium tertiary button: its 36-point frame - // carries m=-7. The collection already contributes ListItem's outer - // mx=8, so move the frame 7 points into that inset and reduce only the - // following gap by 7. This preserves the source button, avatar and text - // positions without shrinking the 24-point SVG glyph or its hit target. - rootLeadingConstraint.constant = 5 - rootStack.setCustomSpacing(5, after: leadingActionButton) - } - addLeading(item.data.dictionary("leading"), key: item.key) - // OneKey patch: custom network initials match LetterAvatar size 32. - if item.data.string("presentation") == "networkSelector", let leading = item.data.dictionary("leading"), leading.dictionary("image") == nil, leading.dictionary("fallbackIcon") == nil, !leading.string("fallbackText").isEmpty { - fallbackLabel.font = nativeListFont(ofSize: 19, weight: .semibold) - fallbackLabel.textColor = nativeListColor(theme, "inverseText", "#FCFCFC") - setLineHeight(fallbackLabel, text: leading.string("fallbackText"), lineHeight: 27) - } - rootStack.addArrangedSubview(mainStack) - show(titleLabel, item.data.string("title"), lines: item.data.int("titleLines", default: 1)) - show(subtitleLabel, item.data.string("subtitle"), lines: item.data.int("subtitleLines", default: 1)) - show(tertiaryLabel, item.data.string("tertiary"), lines: 1) - if !item.data.string("subtitle").isEmpty, item.data.string("tertiary").isEmpty { - mainStack.spacing = 0 - setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) - setLineHeight(subtitleLabel, text: item.data.string("subtitle"), lineHeight: 20) - } - // OneKey patch: preserve independent balance/address truncation and warning tones. - let segments = item.data.dictionaries("subtitleSegments") - if !segments.isEmpty { - subtitleLabel.isHidden = true - mainStack.spacing = 0 - setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) - let line = UIStackView() - line.axis = .horizontal - line.alignment = .center - line.spacing = 0 - for segment in segments { - if segment.bool("separatorBefore") { - let gap = UIView() - gap.translatesAutoresizingMaskIntoConstraints = false - let dot = UIView() - dot.translatesAutoresizingMaskIntoConstraints = false - dot.backgroundColor = nativeListColor(theme, "disabledText", "#8D8D8D") - dot.layer.cornerRadius = 2 - gap.addSubview(dot) - NSLayoutConstraint.activate([ - gap.widthAnchor.constraint(equalToConstant: 16), - gap.heightAnchor.constraint(equalToConstant: 20), - dot.widthAnchor.constraint(equalToConstant: 4), - dot.heightAnchor.constraint(equalToConstant: 4), - dot.centerXAnchor.constraint(equalTo: gap.centerXAnchor), - dot.centerYAnchor.constraint(equalTo: gap.centerYAnchor), - ]) - line.addArrangedSubview(gap) - } - let label = NativeListTextLabel() - label.font = nativeListFont(ofSize: 14) - label.lineBreakMode = .byTruncatingTail - // Match V1: the balance may shrink, but keep the shortened address intact. - label.setContentCompressionResistancePriority( - item.data.string("presentation") == "accountSelector" && segment.bool("separatorBefore") - ? .defaultHigh - : .defaultLow, - for: .horizontal - ) - label.textColor = dataTextColor(segment.string("tone", default: "secondary"), theme: theme) - setLineHeight(label, text: segment.string("text"), lineHeight: 20) - let runs = segment.dictionaries("textSegments") - if !runs.isEmpty { - let value = NSMutableAttributedString(string: "") - let paragraph = NSMutableParagraphStyle() - paragraph.minimumLineHeight = 20 - paragraph.maximumLineHeight = 20 - for run in runs { - value.append(NSAttributedString(string: run.string("text"), attributes: [ - .font: nativeListFont(ofSize: run.string("style") == "subscript" ? 9 : 14), - .foregroundColor: label.textColor as Any, - .paragraphStyle: paragraph, - .baselineOffset: max(0, (20 - nativeListFont(ofSize: 14).lineHeight) / 2), - ])) - } - label.attributedText = value - } - semanticSubtitleLabels.append(label) - line.addArrangedSubview(label) - } - let filler = UIView() - filler.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal) - line.addArrangedSubview(filler) - mainStack.insertArrangedSubview(line, at: 2) - selectorViews.append(line) - } - let matches = item.data.dictionaries("titleMatch") - if !matches.isEmpty { - let text = NSMutableAttributedString(attributedString: titleLabel.attributedText ?? NSAttributedString(string: item.data.string("title"))) - for match in matches { - let start = match.int("start") - let end = match.int("end") - if start >= 0 && end > start && end <= text.length { - text.addAttribute(.foregroundColor, value: nativeListColor(theme, "info", "#0D74CE"), range: NSRange(location: start, length: end - start)) - } - } - titleLabel.attributedText = text - } - tertiaryLabel.textColor = nativeListColor( - theme, - item.data.string("tertiaryTone") == "info" ? "info" : "secondaryText", - item.data.string("tertiaryTone") == "info" ? "#0D74CE" : "#646464" - ) - let badges = item.data.dictionaries("badges").prefix(2).map { $0.string("text") } - if !badges.isEmpty { - let badgeText = badges.joined(separator: " · ") - show(badgeLabel, badgeText, lines: 1) - badgeLabel.horizontalInset = 8 - badgeLabel.topInset = 2 - badgeLabel.bottomInset = 2 - setLineHeight(badgeLabel, text: badgeText, lineHeight: 16) - badgeLabel.backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0") - badgeLabel.textColor = nativeListColor(theme, "secondaryText", "#646464") - badgeLabel.layer.cornerRadius = 4 - badgeLabel.clipsToBounds = true - } - rootStack.addArrangedSubview(trailingStack) - let accessories = item.data.dictionaries("trailing") - if accessories.contains(where: { $0.string("kind") == "checkbox" }) && - accessories.contains(where: { $0.string("kind") == "value" }) { - trailingStack.axis = .horizontal - trailingStack.alignment = .center - trailingStack.spacing = 12 - } else if accessories.count == 2, - accessories[0].string("kind") == "valuePair", - accessories[1].string("kind") == "menu" { - trailingStack.axis = .horizontal - trailingStack.alignment = .center - trailingStack.spacing = 8 - } else if accessories.count == 2, - accessories[0].string("kind") == "icon", - accessories[0].string("name") == "PencilOutline", - accessories[1].string("kind") == "icon", - accessories[1].string("name") == "DragOutline" { - trailingStack.axis = .horizontal - trailingStack.alignment = .center - // Each source IconButton has a 36-point frame and m=-7. XStack gap=$6 - // therefore places the physical frames 10 points apart (46-point - // center distance), not 24 points apart. - trailingStack.spacing = 10 - } - bindAccessories(item, accessories, theme, checkboxState) - if item.data.string("presentation") == "accountSelector", - accessories.count == 1, - let accessory = accessoryButtons.first(where: { !$0.isHidden }) { - // The vertical trailing stack has no stable intrinsic width, so keep its - // flexible space in the account title and subtitle column. - let width = trailingStack.widthAnchor.constraint(equalTo: accessory.widthAnchor) - width.isActive = true - selectorConstraints.append(width) - } - } - - - - private func marketFontWeight(_ value: String, fallback: NativeListFontWeight) -> NativeListFontWeight { - switch value { - case "regular": return .regular - case "semibold": return .semibold - case "bold": return .bold - case "medium": return .medium - default: return fallback - } - } - - private func marketTextAlignment(_ value: String) -> NSTextAlignment { - switch value { - case "center": return .center - case "start": return effectiveUserInterfaceLayoutDirection == .rightToLeft ? .right : .left - case "end": return effectiveUserInterfaceLayoutDirection == .rightToLeft ? .left : .right - default: return .natural - } - } - - /** - docs/STYLE_SPEC.md section 4. A style key names a model field, and the view pool - is shared, so the same field lands in a different view per template. This table - must stay in step with `nativeListStyleSlot` on Android. - */ - private func styleSlot(type: String, variant: String, field: String) -> String? { - switch type { - case "identity": - return ["title", "subtitle", "tertiary", "badge", "value", "valueSecondary"] - .contains(field) ? field : nil - case "sectionHeader": - return ["title", "subtitle", "value"].contains(field) ? field : nil - default: - return nil - } - } - - /** - docs/STYLE_SPEC.md section 8. Runs after the per-template binder, so it is the - last writer. Market owns its own richer path inside bindMarket. - */ - private func applyRowStyle(_ item: NativeListItem) { - guard let style = item.data.dictionary("style") else { return } - styledViewsDirty = true - defer { - applyContainerAppearance() - if var container = style.dictionary("container") { - if item.styledHeight != nil, rootStack.axis == .vertical, container["contentVerticalAlignment"] == nil { - container["contentVerticalAlignment"] = item.type == "identity" && item.data.string("presentation") == "walletSidebar" ? "center" : "top" - } - applyContainerAlignment(container) - } - } - if item.type == "market" { - applyStyledText(titleLabel, textLayoutStyle(style.dictionary("title"))) - applyStyledText(subtitleLabel, textLayoutStyle(style.dictionary("subtitle"))) - applyStyledText(tertiaryLabel, textLayoutStyle(item.data.dictionary("subtitlePrefix")?.dictionary("style"))) - updateMarketQuote(item, theme: currentTheme) - return - } - let constraints = [rootLeadingConstraint!, rootTrailingConstraint!, rootTopConstraint!, rootBottomConstraint!, leadingWidth!, leadingHeight!] - let constants = constraints.map { $0.constant } - let active = constraints.map { $0.isActive } - styledTextRestorations.append { - for (index, constraint) in constraints.enumerated() { - constraint.constant = constants[index] - constraint.isActive = active[index] - } - } - if style["horizontalPadding"] != nil { - let inset = CGFloat(style.double("horizontalPadding")) - rootLeadingConstraint.constant = inset - rootTrailingConstraint.constant = -inset - } - if style["verticalPadding"] != nil { - let inset = CGFloat(style.double("verticalPadding")) - rootTopConstraint.constant = inset - rootBottomConstraint.constant = -inset - } - if style["lineGap"] != nil, item.type != "walletGroup" { - let gap = CGFloat(style.double("lineGap")) - styleGap(mainStack, gap) - - } - let leadingSlot = leadingContainer - if style["leadingGap"] != nil, let stack = leadingSlot.superview as? UIStackView { - styleSpacing(stack, after: leadingSlot, CGFloat(style.double("leadingGap"))) - } - if style["titleBadgeGap"] != nil { - let gap = CGFloat(style.double("titleBadgeGap")) - if walletBadgeLine != nil { styleSpacing(mainStack, after: titleRowStack, gap) } - else { styleSpacing(titleRowStack, after: titleLabel, gap) } - } - if style["trailingGap"] != nil { - let gap = CGFloat(style.double("trailingGap")) - styleGap(trailingStack, gap) - } - if let image = style.dictionary("image"), leadingContainer.superview != nil { - if image["width"] != nil { - leadingWidth.constant = CGFloat(image.double("width")) - leadingWidth.isActive = true - } - if image["height"] != nil { - leadingHeight.constant = CGFloat(image.double("height")) - leadingHeight.isActive = true - } - let radius: CGFloat? = image["cornerRadius"] != nil ? CGFloat(image.double("cornerRadius")) - : image.string("shape") == "circle" ? min(leadingWidth.constant, leadingHeight.constant) / 2 - : image.string("shape") == "square" ? 0 - : image.string("shape") == "rounded" ? 10 : nil - if let radius { - let visual = item.data.dictionary("leading") ?? item.data.dictionary("visual") ?? [:] - let decorated = visual.dictionary("networkImage") != nil || !visual.dictionaries("overlays").isEmpty || visual.dictionary("cornerIcon") != nil || item.data.dictionary("secondaryLeading") != nil || visual.string("kind") == "stackedImages" - for view in [leadingContainer] + leadingImages.prefix(1).map({ $0 as UIView }) { - let oldRadius = view.layer.cornerRadius - let clips = view.clipsToBounds - styledTextRestorations.append { view.layer.cornerRadius = oldRadius; view.clipsToBounds = clips } - view.layer.cornerRadius = radius - view.clipsToBounds = view !== leadingContainer || !decorated - if image.string("shape") == "circle", image["cornerRadius"] == nil, view !== leadingContainer || !decorated { - let originalMask = view.layer.mask - styledTextRestorations.append { view.layer.mask = originalMask } - let mask = CAShapeLayer() - mask.path = UIBezierPath(ovalIn: view.bounds).cgPath - view.layer.mask = mask - styledCircleViews.append(view) - } - } - } - } - - - let variant = item.data.string("variant") - for (field, value) in style { - guard let slotStyle = value as? [String: Any], - let slot = styleSlot(type: item.type, variant: variant, field: field) - else { continue } - switch slot { - case "title": applyStyledText(titleLabel, slotStyle) - case "subtitle": (semanticSubtitleLabels.isEmpty ? [subtitleLabel] : semanticSubtitleLabels).forEach { applyStyledText($0, slotStyle) } - case "tertiary": applyStyledText(tertiaryLabel, slotStyle) - case "status": applyStyledText(statusLabel, slotStyle) - case "badge": (semanticBadgeLabels.isEmpty ? [badgeLabel] : semanticBadgeLabels).forEach { applyStyledText($0, slotStyle) } - case "value", "valueSecondary": - let index = slot == "value" ? 0 : 1 - let buttons = item.type == "identity" ? semanticValueButtons : accessoryButtons - if buttons.indices.contains(index) { applyStyledButton(buttons[index], slotStyle) } - default: break - } - } - } - - /** - The binders install an attributed string through setLineHeight, so assigning - `font` or `textColor` alone would not take effect. Rebuild the line box. - */ - private func styleSpacing(_ stack: UIStackView, after view: UIView, _ gap: CGFloat) { - guard stack.arrangedSubviews.contains(view) else { return } - let original = stack.customSpacing(after: view) - styledTextRestorations.append { stack.setCustomSpacing(original, after: view) } - stack.setCustomSpacing(gap, after: view) - } - - private func styleGap(_ stack: UIStackView, _ gap: CGFloat) { - let original = stack.spacing - styledTextRestorations.append { stack.spacing = original } - stack.spacing = gap - stack.arrangedSubviews.forEach { styleSpacing(stack, after: $0, gap) } - } - - private func applyStyledFont(_ text: NSMutableAttributedString, _ style: [String: Any], fallback: UIFont) { - guard style["fontSize"] != nil || style["fontWeight"] != nil else { return } - text.enumerateAttribute(.font, in: NSRange(location: 0, length: text.length)) { value, range, _ in - let original = (value as? UIFont) ?? fallback - let size = CGFloat(style.double("fontSize", default: Double(original.pointSize))) - let font = style["fontWeight"] == nil ? original.withSize(size) - : nativeListFont(ofSize: size, weight: marketFontWeight(style.string("fontWeight"), fallback: .regular)) - text.addAttribute(.font, value: font, range: range) - } - } - - private func textLayoutStyle(_ style: [String: Any]?) -> [String: Any] { - (style ?? [:]).filter { ["lines", "truncate", "verticalAlignment", "offsetY"].contains($0.key) } - } - - private func normalizeSingleLine(_ text: NSMutableAttributedString) { - let pattern = try! NSRegularExpression(pattern: "\\r\\n|[\\r\\n]") - for match in pattern.matches(in: text.string, range: NSRange(location: 0, length: text.length)).reversed() { - text.replaceCharacters(in: match.range, with: " ") - } - } - - private func styledLineBreakMode(_ style: [String: Any], lines: Int) -> NSLineBreakMode { - if style.string("truncate", default: "tail") != "clip" { return .byTruncatingTail } - // Clipping disables wrapping; multi-line clip must wrap without adding an ellipsis. - return lines == 1 ? .byClipping : .byWordWrapping - } - - private func applyStyledText(_ label: UILabel, _ style: [String: Any]) { - let text = label.attributedText?.string ?? label.text ?? "" - guard !text.isEmpty, !style.isEmpty else { return } - let originalFont = label.font - let originalColor = label.textColor - let originalText = label.attributedText - let originalLines = label.numberOfLines - let originalAlignment = label.textAlignment - let originalBreakMode = label.lineBreakMode - let textLabel = label as? NativeListTextLabel - let originalVertical = textLabel?.rowVerticalAlignment - let originalOffset = textLabel?.rowOffsetY ?? 0 - if styledTextViews.insert(ObjectIdentifier(label)).inserted { - styledTextRestorations.append { - label.font = originalFont - label.textColor = originalColor - label.attributedText = originalText - label.numberOfLines = originalLines - label.textAlignment = originalAlignment - label.lineBreakMode = originalBreakMode - textLabel?.rowVerticalAlignment = originalVertical - textLabel?.rowOffsetY = originalOffset - } - } - let baseFont = (originalText?.attribute(.font, at: 0, effectiveRange: nil) as? UIFont) ?? label.font ?? nativeListFont(ofSize: 14) - let size = CGFloat(style.double("fontSize", default: Double(baseFont.pointSize))) - let font: UIFont - if let weightName = style["fontWeight"] as? String { - font = nativeListFont( - ofSize: size, - weight: marketFontWeight(weightName, fallback: .regular) - ) - } else { - font = baseFont.withSize(size) - } - let color = (style["color"] as? String) - .map { UIColor(nativeListHex: $0, fallback: label.textColor ?? .black) } - ?? label.textColor ?? .black - if style["lines"] != nil { - label.numberOfLines = min(3, max(1, style.int("lines", default: 1))) - label.lineBreakMode = .byTruncatingTail - } - if let alignmentName = style["alignment"] as? String { - label.textAlignment = marketTextAlignment(alignmentName) - } - if style["truncate"] != nil || style["lines"] != nil { - label.lineBreakMode = styledLineBreakMode(style, lines: label.numberOfLines) - } - if let alignment = style["verticalAlignment"] as? String { textLabel?.rowVerticalAlignment = alignment } - if style["offsetY"] != nil { textLabel?.rowOffsetY = CGFloat(style.double("offsetY")) } - label.font = font - label.textColor = color - let attributed = originalText.map { NSMutableAttributedString(attributedString: $0) } - ?? NSMutableAttributedString(string: text) - let paragraph = (attributed.attribute(.paragraphStyle, at: 0, effectiveRange: nil) - as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() - if style["alignment"] != nil { paragraph.alignment = label.textAlignment } - if style["lines"] != nil || style["truncate"] != nil { paragraph.lineBreakMode = label.lineBreakMode } - var attributes: [NSAttributedString.Key: Any] = [:] - applyStyledFont(attributed, style, fallback: baseFont) - if style["color"] != nil { attributes[.foregroundColor] = color } - if style["alignment"] != nil || style["lines"] != nil || style["truncate"] != nil || style["lineHeight"] != nil { - attributes[.paragraphStyle] = paragraph - } - if style["lineHeight"] != nil { - let box = CGFloat(style.double("lineHeight")) - paragraph.minimumLineHeight = box - paragraph.maximumLineHeight = box - // React Native centers font metrics inside an explicit line height. - attributes[.baselineOffset] = max(0, (box - font.lineHeight) / 2) - } - attributed.addAttributes(attributes, range: NSRange(location: 0, length: attributed.length)) - if label.numberOfLines == 1 && (style["lines"] != nil || style["truncate"] != nil) { normalizeSingleLine(attributed) } - label.attributedText = attributed - } - - private func applyStyledButton(_ button: UIButton, _ style: [String: Any]) { - let text = button.attributedTitle(for: .normal)?.string - ?? button.title(for: .normal) - ?? "" - guard !text.isEmpty, !style.isEmpty else { return } - let originalFont = button.titleLabel?.font - let originalLines = button.titleLabel?.numberOfLines - let originalText = button.attributedTitle(for: .normal) - let originalAlignment = button.contentHorizontalAlignment - let originalVertical = button.contentVerticalAlignment - let originalBreakMode = button.titleLabel?.lineBreakMode - let originalOffset = (button as? NativeListAccessoryButton)?.rowTextOffsetY ?? 0 - let accessory = button as? NativeListAccessoryButton - let originalMarketLineHeight = accessory?.marketLineHeight - let originalSummaryLineHeight = accessory?.selectorSummaryLineHeight - if styledTextViews.insert(ObjectIdentifier(button)).inserted { - styledTextRestorations.append { - button.titleLabel?.font = originalFont - if let originalLines { button.titleLabel?.numberOfLines = originalLines } - button.setAttributedTitle(originalText, for: .normal) - button.contentHorizontalAlignment = originalAlignment - button.contentVerticalAlignment = originalVertical - if let originalBreakMode { button.titleLabel?.lineBreakMode = originalBreakMode } - (button as? NativeListAccessoryButton)?.rowTextOffsetY = originalOffset - accessory?.marketLineHeight = originalMarketLineHeight - accessory?.selectorSummaryLineHeight = originalSummaryLineHeight - } - } - let baseFont = (originalText?.attribute(.font, at: 0, effectiveRange: nil) as? UIFont) ?? button.titleLabel?.font ?? nativeListFont(ofSize: 14) - let size = CGFloat(style.double("fontSize", default: Double(baseFont.pointSize))) - let font: UIFont - if let weightName = style["fontWeight"] as? String { - font = nativeListFont( - ofSize: size, - weight: marketFontWeight(weightName, fallback: .regular) - ) - } else { - font = baseFont.withSize(size) - } - let fallbackColor = button.titleColor(for: .normal) ?? .black - let color = (style["color"] as? String) - .map { UIColor(nativeListHex: $0, fallback: fallbackColor) } - ?? fallbackColor - let attributed = button.attributedTitle(for: .normal) - .map { NSMutableAttributedString(attributedString: $0) } ?? NSMutableAttributedString(string: text) - let paragraph = (attributed.attribute(.paragraphStyle, at: 0, effectiveRange: nil) - as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() - if let alignmentName = style["alignment"] as? String { - paragraph.alignment = marketTextAlignment(alignmentName) - button.contentHorizontalAlignment = alignmentName == "start" - ? .leading - : alignmentName == "end" ? .trailing : .center - } - var attributes: [NSAttributedString.Key: Any] = [:] - applyStyledFont(attributed, style, fallback: baseFont) - if style["color"] != nil { attributes[.foregroundColor] = color } - if style["alignment"] != nil || style["lineHeight"] != nil || style["lines"] != nil || style["truncate"] != nil { - attributes[.paragraphStyle] = paragraph - } - if style["lineHeight"] != nil { - let box = CGFloat(style.double("lineHeight")) - paragraph.minimumLineHeight = box - paragraph.maximumLineHeight = box - attributes[.baselineOffset] = max(0, (box - font.lineHeight) / 2) - if accessory?.marketLineHeight != nil { accessory?.marketLineHeight = box } - if accessory?.selectorSummaryLineHeight != nil { accessory?.selectorSummaryLineHeight = box } - } - if style["lines"] != nil { - button.titleLabel?.numberOfLines = min(3, max(1, style.int("lines", default: 1))) - } - if style["lines"] != nil || style["truncate"] != nil { - let mode = styledLineBreakMode(style, lines: button.titleLabel?.numberOfLines ?? 1) - button.titleLabel?.lineBreakMode = mode - paragraph.lineBreakMode = mode - if button.titleLabel?.numberOfLines == 1 { normalizeSingleLine(attributed) } - } - if let alignment = style["verticalAlignment"] as? String { - button.contentVerticalAlignment = alignment == "top" ? .top : alignment == "bottom" ? .bottom : .center - } - if style["offsetY"] != nil { (button as? NativeListAccessoryButton)?.rowTextOffsetY = CGFloat(style.double("offsetY")) } - button.titleLabel?.font = font - attributed.addAttributes(attributes, range: NSRange(location: 0, length: attributed.length)) - button.setAttributedTitle(attributed, for: .normal) - } - - private func applyMarketTextStyle( - _ label: UILabel, - data: [String: Any]?, - theme: [String: Any]?, - defaultSize: CGFloat, - defaultLineHeight: CGFloat, - defaultWeight: NativeListFontWeight, - defaultColor: UIColor, - defaultAlignment: NSTextAlignment = .natural - ) { - let size = CGFloat(data?.double("fontSize", default: Double(defaultSize)) ?? Double(defaultSize)) - let lineHeight = CGFloat(data?.double("lineHeight", default: Double(defaultLineHeight)) ?? Double(defaultLineHeight)) - label.font = nativeListTabularFont(ofSize: size, weight: marketFontWeight(data?.string("fontWeight") ?? "", fallback: defaultWeight)) - label.textColor = data?["color"].flatMap { $0 as? String }.map { UIColor(nativeListHex: $0, fallback: defaultColor) } ?? defaultColor - label.textAlignment = data?["alignment"] == nil - ? defaultAlignment - : marketTextAlignment(data?.string("alignment") ?? "") - label.numberOfLines = min(3, max(1, data?.int("lines", default: 1) ?? 1)) - setLineHeight(label, text: label.text ?? "", lineHeight: lineHeight) - } - - private func applyMarketButtonStyle( - _ button: UIButton, - data: [String: Any]?, - defaultSize: CGFloat, - defaultLineHeight: CGFloat, - defaultWeight: NativeListFontWeight, - color: UIColor, - defaultAlignment: UIControl.ContentHorizontalAlignment - ) { - let size = CGFloat(data?.double("fontSize", default: Double(defaultSize)) ?? Double(defaultSize)) - let lineHeight = CGFloat(data?.double("lineHeight", default: Double(defaultLineHeight)) ?? Double(defaultLineHeight)) - setButtonLine( - button, - text: button.title(for: .normal) ?? "", - font: nativeListTabularFont(ofSize: size, weight: marketFontWeight(data?.string("fontWeight") ?? "", fallback: defaultWeight)), - color: data?["color"].flatMap { $0 as? String }.map { UIColor(nativeListHex: $0, fallback: color) } ?? color, - lineHeight: lineHeight - ) - if data?["alignment"] == nil { - button.contentHorizontalAlignment = defaultAlignment - } else { - button.contentHorizontalAlignment = data?.string("alignment") == "start" - ? .leading - : data?.string("alignment") == "end" ? .trailing : .center - } - button.titleLabel?.numberOfLines = min(3, max(1, data?.int("lines", default: 1) ?? 1)) - } - - private func marketAttributedText( - _ text: String, - segments: [[String: Any]], - style: [String: Any]?, - defaultSize: CGFloat, - defaultLineHeight: CGFloat, - defaultWeight: NativeListFontWeight, - color: UIColor, - defaultAlignment: NSTextAlignment - ) -> NSAttributedString { - let size = CGFloat(style?.double("fontSize", default: Double(defaultSize)) ?? Double(defaultSize)) - let lineHeight = CGFloat(style?.double("lineHeight", default: Double(defaultLineHeight)) ?? Double(defaultLineHeight)) - let paragraph = NSMutableParagraphStyle() - paragraph.minimumLineHeight = lineHeight - paragraph.maximumLineHeight = lineHeight - paragraph.alignment = style?["alignment"] == nil - ? defaultAlignment - : marketTextAlignment(style?.string("alignment") ?? "") - let weight = marketFontWeight(style?.string("fontWeight") ?? "", fallback: defaultWeight) - let resolvedColor = style?["color"].flatMap { $0 as? String }.map { UIColor(nativeListHex: $0, fallback: color) } ?? color - let result = NSMutableAttributedString(string: "") - let source = segments.isEmpty ? [["text": text]] : segments - for segment in source { - let segmentSize = style?["fontSize"] == nil && segment.string("style") == "subscript" ? ceil(size * 0.6) : size - result.append(NSAttributedString(string: segment.string("text"), attributes: [ - .font: nativeListTabularFont(ofSize: segmentSize, weight: weight), - .foregroundColor: resolvedColor, - .kern: 0, - .paragraphStyle: paragraph, - .baselineOffset: max(0, (lineHeight - nativeListTabularFont(ofSize: size, weight: weight).lineHeight) / 2), - ])) - } - return result - } - - private func marketLeading(_ item: NativeListItem, style: [String: Any]?) -> [String: Any]? { - guard var visual = item.data.dictionary("leading") else { return nil } - guard let imageStyle = style?.dictionary("image") else { return visual } - if let shape = imageStyle["shape"] as? String { visual["shape"] = shape } - if let contentFit = imageStyle["contentFit"] as? String, var image = visual.dictionary("image") { - image["contentFit"] = contentFit - visual["image"] = image - } - return visual - } - - private func bindMarket(_ item: NativeListItem, theme: [String: Any]?) { - let variant = item.data.string("variant") - let style = item.data.dictionary("style") - let imageStyle = style?.dictionary("image") - let imageWidth = CGFloat(imageStyle?.double("width", default: variant == "stock" ? 40 : 32) ?? (variant == "stock" ? 40 : 32)) - let imageHeight = CGFloat(imageStyle?.double("height", default: variant == "stock" ? 40 : 32) ?? (variant == "stock" ? 40 : 32)) - let horizontalPadding = CGFloat(style?.double("horizontalPadding", default: variant == "perp" ? 16 : 20) ?? (variant == "perp" ? 16 : 20)) - let verticalPadding = CGFloat(style?.double("verticalPadding", default: 12) ?? 12) - rootLeadingConstraint.constant = horizontalPadding - rootTrailingConstraint.constant = -horizontalPadding - rootTopConstraint.constant = verticalPadding - rootBottomConstraint.constant = -verticalPadding - rootStack.spacing = CGFloat(style?.double("leadingGap", default: variant == "perp" ? 8 : 14) ?? (variant == "perp" ? 8 : 14)) - if let leadingAction = item.data.dictionary("leadingAction") { - leadingActionButton.isHidden = false - let tintColor = UIColor( - nativeListHex: leadingAction.string("tintColor", default: "#646464"), - fallback: .darkGray - ) - leadingActionButton.tintColor = tintColor - if let image = nativeListIcon(named: leadingAction.string("name")) { - leadingActionButton.setImage(image, for: .normal) - leadingActionButton.setImage( - image.withTintColor(tintColor, renderingMode: .alwaysOriginal), - for: .disabled - ) - } - leadingActionButton.isEnabled = !leadingAction.bool("disabled") - leadingActionButton.alpha = leadingActionButton.isEnabled ? 1 : 0.4 - leadingActionButton.accessibilityIdentifier = leadingAction["testID"] as? String - leadingActionButton.accessibilityLabel = leadingAction["accessibilityLabel"] as? String - leadingActionKey = leadingAction.string("actionKey") - rootStack.addArrangedSubview(leadingActionButton) - rootStack.setCustomSpacing(5, after: leadingActionButton) - } - leadingWidth.constant = imageWidth - leadingHeight.constant = imageHeight - if let visual = marketLeading(item, style: style) { - addLeading(visual, key: item.key) - let radius = CGFloat(imageStyle?.double("cornerRadius", default: imageStyle?.string("shape") == "square" ? 0 : imageStyle?.string("shape") == "rounded" ? 8 : Double(min(imageWidth, imageHeight) / 2)) ?? Double(min(imageWidth, imageHeight) / 2)) - leadingContainer.layer.cornerRadius = radius - leadingImages.first?.layer.cornerRadius = radius - if let image = leadingImages.first, !visual.string("borderColor").isEmpty { - // Match Token's border box: the bitmap occupies the one-point inset. - leadingContainer.layer.borderWidth = 1 - leadingContainer.layer.borderColor = UIColor(nativeListHex: visual.string("borderColor"), fallback: .clear).cgColor - for constraint in leadingSlotConstraints where constraint.firstItem === image { - switch constraint.firstAttribute { - case .leading, .top: constraint.constant = 1 - case .trailing, .bottom: constraint.constant = -1 - default: break - } - } - image.layer.cornerRadius = 0 - let mask = CAShapeLayer() - mask.path = UIBezierPath(roundedRect: CGRect(x: -1, y: -1, width: imageWidth, height: imageHeight), cornerRadius: radius).cgPath - image.layer.mask = mask - } - if let diagnostic = item.data.dictionary("diagnostics")?.string("imageBindActionKey"), !diagnostic.isEmpty, - visual.dictionary("image") != nil || visual.dictionary("networkImage") != nil { - onAction?(item, diagnostic, nil, nil) - } - } - rootStack.addArrangedSubview(mainStack) - // OneKey patch: opt in to the source Market row's content gap. - // rootStack.setCustomSpacing(0, after: mainStack) - rootStack.setCustomSpacing(CGFloat(style?.double("contentTrailingGap", default: 0) ?? 0), after: mainStack) - mainStack.spacing = CGFloat(style?.double("lineGap", default: 0) ?? 0) - titleRowStack.spacing = CGFloat(style?.double("titleBadgeGap", default: 4) ?? 4) - // OneKey patch: opt in without changing the other row templates' filled layout. - if style?.string("titleBadgeLayout") == "inline" { - mainStack.alignment = .leading - titleRowStack.setContentHuggingPriority(.required, for: .horizontal) - } - show(titleLabel, item.data.string("title"), lines: style?.dictionary("title")?.int("lines", default: 1) ?? 1) - applyMarketTextStyle(titleLabel, data: style?.dictionary("title"), theme: theme, defaultSize: 16, defaultLineHeight: 24, defaultWeight: .medium, defaultColor: nativeListColor(theme, "primaryText", "#202020")) - let badges = Array(item.data.dictionaries("badges").prefix(marketBadgeButtons.count)) - marketBadgeActionKeys = badges.map { $0["actionKey"] as? String } - for (index, badge) in badges.enumerated() { - let button = marketBadgeButtons[index] - let badgeStyle = badge.dictionary("style") - let badgeFontSize = CGFloat(badgeStyle?.double("fontSize", default: 11) ?? 11) - let badgeFontWeight = marketFontWeight(badgeStyle?.string("fontWeight") ?? "", fallback: .medium) - // OneKey patch: SizableText supplies tabular numerals for explicit Market metrics. - let badgeFont = badgeStyle == nil - ? nativeListFont(ofSize: badgeFontSize, weight: badgeFontWeight) - : nativeListTabularFont(ofSize: badgeFontSize, weight: badgeFontWeight) - let hasBuiltInIcon = badge.string("iconName") == "verified" - let hasRemoteIcon = badge.dictionary("icon") != nil - let hasIcon = hasBuiltInIcon || hasRemoteIcon - let text = badge.string("text") - let toneColor: UIColor - switch badge.string("tone") { - case "success": toneColor = nativeListColor(theme, "positive", "#218358") - case "danger": toneColor = nativeListColor(theme, "negative", "#CE2C31") - case "info": toneColor = nativeListColor(theme, "info", "#0D74CE") - case "warning": toneColor = nativeListColor(theme, "primaryText", "#202020") - default: toneColor = nativeListColor(theme, "secondaryText", "#646464") - } - let foreground = UIColor( - nativeListHex: badge.string("textColor", default: ""), - fallback: toneColor - ) - button.isHidden = false - button.isEnabled = true - button.isUserInteractionEnabled = !badge.string("actionKey").isEmpty - button.accessibilityTraits = button.isUserInteractionEnabled ? .button : .staticText - button.setTitle(text, for: .normal) - button.setTitleColor(foreground, for: .normal) - button.titleLabel?.font = badgeFont - if let lineHeight = badgeStyle?["lineHeight"] as? Double { - setButtonLine(button, text: text, font: badgeFont, color: foreground, lineHeight: CGFloat(lineHeight)) - // The explicit text-only line box must not cover an adjacent icon. - if hasIcon { button.marketLineHeight = nil } - } - button.tintColor = foreground - button.backgroundColor = UIColor( - nativeListHex: badge.string("backgroundColor", default: ""), - fallback: hasIcon && text.isEmpty - ? .clear - : nativeListColor(theme, "strongBackground", "#0000000F") - ) - let iconOnly = hasIcon && text.isEmpty - let iconSize: CGFloat = hasBuiltInIcon ? 16 : 14 - // OneKey patch: preserve the native defaults unless the caller supplies padding. - let padding = CGFloat(badgeStyle?.double("horizontalPadding", default: 5) ?? 5) - let hasCustomPadding = badgeStyle?["horizontalPadding"] != nil - let leftPadding = hasCustomPadding ? padding + (hasRemoteIcon ? iconSize + 2 : 0) : hasRemoteIcon ? 20 : hasIcon ? 3 : 5 - // button.contentEdgeInsets = UIEdgeInsets(top: 0, left: iconOnly ? 0 : hasRemoteIcon ? 20 : hasIcon ? 3 : 5, bottom: 0, right: iconOnly ? 0 : 5) - button.contentEdgeInsets = UIEdgeInsets(top: 0, left: iconOnly ? 0 : leftPadding, bottom: 0, right: iconOnly ? 0 : padding) - button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: text.isEmpty ? 0 : 3) - button.titleEdgeInsets = .zero - button.imageView?.contentMode = .scaleAspectFit - if hasBuiltInIcon { - button.setImage( - nativeListIcon(named: "BadgeVerifiedSolid", size: CGSize(width: iconSize, height: iconSize)), - for: .normal - ) - } - // OneKey patch: match source badge metrics at physical-pixel precision. - // let height = button.heightAnchor.constraint(equalToConstant: 18) - let height = button.heightAnchor.constraint(equalToConstant: CGFloat(badgeStyle?.double("height", default: 18) ?? 18)) - let textWidth = (text as NSString).size(withAttributes: [.font: badgeFont]).width - let scale = max(1, traitCollection.displayScale) - let roundedTextWidth = badgeStyle == nil ? ceil(textWidth) : ceil(textWidth * scale) / scale - let extraWidth = hasCustomPadding ? padding * 2 + (hasIcon ? iconSize + 2 : 0) : hasIcon ? iconSize + 11 : 10 - // let width = button.widthAnchor.constraint(equalToConstant: iconOnly ? iconSize : ceil(textWidth) + (hasIcon ? iconSize + 11 : 10)) - let width = button.widthAnchor.constraint(equalToConstant: iconOnly ? iconSize : roundedTextWidth + extraWidth) - NSLayoutConstraint.activate([width, height]) - selectorConstraints.append(contentsOf: [width, height]) - if let icon = badge.dictionary("icon") { - marketBadgeImages[index].isHidden = false - marketBadgeImages[index].layer.cornerRadius = 7 - marketBadgeImages[index].clipsToBounds = true - let imageLeading = marketBadgeImages[index].leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: text.isEmpty ? 0 : 4) - imageLeading.isActive = true - selectorConstraints.append(imageLeading) - bindImage(icon, into: marketBadgeImages[index], token: item.key, slot: 20 + index, variant: "generic") - } - button.accessibilityLabel = badge.string("accessibilityLabel", default: badge.string("text")) - } - if !item.data.string("subtitle").isEmpty || !item.data.dictionaries("subtitleSegments").isEmpty { - show(subtitleLabel, item.data.string("subtitle"), lines: style?.dictionary("subtitle")?.int("lines", default: 1) ?? 1) - applyMarketTextStyle(subtitleLabel, data: style?.dictionary("subtitle"), theme: theme, defaultSize: 14, defaultLineHeight: 20, defaultWeight: .regular, defaultColor: nativeListColor(theme, "secondaryText", "#646464")) - if !item.data.dictionaries("subtitleSegments").isEmpty { - subtitleLabel.attributedText = marketAttributedText(item.data.string("subtitle"), segments: item.data.dictionaries("subtitleSegments"), style: style?.dictionary("subtitle"), defaultSize: 14, defaultLineHeight: 20, defaultWeight: .regular, color: nativeListColor(theme, "secondaryText", "#646464"), defaultAlignment: .natural) - } - } - // OneKey patch: preserve volume width while the localized name truncates. - let subtitlePrefix = item.data.dictionary("subtitlePrefix") - let subtitlePadding = CGFloat(style?.double("subtitleTrailingPadding", default: 0) ?? 0) - if subtitlePrefix != nil || subtitlePadding > 0 { - mainStack.removeArrangedSubview(subtitleLabel) - subtitleLabel.removeFromSuperview() - mainStack.removeArrangedSubview(tertiaryLabel) - tertiaryLabel.removeFromSuperview() - marketSubtitleStack.axis = .horizontal - marketSubtitleStack.alignment = .center - marketSubtitleStack.spacing = 0 - marketSubtitleStack.clipsToBounds = true - show(tertiaryLabel, subtitlePrefix?.string("text") ?? "", lines: 1) - applyMarketTextStyle(tertiaryLabel, data: subtitlePrefix?.dictionary("style"), theme: theme, defaultSize: 12, defaultLineHeight: 16, defaultWeight: .regular, defaultColor: nativeListColor(theme, "secondaryText", "#646464")) - tertiaryLabel.setContentHuggingPriority(.required, for: .horizontal) - tertiaryLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - subtitleLabel.setContentHuggingPriority(.required, for: .horizontal) - subtitleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) - marketSubtitleStack.addArrangedSubview(tertiaryLabel) - marketSubtitleStack.addArrangedSubview(subtitleLabel) - marketSubtitleStack.addArrangedSubview(marketSubtitleSpacer) - if !tertiaryLabel.isHidden && !subtitleLabel.isHidden { - marketSubtitleStack.setCustomSpacing(CGFloat(subtitlePrefix?.double("gap", default: 4) ?? 4), after: tertiaryLabel) - } - mainStack.insertArrangedSubview(marketSubtitleStack, at: 1) - let width = marketSubtitleStack.widthAnchor.constraint(equalTo: mainStack.widthAnchor, constant: -subtitlePadding) - width.isActive = true - selectorConstraints.append(width) - if let maxWidth = subtitlePrefix?["maxWidth"] as? Double { - let limit = tertiaryLabel.widthAnchor.constraint(lessThanOrEqualToConstant: CGFloat(maxWidth)) - limit.isActive = true - selectorConstraints.append(limit) - } - marketSubtitleStack.isHidden = tertiaryLabel.isHidden && subtitleLabel.isHidden - } - rootStack.addArrangedSubview(trailingStack) - trailingStack.axis = .horizontal - trailingStack.alignment = .center - trailingStack.spacing = CGFloat(style?.double("trailingGap", default: 8) ?? 8) - updateMarketQuote(item, theme: theme) - } - - override func updateMarketQuote(_ item: NativeListItem, theme: [String: Any]?) { - guard currentItem?.key == item.key, item.type == "market" else { return } - currentItem = item - NSLayoutConstraint.deactivate(accessorySizeConstraints) - accessorySizeConstraints.removeAll() - let style = item.data.dictionary("style") - let price = accessoryButtons[0] - price.isUserInteractionEnabled = false - price.isHidden = false - price.setAttributedTitle(nil, for: .normal) - price.setTitle(item.data.string("price"), for: .normal) - applyMarketButtonStyle(price, data: style?.dictionary("price"), defaultSize: 16, defaultLineHeight: 24, defaultWeight: .medium, color: nativeListColor(theme, "primaryText", "#202020"), defaultAlignment: .trailing) - if !item.data.dictionaries("priceSegments").isEmpty { - price.setAttributedTitle(marketAttributedText(item.data.string("price"), segments: item.data.dictionaries("priceSegments"), style: style?.dictionary("price"), defaultSize: 16, defaultLineHeight: 24, defaultWeight: .medium, color: nativeListColor(theme, "primaryText", "#202020"), defaultAlignment: marketTextAlignment("end")), for: .normal) - } - let changeData = item.data.dictionary("change") ?? [:] - let change = accessoryButtons[1] - change.isUserInteractionEnabled = false - change.isHidden = false - change.setAttributedTitle(nil, for: .normal) - change.setTitle(changeData.string("text"), for: .normal) - let defaultChangeColor = nativeListColor(theme, "inverseText", "#FFFFFF") - applyMarketButtonStyle(change, data: style?.dictionary("change"), defaultSize: 14, defaultLineHeight: 20, defaultWeight: .medium, color: UIColor(nativeListHex: changeData.string("textColor", default: "#FFFFFF"), fallback: defaultChangeColor), defaultAlignment: .center) - if !changeData.dictionaries("textSegments").isEmpty { - let changeTextColor = UIColor(nativeListHex: changeData.string("textColor", default: ""), fallback: defaultChangeColor) - change.setAttributedTitle(marketAttributedText(changeData.string("text"), segments: changeData.dictionaries("textSegments"), style: style?.dictionary("change"), defaultSize: 14, defaultLineHeight: 20, defaultWeight: .medium, color: changeTextColor, defaultAlignment: .center), for: .normal) - } - let toneKey = changeData.string("tone") == "positive" ? "positive" : changeData.string("tone") == "negative" ? "negative" : "secondaryText" - change.backgroundColor = UIColor(nativeListHex: changeData.string("backgroundColor", default: ""), fallback: nativeListColor(theme, toneKey, changeData.string("tone") == "positive" ? "#218358" : changeData.string("tone") == "negative" ? "#CE2C31" : "#8D8D8D")) - change.layer.cornerRadius = CGFloat(style?.double("changeCornerRadius", default: 8) ?? 8) - change.clipsToBounds = true - let width = change.widthAnchor.constraint(equalToConstant: CGFloat(style?.double("changeWidth", default: 80) ?? 80)) - let height = change.heightAnchor.constraint(equalToConstant: CGFloat(style?.double("changeHeight", default: 32) ?? 32)) - width.isActive = true - height.isActive = true - accessorySizeConstraints.append(contentsOf: [width, height]) - if let priceStyle = style?.dictionary("price"), !textLayoutStyle(priceStyle).isEmpty { - styledViewsDirty = true - applyStyledButton(price, textLayoutStyle(priceStyle)) - } - if let changeStyle = style?.dictionary("change"), !textLayoutStyle(changeStyle).isEmpty { - styledViewsDirty = true - applyStyledButton(change, textLayoutStyle(changeStyle)) - } - accessibilityLabel = item.data.string("accessibilityLabel", default: [item.data.string("title"), item.data.string("subtitle"), item.data.string("price"), changeData.string("text")].filter { !$0.isEmpty }.joined(separator: ", ")) - } - - private func bindSectionHeader( - _ item: NativeListItem, - theme: [String: Any]?, - layout: String, - _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String - ) { - rootStack.addArrangedSubview(mainStack) - let variant = item.data.string("variant") - // OneKey patch: title help is a separate target from checkbox and value actions. - if !item.data.string("titleActionKey").isEmpty { - titleLabel.isUserInteractionEnabled = true - titleLabel.addGestureRecognizer(selectorTitleTap) - titleLabel.setContentHuggingPriority(.required, for: .horizontal) - } - let isSummary = variant == "summary" - let isGallery = variant == "gallery" - let isTable = layout == "table" - let isNetworkSelector = item.data.string("presentation") == "networkSelector" - let isExplicitNetworkHeader = isNetworkSelector && item.data["height"] != nil - if isExplicitNetworkHeader { - // OneKey patch: the flexible title consumes spare space before trailing totals. - trailingStack.setContentHuggingPriority(.required, for: .horizontal) - trailingStack.setContentCompressionResistancePriority(.required, for: .horizontal) - } - titleLabel.reservesDottedUnderlineSpace = isExplicitNetworkHeader && !item.data.string("titleActionKey").isEmpty - let isHistory = variant == "history" || - item.key.hasPrefix("history-") || - (item.sectionKey?.hasPrefix("history-") ?? false) - let headerWeight: NativeListFontWeight = isSummary - ? .medium - : isExplicitNetworkHeader && (item.data.dictionary("checkbox") != nil || item.data.string("titleActionKey").isEmpty) ? .semibold - : isNetworkSelector ? .medium - : isGallery || layout == "sectioned" ? .semibold : .regular - titleLabel.font = nativeListFont( - ofSize: isGallery ? 18 : isSummary ? 16 : isTable ? 11 : isHistory ? 12 : 14, - weight: isHistory ? .semibold : headerWeight - ) - titleLabel.textColor = nativeListColor( - theme, - isSummary || isGallery ? "primaryText" : "secondaryText", - isSummary || isGallery ? "#202020" : "#646464" - ) - show(titleLabel, item.data.string("title"), lines: 1) - if isNetworkSelector { - titleLabel.showsDottedUnderline = !isExplicitNetworkHeader || !item.data.string("titleActionKey").isEmpty - titleLabel.dottedUnderlineVerticalOffset = isExplicitNetworkHeader ? 1 : 2 - titleLabel.dottedUnderlineColor = nativeListColor( - theme, - "secondaryText", - "#646464" - ) - rootLeadingConstraint.constant = 12 - rootTrailingConstraint.constant = -12 - let isAlphabet = isExplicitNetworkHeader && item.data.string("titleActionKey").isEmpty - rootTopConstraint.constant = isAlphabet ? 8 : 12 - rootBottomConstraint.constant = isAlphabet ? -8 : isExplicitNetworkHeader ? -12 : -15 - setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20) - } else if isHistory { - rootLeadingConstraint.constant = 0 - rootTrailingConstraint.constant = 0 - rootTopConstraint.constant = 0 - rootBottomConstraint.constant = 0 - setLineHeight( - titleLabel, - text: item.data.string("title").uppercased(), - lineHeight: 16, - letterSpacing: 0.8 - ) - } else if !isTable && !isGallery && !isSummary { - setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20) - if layout == "linear" { - // TokenManager's section title is a plain body label with mt=10, - // unlike the reusable 36-point SectionHeader used by sectioned lists. - titleLabel.font = nativeListFont(ofSize: 14) - rootTopConstraint.constant = 10 - rootBottomConstraint.constant = 0 - } - } - if isTable { - mainStack.alignment = .leading - titleRowStack.spacing = 4 - titleLabel.setContentHuggingPriority(.required, for: .horizontal) - setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 14) - rootTopConstraint.constant = 12 - rootBottomConstraint.constant = -2 - if let icon = item.data.dictionary("titleIcon") { - headerTitleIconImageView.image = nativeListIcon(named: icon.string("name")) - headerTitleIconImageView.tintColor = UIColor( - nativeListHex: icon.string("tintColor", default: "#646464"), - fallback: .darkGray - ) - headerTitleIconImageView.isHidden = false - titleRowStack.insertArrangedSubview(headerTitleIconImageView, at: 1) - if icon.string("name") != "ChevronGrabberVerOutline" { - titleLabel.textColor = nativeListColor(theme, "primaryText", "#202020") - setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 14) - } - } - } - if isGallery { - setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) - rootTopConstraint.constant = 0 - rootBottomConstraint.constant = -8 - } - show(subtitleLabel, item.data.string("subtitle"), lines: 1) - if isSummary { - titleLabel.showsDottedUnderline = true - titleLabel.dottedUnderlineColor = nativeListColor( - theme, - "secondaryText", - "#646464" - ) - rootStack.addArrangedSubview(trailingStack) - // The surrounding Stack contributes mt=16/pb=12 while its XStack uses - // px=20/py=8. The collection's 8-point inset supplies the outer 8 points - // of horizontal padding, so the cell supplies the remaining 12. - rootLeadingConstraint.constant = 12 - rootTrailingConstraint.constant = -12 - rootTopConstraint.constant = 24 - rootBottomConstraint.constant = -20 - setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) - accessoryButtons[0].accessibilityIdentifier = item.data["valueActionTestID"] as? String - let valueActionKey = item.data.string("valueActionKey") - let action: (String, NativeSelectionTarget?)? = valueActionKey.isEmpty - ? nil - : (valueActionKey, nil) - showAccessory( - 0, - item.data.string("value"), - action: action, - color: nativeListColor(theme, "secondaryText", "#646464") - ) - accessoryButtons[0].titleLabel?.font = nativeListFont(ofSize: 16, weight: isExplicitNetworkHeader ? .medium : .regular) - setButtonLine( - accessoryButtons[0], - text: item.data.string("value"), - font: nativeListFont(ofSize: 16, weight: isExplicitNetworkHeader ? .medium : .regular), - color: nativeListColor(theme, "secondaryText", "#646464"), - lineHeight: 24 - ) - } else if !isGallery && !isHistory { - rootStack.addArrangedSubview(trailingStack) - showAccessory(0, item.data.string("value")) - if isTable { - trailingStack.axis = .horizontal - trailingStack.alignment = .center - trailingStack.spacing = 4 - accessoryButtons[0].titleLabel?.font = nativeListFont(ofSize: 11) - accessoryButtons[0].setTitleColor( - nativeListColor(theme, "secondaryText", "#646464"), - for: .normal - ) - if let icon = item.data.dictionary("valueIcon") { - headerValueIconImageView.image = nativeListIcon(named: icon.string("name")) - headerValueIconImageView.tintColor = UIColor( - nativeListHex: icon.string("tintColor", default: "#646464"), - fallback: .darkGray - ) - headerValueIconImageView.isHidden = false - trailingStack.addArrangedSubview(headerValueIconImageView) - if icon.string("name") != "ChevronGrabberVerOutline" { - accessoryButtons[0].setTitleColor( - nativeListColor(theme, "primaryText", "#202020"), - for: .normal - ) - } - } - } - if let checkbox = item.data.dictionary("checkbox") { - bindCheckbox(item, checkbox, checkboxState) - } - if item.data.dictionary("checkbox") != nil && !item.data.string("value").isEmpty { - titleLabel.showsDottedUnderline = true - titleLabel.dottedUnderlineColor = nativeListColor( - theme, - "secondaryText", - "#646464" - ) - trailingStack.axis = .horizontal - trailingStack.alignment = .center - trailingStack.spacing = 12 - rootTopConstraint.constant = 8 - rootBottomConstraint.constant = -8 - accessoryButtons[0].titleLabel?.font = nativeListTabularFont( - ofSize: 16, - weight: .medium - ) - setButtonLine( - accessoryButtons[0], - text: item.data.string("value"), - font: nativeListTabularFont(ofSize: 16, weight: .medium), - color: nativeListColor(theme, "primaryText", "#202020"), - lineHeight: 24 - ) - } - } - applyValueSegments(item.data.dictionaries("valueSegments"), to: accessoryButtons[0], theme: theme) - } - - // OneKey patch: small zero-count digits remain on the regular amount baseline. - private func applyValueSegments(_ segments: [[String: Any]], to button: UIButton, theme: [String: Any]?) { - guard !segments.isEmpty else { return } - let value = NSMutableAttributedString(string: "") - let color = nativeListColor(theme, "primaryText", "#202020") - // OneKey patch: rich currency changes font runs without dropping the established line baseline. - let current = button.attributedTitle(for: .normal) - var attributes = current.flatMap { $0.length > 0 ? $0.attributes(at: 0, effectiveRange: nil) : nil } ?? [:] - attributes[.foregroundColor] = color - if currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil { - let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() - paragraph.alignment = .right - attributes[.paragraphStyle] = paragraph - } - for segment in segments { - attributes[.font] = nativeListTabularFont(ofSize: segment.string("style") == "subscript" ? 10 : 16, weight: .medium) - value.append(NSAttributedString(string: segment.string("text"), attributes: attributes)) - } - button.setAttributedTitle(value, for: .normal) - } - - - // OneKey patch: preserve the actual title frame for Popover placement. - @objc private func selectorTitlePressed() { - guard let item = currentItem else { return } - let action = item.data.string("titleActionKey") - guard !action.isEmpty else { return } - onAction?(item, action, nil, actionOrigin(sourceView: titleLabel, source: "leadingAction")) - } - - private func dataTextColor(_ tone: String, theme: [String: Any]?) -> UIColor { - switch tone.isEmpty ? "primary" : tone { - // OneKey patch: account warnings and hidden balances use existing theme tokens. - case "disabled": return nativeListColor(theme, "disabledText", "#8D8D8D") - case "caution": return nativeListColor(theme, "caution", "#AB6400") - case "secondary": return nativeListColor(theme, "secondaryText", "#646464") - case "positive": return nativeListColor(theme, "positive", "#218358") - case "negative": return nativeListColor(theme, "negative", "#CE2C31") - default: return nativeListColor(theme, "primaryText", "#202020") - } - } - - - private func addLeading( - _ visual: [String: Any]?, - secondaryVisual: [String: Any]? = nil, - key: String, - to stack: UIStackView? = nil - ) { - if let image = currentItem?.data.dictionary("style")?.dictionary("image") { - if image["width"] != nil { leadingWidth.constant = CGFloat(image.double("width")) } - if image["height"] != nil { leadingHeight.constant = CGFloat(image.double("height")) } - } - let targetStack = stack ?? rootStack - targetStack.addArrangedSubview(leadingContainer) - guard let visual else { return } - let kind = visual.string("kind") - var sources = visualSources(visual) - if let secondaryVisual, let secondarySource = visualSources(secondaryVisual).first { - sources.append(secondarySource) - } - let isIcon = kind == "icon" - let shape = visual.string( - "shape", - default: kind == "image" ? "rounded" : "circle" - ) - let cornerIcon = visual.dictionary("cornerIcon") - let fallbackText = String(visual.string("fallbackText").prefix(2)) - let fallbackIconData = visual.dictionary("fallbackIcon") - let fallbackIconSize = fallbackIconData.map { - let slotSize = min(leadingWidth.constant, leadingHeight.constant) - return $0.string("name") == "GlobusOutline" ? slotSize * 1.2 : slotSize - } - let sourceLoadingStrategy = sources.first?.data.string("loadingStrategy", default: "none") ?? "none" - let showsSourcePlaceholder = !isIcon && !sources.isEmpty && sourceLoadingStrategy != "none" - let handlesSourceFallback = !isIcon && !sources.isEmpty && - showsSourcePlaceholder && - (visual["fallbackText"] != nil || fallbackIconData != nil) - let sourceFallbackKey = handlesSourceFallback - ? sources.first.flatMap { nativeListSourceFallbackStateKey($0.data) } - : nil - let restoresSourceFallback = sourceFallbackKey.map(NativeListSourceFallbackState.has) ?? false - fallbackLabel.text = handlesSourceFallback && !restoresSourceFallback ? nil : fallbackText - let sourceFallbackBackground = currentTheme?["strongBackground"] as? String ?? "#0000000F" - // OneKey patch: source-backed visuals default to no placeholder/background. - // A non-none image loadingStrategy opts back into the themed placeholder. - let visualBackground = visual.string( - "backgroundColor", - default: !isIcon && !sources.isEmpty ? "#00000000" : sourceFallbackBackground - ) - let visualBackgroundColor = UIColor(nativeListHex: visualBackground, fallback: .clear) - leadingContainer.backgroundColor = handlesSourceFallback - ? UIColor(nativeListHex: sourceFallbackBackground, fallback: .clear) - : visualBackgroundColor - leadingContainer.layer.cornerRadius = leadingCornerRadius(shape: shape) - leadingContainer.clipsToBounds = true - fallbackLabel.isHidden = isIcon || (!sources.isEmpty && !handlesSourceFallback) - if isIcon { - leadingContainer.layer.borderWidth = 1 / UIScreen.main.scale - leadingContainer.layer.borderColor = UIColor( - nativeListHex: "#0000001F", - fallback: .lightGray - ).cgColor - leadingIconImageView.isHidden = false - leadingIconImageView.tintColor = UIColor( - nativeListHex: visual.string("tintColor", default: "#646464"), - fallback: .darkGray - ) - leadingIconImageView.image = nativeListIcon(named: visual.string("name")) - leadingIconImageView.contentMode = .scaleAspectFit - } else if sources.isEmpty, let fallbackIconData { - fallbackLabel.isHidden = true - leadingIconImageView.isHidden = false - leadingIconImageView.tintColor = UIColor( - nativeListHex: fallbackIconData.string("tintColor", default: "#646464"), - fallback: .darkGray - ) - leadingIconImageView.image = nativeListIcon(named: fallbackIconData.string("name")) - leadingIconImageView.contentMode = .scaleAspectFit - } - if handlesSourceFallback, let fallbackIconData { - fallbackLabel.isHidden = true - leadingIconImageView.image = nativeListIcon(named: fallbackIconData.string("name")) - leadingIconImageView.tintColor = UIColor( - nativeListHex: fallbackIconData.string("tintColor", default: "#646464"), - fallback: .darkGray - ) - leadingIconImageView.contentMode = .scaleAspectFit - leadingIconWidth.constant = fallbackIconSize ?? leadingWidth.constant - leadingIconHeight.constant = fallbackIconSize ?? leadingHeight.constant - leadingIconImageView.isHidden = !restoresSourceFallback - } else if handlesSourceFallback { - fallbackLabel.isHidden = !restoresSourceFallback - } - let visibleSources = Array(sources.prefix(leadingImages.count)) - let tokenPair = kind == "token" && visibleSources.count > 1 - leadingContainer.clipsToBounds = !tokenPair && cornerIcon == nil - if let cornerIcon { - leadingCornerIconBackground.isHidden = false - leadingCornerIconBackground.backgroundColor = UIColor( - nativeListHex: cornerIcon.string("backgroundColor", default: "#FFFFFF"), - fallback: .white - ) - leadingCornerIconImageView.image = nativeListIcon(named: cornerIcon.string("name")) - leadingCornerIconImageView.tintColor = UIColor( - nativeListHex: cornerIcon.string("tintColor", default: "#646464"), - fallback: .darkGray - ) - } - if tokenPair { - leadingOverlayBackground.isHidden = false - leadingOverlayBackground.backgroundColor = visualBackdropColor - leadingSlotConstraints.append(contentsOf: [ - leadingOverlayBackground.widthAnchor.constraint(equalToConstant: 20), - leadingOverlayBackground.heightAnchor.constraint(equalToConstant: 20), - leadingOverlayBackground.trailingAnchor.constraint( - equalTo: leadingContainer.trailingAnchor, - constant: 4 - ), - leadingOverlayBackground.bottomAnchor.constraint( - equalTo: leadingContainer.bottomAnchor, - constant: 4 - ), - ]) - } - for (index, source) in visibleSources.enumerated() { - let imageView = leadingImages[index] - // OneKey patch: Decorative badges and source-owned fallbacks stay hidden until loaded. - // imageView.isHidden = false - let ownsSourceFallback = index == 0 && handlesSourceFallback - imageView.isHidden = index > 0 || ownsSourceFallback - imageView.clipsToBounds = true - leadingSlotConstraints.append(contentsOf: leadingConstraints( - imageView, - index: index, - count: visibleSources.count, - tokenPair: tokenPair, - shape: shape - )) - if currentItem?.data.string("presentation") == "networkSelector", currentItem?.data["height"] != nil, visibleSources.count == 1, cornerIcon == nil, visual.dictionaries("overlays").isEmpty { - // OneKey patch: a single outer mask matches NetworkAvatar's edge antialiasing. - imageView.layer.cornerRadius = 0 - imageView.clipsToBounds = false - } - let fallbackIcon = index == 0 ? fallbackIconData : nil - let expectedEpoch = bindingEpoch - bindImage(source.data, into: imageView, token: key, slot: index, variant: source.variant, - onLoad: !ownsSourceFallback ? nil : { [weak self, weak imageView] in - guard let self, self.bindingEpoch == expectedEpoch else { return } - if let sourceFallbackKey { - NativeListSourceFallbackState.forget(sourceFallbackKey) - } - imageView?.isHidden = false - self.fallbackLabel.isHidden = true - self.leadingIconImageView.isHidden = true - self.leadingContainer.backgroundColor = visualBackgroundColor - }, - onError: !ownsSourceFallback ? nil : { [weak self, weak imageView] in - guard let self, self.bindingEpoch == expectedEpoch else { return } - if let sourceFallbackKey { - NativeListSourceFallbackState.remember(sourceFallbackKey) - } - imageView?.isHidden = true - if let fallbackIcon { - self.fallbackLabel.isHidden = true - self.leadingIconImageView.image = nativeListIcon(named: fallbackIcon.string("name")) - self.leadingIconImageView.tintColor = UIColor(nativeListHex: fallbackIcon.string("tintColor", default: "#646464"), fallback: .darkGray) - self.leadingIconImageView.isHidden = false - } else { - self.leadingIconImageView.isHidden = true - self.fallbackLabel.text = fallbackText - self.fallbackLabel.isHidden = false - } - }, - hideUntilLoaded: index > 0 || ownsSourceFallback) - } - // OneKey patch: source-derived wallet decorations may occupy both corners. - let overlays = visual.dictionaries("overlays") - if !overlays.isEmpty { leadingContainer.clipsToBounds = false } - for (index, overlay) in overlays.enumerated() { - let size = CGFloat(overlay.double("size", default: 20)) - let isWalletText = currentItem?.data.string("presentation") == "walletSidebar" && currentItem?.data["height"] != nil && !overlay.string("text").isEmpty && overlay.dictionary("image") == nil && overlay.string("name").isEmpty - let inset = CGFloat(overlay.double("padding", default: 0)) - let offsetX = CGFloat(overlay.double("offsetX", default: overlay.double("offset", default: 2))) - let offsetY = CGFloat(overlay.double("offsetY", default: overlay.double("offset", default: 2))) - let height = CGFloat(overlay.double("height", default: isWalletText ? 16 : Double(size))) - let textWidth = (overlay.string("text") as NSString).size(withAttributes: [.font: nativeListTabularFont(ofSize: 12), .kern: 0]).width - let naturalTextWidth = ceil(textWidth * UIScreen.main.scale) / UIScreen.main.scale + 4 - let width = CGFloat(overlay.double("width", default: isWalletText ? Double(naturalTextWidth) : Double(size))) - let frame = UIView() - frame.translatesAutoresizingMaskIntoConstraints = false - // OneKey patch: The row theme, not a light-only white, owns badge backing. - // frame.backgroundColor = UIColor(nativeListHex: overlay.string("backgroundColor", default: "#FFFFFF"), fallback: .clear) - frame.backgroundColor = UIColor( - nativeListHex: overlay.string( - "backgroundColor", - default: currentTheme?["rowBackground"] as? String ?? "#00000000" - ), - fallback: .clear - ) - frame.layer.cornerRadius = min(width, height) / 2 - frame.clipsToBounds = true - leadingContainer.addSubview(frame) - selectorViews.append(frame) - let topLeft = overlay.string("position") == "topLeft" - leadingSlotConstraints.append(contentsOf: [ - frame.widthAnchor.constraint(equalToConstant: width), - frame.heightAnchor.constraint(equalToConstant: height), - topLeft ? frame.leadingAnchor.constraint(equalTo: leadingContainer.leadingAnchor, constant: -offsetX) : frame.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor, constant: offsetX), - topLeft ? frame.topAnchor.constraint(equalTo: leadingContainer.topAnchor, constant: -offsetY) : frame.bottomAnchor.constraint(equalTo: leadingContainer.bottomAnchor, constant: offsetY), - ]) - let content: UIView - if let image = overlay.dictionary("image") { - let imageView = OneKeyImageReusableView(frame: .zero) - bindImage( - image, - into: imageView, - token: key, - slot: 10 + index, - variant: "generic", - hideUntilLoaded: true - ) - selectorImages.append(imageView) - content = imageView - } else if !overlay.string("text").isEmpty { - let label = UILabel() - label.text = overlay.string("text") - label.textAlignment = .center - label.font = nativeListFont(ofSize: isWalletText ? 12 : 10, weight: isWalletText ? .regular : .medium) - label.textColor = UIColor(nativeListHex: overlay.string("tintColor", default: "#646464"), fallback: .darkGray) - if isWalletText { setLineHeight(label, text: overlay.string("text"), lineHeight: 16) } - content = label - } else { - let imageView = UIImageView(image: nativeListIcon(named: overlay.string("name"))) - imageView.contentMode = .scaleAspectFit - imageView.tintColor = UIColor(nativeListHex: overlay.string("tintColor", default: "#646464"), fallback: .darkGray) - content = imageView - } - content.translatesAutoresizingMaskIntoConstraints = false - frame.addSubview(content) - NSLayoutConstraint.activate([ - content.leadingAnchor.constraint(equalTo: frame.leadingAnchor, constant: isWalletText ? 2 : inset), - content.trailingAnchor.constraint(equalTo: frame.trailingAnchor, constant: isWalletText ? -2 : -inset), - content.topAnchor.constraint(equalTo: frame.topAnchor, constant: isWalletText ? 0 : inset), - content.bottomAnchor.constraint(equalTo: frame.bottomAnchor, constant: isWalletText ? 0 : -inset), - ]) - } - if let fallbackIcon = visual.dictionary("fallbackIcon"), sources.isEmpty { - fallbackLabel.isHidden = true - leadingIconImageView.isHidden = false - leadingIconImageView.image = nativeListIcon(named: fallbackIcon.string("name")) - leadingIconImageView.tintColor = UIColor(nativeListHex: fallbackIcon.string("tintColor", default: "#646464"), fallback: .darkGray) - if currentItem?.data.string("presentation") == "walletSidebar", currentItem?.data["height"] != nil, fallbackIcon.string("name") == "LockSolid" { - leadingIconWidth.constant = 40 - leadingIconHeight.constant = 40 - leadingContainer.clipsToBounds = false - } - } - if visual.string("borderStyle") == "dashed" { - let border = CAShapeLayer() - border.strokeColor = UIColor(nativeListHex: visual.string("borderColor", default: "#8D8D8D"), fallback: .gray).cgColor - border.fillColor = UIColor.clear.cgColor - border.lineWidth = currentItem?.data.string("presentation") == "walletSidebar" && currentItem?.data["height"] != nil ? 1 : 2 - border.lineDashPattern = [4, 4] - let borderInset = border.lineWidth / 2 - border.path = UIBezierPath(ovalIn: CGRect(x: borderInset, y: borderInset, width: leadingWidth.constant - border.lineWidth, height: leadingHeight.constant - border.lineWidth)).cgPath - leadingContainer.layer.addSublayer(border) - selectorBorder = border - } - NSLayoutConstraint.activate(leadingSlotConstraints) - } - - private func visualSources(_ visual: [String: Any]) -> [(data: [String: Any], variant: String)] { - let kind = visual.string("kind") - if kind == "stackedImages" { - return visual.dictionaries("images").prefix(3).map { ($0, "generic") } - } - guard kind != "icon", let image = visual.dictionary("image") else { return [] } - let variant: String - switch kind { - case "token": variant = "token" - case "network": variant = "network" - case "account", "wallet": variant = "avatar" - default: variant = "generic" - } - var sources: [(data: [String: Any], variant: String)] = [(image, variant)] - if kind == "token", let networkImage = visual.dictionary("networkImage") { - sources.append((networkImage, "network")) - } - return sources - } - - private func leadingConstraints( - _ imageView: UIView, - index: Int, - count: Int, - tokenPair: Bool, - shape: String - ) -> [NSLayoutConstraint] { - if count == 1 || tokenPair && index == 0 { - imageView.layer.cornerRadius = leadingCornerRadius(shape: shape) - return [ - imageView.leadingAnchor.constraint(equalTo: leadingContainer.leadingAnchor), - imageView.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor), - imageView.topAnchor.constraint(equalTo: leadingContainer.topAnchor), - imageView.bottomAnchor.constraint(equalTo: leadingContainer.bottomAnchor), - ] - } - if tokenPair && index == 1 { - let overlaySize: CGFloat = 16 - imageView.layer.cornerRadius = overlaySize / 2 - return [ - imageView.widthAnchor.constraint(equalToConstant: overlaySize), - imageView.heightAnchor.constraint(equalToConstant: overlaySize), - imageView.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor, constant: 2), - imageView.bottomAnchor.constraint(equalTo: leadingContainer.bottomAnchor, constant: 2), - ] - } - let imageSize = leadingHeight.constant * 0.72 - let availableOffset = leadingWidth.constant - imageSize - let offset = CGFloat(index) * availableOffset / CGFloat(max(1, count - 1)) - imageView.layer.cornerRadius = imageSize / 2 - return [ - imageView.widthAnchor.constraint(equalToConstant: imageSize), - imageView.heightAnchor.constraint(equalToConstant: imageSize), - imageView.leadingAnchor.constraint(equalTo: leadingContainer.leadingAnchor, constant: offset), - imageView.centerYAnchor.constraint(equalTo: leadingContainer.centerYAnchor), - ] - } - - private func leadingCornerRadius(shape: String) -> CGFloat { - switch shape { - case "square": return 0 - case "rounded": return min(10, leadingHeight.constant / 4) - default: return leadingHeight.constant / 2 - } - } - - private func bindAccessories( - _ item: NativeListItem, - _ accessories: [[String: Any]], - _ theme: [String: Any]?, - _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String - ) { - var textIndex = 0 - for accessory in accessories.prefix(2) { - switch accessory.string("kind") { - case "value": - showAccessory(textIndex, accessory.string("text")) - semanticValueButtons.append(accessoryButtons[textIndex]) - if item.data.string("presentation") == "networkSelector" && item.data["height"] != nil { - accessoryButtons[textIndex].contentHorizontalAlignment = .trailing - accessoryButtons[textIndex].titleLabel?.textAlignment = .right - } - applyValueSegments(accessory.dictionaries("textSegments"), to: accessoryButtons[textIndex], theme: theme) - textIndex += 1 - case "valuePair": - showValuePairAccessory(textIndex, accessory, theme: theme) - semanticValueButtons.append(accessoryButtons[textIndex]) - textIndex += 1 - case "checkbox": bindCheckbox(item, accessory, checkboxState) - case "radio": showAccessory(textIndex, accessory.bool("checked") ? "●" : "○", action: accessoryAction(accessory)); textIndex += 1 - case "switch": showAccessory(textIndex, accessory.bool("value") ? "ON" : "OFF", action: accessoryAction(accessory)); textIndex += 1 - case "chevron": - var icon = accessory - icon["name"] = "ChevronRightSmallOutline" - if icon["tintColor"] == nil { - icon["tintColor"] = theme?["iconSubdued"] as? String ?? "#8D8D8D" - } - if icon.string("actionKey").isEmpty { icon["actionKey"] = "press" } - showAccessoryIcon(textIndex, icon) - textIndex += 1 - case "menu": showMenuAccessory(textIndex, action: accessoryAction(accessory)); textIndex += 1 - case "drag": - var icon = accessory - icon["name"] = "DragOutline" - if icon["tintColor"] == nil { - icon["tintColor"] = theme?["iconSubdued"] as? String ?? "#8D8D8D" - } - showAccessoryIcon(textIndex, icon) - textIndex += 1 - case "icon": - var icon = accessory - if icon["tintColor"] == nil { - icon["tintColor"] = theme?["iconSubdued"] as? String ?? "#8D8D8D" - } - showAccessoryIcon(textIndex, icon) - textIndex += 1 - case "spinner": spinner.startAnimating() - case "progress": showAccessory(textIndex, "\(Int(accessory.double("value") * 100))%"); textIndex += 1 - default: break - } - } - } - - private func bindCheckbox( - _ item: NativeListItem, - _ data: [String: Any], - _ checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String - ) { - let targetData = data.dictionary("target") - let scope = targetData?.string("scope", default: "row") ?? "row" - let key = scope == "section" ? targetData?.string("sectionKey") : scope == "row" ? item.key : nil - let target = NativeSelectionTarget(scope: scope, key: key) - let state = checkboxState(item, target, data.string("state", default: "unchecked")) - boundCheckboxData = data - boundCheckboxTarget = target - updateCheckboxPresentation(item, data, target: target, state: state) - } - - private func updateCheckboxPresentation( - _ item: NativeListItem, - _ data: [String: Any], - target: NativeSelectionTarget, - state: String - ) { - let accessoryDisabled = data.bool("disabled") - if data.bool("loading") { - checkboxButton.isHidden = true - spinner.startAnimating() - spinner.alpha = item.data.bool("disabled") ? 1 : accessoryDisabled ? 0.5 : 1 - checkboxAction = nil - return - } - checkboxButton.isHidden = false - checkboxButton.backgroundColor = state == "unchecked" ? checkboxUncheckedColor : checkboxCheckedColor - checkboxButton.layer.borderColor = state == "unchecked" - ? checkboxBorderColor.cgColor - : UIColor.clear.cgColor - let glyphName = state == "indeterminate" - ? "CheckboxIndeterminateCustom" - : "CheckboxCheckedCustom" - checkboxButton.setImage( - state == "unchecked" ? nil : nativeListIcon(named: glyphName), - for: .normal - ) - checkboxButton.tintColor = checkboxIconColor - // A disabled ListItem already applies 0.5 to its complete content. Avoid - // multiplying that opacity on the nested control a second time. - checkboxButton.alpha = item.data.bool("disabled") ? 1 : accessoryDisabled ? 0.5 : 1 - checkboxButton.isEnabled = !item.data.bool("disabled") && !accessoryDisabled && !data.bool("loading") - checkboxAction = (data.string("actionKey", default: "selection"), target) - } - - private func show(_ label: UILabel, _ value: String, lines: Int) { - guard !value.isEmpty else { return } - label.text = value - label.numberOfLines = lines - label.isHidden = false - } - - private func setLineHeight( - _ label: UILabel, - text: String, - lineHeight: CGFloat, - letterSpacing: CGFloat = 0 - ) { - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.minimumLineHeight = lineHeight - paragraphStyle.maximumLineHeight = lineHeight - paragraphStyle.alignment = label.textAlignment - if currentItem?.type == "market" || currentItem?.data.string("presentation") == "walletSidebar" { - // Attributed paragraphs must preserve the tail ellipsis. - paragraphStyle.lineBreakMode = label.lineBreakMode - } - var attributes: [NSAttributedString.Key: Any] = [ - .font: label.font as Any, - .foregroundColor: label.textColor as Any, - .paragraphStyle: paragraphStyle, - ] - let isNetworkFallback = - label === fallbackLabel && - currentItem?.type == "identity" && - currentItem?.data.string("presentation") == "networkSelector" - if currentItem?.type == "market" || (currentItem?.data["height"] != nil && (["accountSelector", "walletSidebar"].contains(currentItem?.data.string("presentation") ?? "") || currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector")) || isNetworkFallback { - // OneKey patch: React Native centers font metrics inside explicit line heights. - let baselineOffset = max(0, (lineHeight - label.font.lineHeight) / 2) - // OneKey patch: TextKit's 14/20 headings align their baseline to the upper physical pixel. - let isSelectorHeading = lineHeight == 20 && (currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector" || currentItem?.type == "market" && label.font.pointSize == 14) - let scale = window?.screen.scale ?? traitCollection.displayScale - attributes[.baselineOffset] = isSelectorHeading && scale > 0 ? ceil(baselineOffset * scale) / scale : baselineOffset - } - if letterSpacing != 0 || currentItem?.type == "market" || currentItem?.data.string("presentation") == "market" { attributes[.kern] = letterSpacing } - label.attributedText = NSAttributedString(string: text, attributes: attributes) - } - - private func setButtonLine( - _ button: UIButton, - text: String, - font: UIFont, - color: UIColor, - lineHeight: CGFloat - ) { - guard !text.isEmpty else { return } - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.minimumLineHeight = lineHeight - paragraphStyle.maximumLineHeight = lineHeight - let isMarketText = currentItem?.type == "market" - let isSelectorValue = currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil && currentItem?.data.string("variant") != "summary" - let isSelectorSummary = currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil && currentItem?.data.string("variant") == "summary" - (button as? NativeListAccessoryButton)?.selectorSummaryLineHeight = isSelectorSummary ? lineHeight : nil - (button as? NativeListAccessoryButton)?.marketLineHeight = isMarketText ? lineHeight : nil - // OneKey patch: summary text uses its source line box; currency retains trailing alignment. - // Market's line box already handles alignment; source text starts at its origin. - paragraphStyle.alignment = isSelectorValue ? .right : isSelectorSummary || isMarketText ? .natural : .center - if isSelectorSummary { - button.contentHorizontalAlignment = .leading - button.titleLabel?.textAlignment = .natural - } - if isSelectorValue { - button.contentHorizontalAlignment = .trailing - button.titleLabel?.textAlignment = .right - } - let baselineOffset: CGFloat = isMarketText || currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil ? max(0, (lineHeight - font.lineHeight) / 2) : 0 - var attributes: [NSAttributedString.Key: Any] = [ - .font: font, - .foregroundColor: color, - .paragraphStyle: paragraphStyle, - .baselineOffset: isMarketText && lineHeight == 20 && font.pointSize == 14 ? ceil(baselineOffset * max(1, traitCollection.displayScale)) / max(1, traitCollection.displayScale) : baselineOffset, - ] - if isMarketText { attributes[.kern] = 0 } - button.setAttributedTitle( - NSAttributedString(string: text, attributes: attributes), - for: .normal - ) - } - - private func showAccessory( - _ index: Int, - _ value: String, - action: (String, NativeSelectionTarget?)? = nil, - color: UIColor? = nil - ) { - guard accessoryButtons.indices.contains(index), !value.isEmpty else { return } - let button = accessoryButtons[index] - button.isHidden = false - button.setTitle(value, for: .normal) - button.setContentHuggingPriority(.required, for: .horizontal) - button.setContentCompressionResistancePriority(.required, for: .horizontal) - if let color { button.setTitleColor(color, for: .normal) } - while accessoryActions.count <= index { accessoryActions.append(("", nil)) } - accessoryActions[index] = action ?? ("", nil) - } - - private func showValuePairAccessory( - _ index: Int, - _ accessory: [String: Any], - theme: [String: Any]? - ) { - guard accessoryButtons.indices.contains(index) else { return } - let primary = accessory.string("primary") - let secondary = accessory.string("secondary") - guard !primary.isEmpty || !secondary.isEmpty else { return } - let button = accessoryButtons[index] - let text = NSMutableAttributedString() - let primaryParagraph = NSMutableParagraphStyle() - primaryParagraph.alignment = .right - primaryParagraph.minimumLineHeight = 20 - primaryParagraph.maximumLineHeight = 20 - text.append(NSAttributedString( - string: primary, - attributes: [ - .font: nativeListFont(ofSize: 16, weight: .medium), - .foregroundColor: accessoryTextColor( - accessory.string("primaryTone"), - defaultTone: "primary", - theme: theme - ), - .paragraphStyle: primaryParagraph, - ] - )) - if !primary.isEmpty, !secondary.isEmpty { text.append(NSAttributedString(string: "\n")) } - let secondaryParagraph = NSMutableParagraphStyle() - secondaryParagraph.alignment = .right - secondaryParagraph.minimumLineHeight = 20 - secondaryParagraph.maximumLineHeight = 20 - text.append(NSAttributedString( - string: secondary, - attributes: [ - .font: nativeListFont(ofSize: 14), - .foregroundColor: accessoryTextColor( - accessory.string("secondaryTone"), - defaultTone: "secondary", - theme: theme - ), - .paragraphStyle: secondaryParagraph, - ] - )) - button.isHidden = false - button.titleLabel?.numberOfLines = 2 - button.contentHorizontalAlignment = .trailing - button.setAttributedTitle(text, for: .normal) - button.setContentHuggingPriority(.required, for: .horizontal) - button.setContentCompressionResistancePriority(.required, for: .horizontal) - while accessoryActions.count <= index { accessoryActions.append(("", nil)) } - accessoryActions[index] = ("", nil) - } - - private func showMenuAccessory( - _ index: Int, - action: (String, NativeSelectionTarget?)? - ) { - showAccessory(index, "⋮", action: action) - guard accessoryButtons.indices.contains(index) else { return } - let button = accessoryButtons[index] - button.titleLabel?.font = nativeListFont(ofSize: 20, weight: .medium) - accessorySizeConstraints.append(contentsOf: [ - button.widthAnchor.constraint(equalToConstant: 24), - button.heightAnchor.constraint(equalToConstant: 24), - ]) - NSLayoutConstraint.activate(Array(accessorySizeConstraints.suffix(2))) - } - - private func accessoryTextColor( - _ tone: String, - defaultTone: String, - theme: [String: Any]? - ) -> UIColor { - switch tone.isEmpty ? defaultTone : tone { - case "positive": return nativeListColor(theme, "positive", "#218358") - case "negative": return nativeListColor(theme, "negative", "#CE2C31") - // OneKey patch: account warnings and hidden balances use existing theme tokens. - case "disabled": return nativeListColor(theme, "disabledText", "#8D8D8D") - case "caution": return nativeListColor(theme, "caution", "#AB6400") - case "secondary": return nativeListColor(theme, "secondaryText", "#646464") - default: return nativeListColor(theme, "primaryText", "#202020") - } - } - - private func showAccessoryIcon(_ index: Int, _ data: [String: Any]) { - guard accessoryButtons.indices.contains(index) else { return } - let button = accessoryButtons[index] - button.isHidden = false - button.isEnabled = !data.bool("disabled") - button.alpha = button.isEnabled ? 1 : 0.4 - let isAccountCreate = currentItem?.data.string("presentation") == "accountSelector" && currentItem?.data["height"] != nil && data.string("name") == "PlusSmallOutline" - let tintColor = data["tintColor"] == nil && isAccountCreate - ? nativeListColor(currentTheme, "iconSubdued", "#8D8D8D") - : UIColor(nativeListHex: data.string("tintColor", default: "#646464"), fallback: .darkGray) - button.tintColor = tintColor - if let image = nativeListIcon(named: data.string("name")) { - button.setImage(image, for: .normal) - button.setImage( - image.withTintColor(tintColor, renderingMode: .alwaysOriginal), - for: .disabled - ) - } - let isDrillIn = data.string("kind") == "chevron" - let isAccountIcon = currentItem?.data.string("presentation") == "accountSelector" && !isDrillIn - let size: CGFloat = isDrillIn ? 24 : isAccountIcon && !isAccountCreate ? 38 : 36 - if isAccountCreate { button.layer.cornerRadius = 8 } - if isAccountIcon && !isAccountCreate && !data.string("actionKey").isEmpty { - button.pressedBackgroundColor = nativeListColor(currentTheme, "rowPressedBackground", "#E8E8E8") - button.layer.cornerRadius = size / 2 - } - if isAccountIcon { rootStack.setCustomSpacing(5, after: mainStack) } - button.accessibilityIdentifier = data["testID"] as? String - button.accessibilityLabel = data["accessibilityLabel"] as? String - if !isDrillIn, !data.string("actionKey").isEmpty { - // Reproduce the trailing edge of IconButton's m=-7 while keeping its - // full 36-point frame for padding/highlight behavior. - rootTrailingConstraint.constant = -5 - } - accessorySizeConstraints.append(contentsOf: [ - button.widthAnchor.constraint(equalToConstant: size), - button.heightAnchor.constraint(equalToConstant: size), - ]) - NSLayoutConstraint.activate(Array(accessorySizeConstraints.suffix(2))) - while accessoryActions.count <= index { accessoryActions.append(("", nil)) } - accessoryActions[index] = data.bool("disabled") - ? ("", nil) - : (data.string("actionKey"), nil) - } - - private func accessoryAction( - _ accessory: [String: Any], - defaultKey: String = "" - ) -> (String, NativeSelectionTarget?)? { - if accessory.bool("disabled") { return nil } - let key = accessory.string("actionKey", default: defaultKey) - return key.isEmpty ? nil : (key, nil) - } - - private func bindImage( - _ source: [String: Any], - into imageView: OneKeyImageReusableView, - token: String, - slot: Int, - variant: String, - onLoad: (() -> Void)? = nil, - onError: (() -> Void)? = nil, - hideUntilLoaded: Bool = false, - retryAttempt: Int = 0 - ) { - let imageID = ObjectIdentifier(imageView) - selectorImageRetries.removeValue(forKey: imageID)?.cancel() - let expectedEpoch = bindingEpoch - let retryLimit = max(0, source.int("retryTimes", default: 0)) - let headersJson: String? - if let headers = source.dictionary("headers"), - JSONSerialization.isValidJSONObject(headers), - let data = try? JSONSerialization.data(withJSONObject: headers) { - headersJson = String(data: data, encoding: .utf8) - } else { - headersJson = nil - } - if hideUntilLoaded { imageView.isHidden = true } - let handleLoad: (() -> Void)? = hideUntilLoaded ? { [weak self, weak imageView] in - guard let self, self.bindingEpoch == expectedEpoch else { return } - imageView?.isHidden = false - onLoad?() - } : onLoad - let handleError: (() -> Void)? = hideUntilLoaded ? { [weak self, weak imageView] in - guard let self, self.bindingEpoch == expectedEpoch else { return } - imageView?.isHidden = true - onError?() - } : onError - imageView.configure( - sourceUri: source.string("uri").trimmingCharacters(in: .whitespacesAndNewlines), - sourceHeadersJson: headersJson, - variant: variant, - contentFit: imageView === leadingImages.first - ? currentItem?.data.dictionary("style")?.dictionary("image")?["contentFit"] as? String ?? source.string("contentFit", default: "cover") - : source.string("contentFit", default: "cover"), - cachePolicy: source.string("cachePolicy", default: "memory-disk"), - autoplay: source.bool("autoplay"), - recyclingKey: retryAttempt == 0 ? "\(token):\(slot)" : "\(token):\(slot):retry:\(retryAttempt)", - optimizeTos: retryAttempt == 0 && (source["optimizeTos"] == nil || source.bool("optimizeTos")), - overscan: source["overscan"] == nil ? 1.1 : source.double("overscan"), - loadingStrategy: source.string("loadingStrategy", default: "none"), - placeholderColor: currentTheme?["strongBackground"] as? String ?? "#0000000F", - onLoad: retryLimit == 0 ? handleLoad : { [weak self] in - guard let self, self.bindingEpoch == expectedEpoch else { return } - self.selectorImageRetries.removeValue(forKey: imageID)?.cancel() - handleLoad?() - }, - onError: retryLimit == 0 ? handleError : { [weak self, weak imageView] in - guard let self, self.bindingEpoch == expectedEpoch, let imageView else { return } - // Report every failed attempt: a rebind during the retry delay cancels the pending retry, - // so deferring to the last attempt would never record the source fallback state. - handleError?() - guard retryAttempt < retryLimit, self.selectorImageRetries[imageID] == nil else { return } - let retry = DispatchWorkItem { [weak self, weak imageView] in - guard let self, self.bindingEpoch == expectedEpoch, let imageView else { return } - self.selectorImageRetries.removeValue(forKey: imageID) - self.bindImage(source, into: imageView, token: token, slot: slot, variant: variant, onLoad: onLoad, onError: onError, hideUntilLoaded: hideUntilLoaded, retryAttempt: retryAttempt + 1) - } - self.selectorImageRetries[imageID] = retry - DispatchQueue.main.asyncAfter(deadline: .now() + Double(Int.random(in: 0...2)), execute: retry) - } - ) - } - - private func applyGroupPosition(_ position: String) { - let top: CACornerMask = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - let bottom: CACornerMask = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] - switch position { - case "first": layer.maskedCorners = top - case "last": layer.maskedCorners = bottom - case "single": layer.maskedCorners = top.union(bottom) - default: - layer.maskedCorners = [] - layer.cornerRadius = 0 - layer.masksToBounds = false - return - } - let isWalletSidebar = currentItem?.type == "identity" - && currentItem?.data.string("presentation") == "walletSidebar" - layer.cornerRadius = listStyle?["groupCornerRadius"] == nil - ? (isWalletSidebar ? 20 : 12) - : CGFloat(listStyle?.double("groupCornerRadius") ?? 12) - layer.cornerCurve = isWalletSidebar ? .continuous : .circular - layer.masksToBounds = true - } - - - @objc private func accessoryPressed(_ sender: UIButton) { - guard let item = currentItem, accessoryActions.indices.contains(sender.tag) else { return } - let action = accessoryActions[sender.tag] - guard !action.0.isEmpty else { return } - let source = "trailingAccessory" - onAction?( - item, - action.0, - action.1, - actionOrigin(sourceView: sender, source: source, slot: sender.tag) - ) - } - - @objc private func marketBadgePressed(_ sender: UIButton) { - guard let item = currentItem, - marketBadgeActionKeys.indices.contains(sender.tag), - let actionKey = marketBadgeActionKeys[sender.tag], - !actionKey.isEmpty else { return } - onAction?( - item, - actionKey, - nil, - actionOrigin(sourceView: sender, source: "marketBadge", slot: sender.tag) - ) - } - - - @objc private func checkboxPressed() { - guard let item = currentItem, let action = checkboxAction else { return } - onAction?( - item, - action.0, - action.1, - actionOrigin(sourceView: checkboxButton, source: "trailingAccessory") - ) - } - - @objc private func leadingActionPressed() { - guard let item = currentItem, let actionKey = leadingActionKey, !actionKey.isEmpty else { return } - onAction?( - item, - actionKey, - nil, - actionOrigin(sourceView: leadingActionButton, source: "leadingAction") - ) - } - - private func actionOrigin( - sourceView: UIView, - source: String, - slot: Int? = nil - ) -> NativeListActionOrigin { - let isAccountIcon = currentItem?.data.string("presentation") == "accountSelector" - && source == "trailingAccessory" - && accessoryButtons.contains { $0 === sourceView } - && sourceView.bounds.width == 38 - return NativeListActionOrigin( - sourceView: sourceView, - ownerCell: self, - bindingEpoch: bindingEpoch, - source: source, - slot: slot, - anchorInset: isAccountIcon ? 7 : 0 - ) - } - - override func rowActionOrigin() -> NativeListActionOrigin { - actionOrigin(sourceView: contentView, source: "row") - } - - private func invalidateCurrentBinding() { - selectorImageRetries.values.forEach { $0.cancel() } - selectorImageRetries.removeAll() - guard currentItem != nil else { return } - onBindingInvalidated?(self, bindingEpoch) - bindingEpoch &+= 1 - } -} diff --git a/native-views/react-native-native-list/ios/NativeListModels.swift b/native-views/react-native-native-list/ios/NativeListModels.swift index 8e2e9f334..a9194495f 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -1,10 +1,9 @@ import Foundation import UIKit -// Message is the first migrated renderer. Other templates still use the -// compatible legacy tree; style, content and row keys do not affect reuse. +// Template type alone partitions reuse; data keys and styles never select a renderer. enum NativeListRendererKey: String, CaseIterable { - case legacy, message, rail, mediaTile, action, system, activity, dataRow, metricCard, market, identity, sectionHeader, walletGroup + case message, rail, mediaTile, action, system, activity, dataRow, metricCard, market, identity, sectionHeader, walletGroup } struct NativeListItem { @@ -15,9 +14,7 @@ struct NativeListItem { let data: [String: Any] let content: String - var rendererKey: NativeListRendererKey { - NativeListRendererRegistry.key(for: type) - } + let rendererKey: NativeListRendererKey var styledHeight: CGFloat? { (data.dictionary("style")?.dictionary("container")?["height"] as? Double).map { CGFloat($0) } @@ -44,11 +41,13 @@ struct NativeListItem { init(data: [String: Any]) throws { guard let key = data["key"] as? String, !key.isEmpty, - let type = data["type"] as? String, !type.isEmpty else { + let type = data["type"] as? String, + let rendererKey = NativeListRendererKey(rawValue: type) else { throw NativeListModelError.invalidRow } self.key = key self.type = type + self.rendererKey = rendererKey self.sectionKey = data["sectionKey"] as? String self.revision = data["revision"] as? Int ?? 0 self.data = data diff --git a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift index 25c97ecad..f891a2261 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift @@ -1,7 +1,6 @@ import UIKit -// Closed internal registry. Legacy templates keep their existing implementation -// until their own migration; no public plugin or bridge surface is introduced. +// Closed internal registry; no public plugin or bridge surface. enum NativeListRendererRegistry { private static let hosts: [NativeListRendererKey: (type: NativeListRowHost.Type, create: () -> NativeListRowHost)] = [ @@ -11,7 +10,6 @@ enum NativeListRendererRegistry { .walletGroup: (NativeListWalletGroupCell.self, { NativeListWalletGroupCell(frame: .zero) }), .identity: (NativeListIdentityCell.self, { NativeListIdentityCell(frame: .zero) }), .market: (NativeListMarketCell.self, { NativeListMarketCell(frame: .zero) }), - .legacy: (NativeListCell.self, { NativeListCell(frame: .zero) }), .message: (NativeListMessageCell.self, { NativeListMessageCell(frame: .zero) }), .rail: (NativeListRailCell.self, { NativeListRailCell(frame: .zero) }), .mediaTile: (NativeListMediaTileCell.self, { NativeListMediaTileCell(frame: .zero) }), @@ -22,9 +20,6 @@ enum NativeListRendererRegistry { .action: (NativeListActionCell.self, { NativeListActionCell(frame: .zero) }), ] static func create(_ key: NativeListRendererKey) -> NativeListRowHost { hosts[key]!.create() } - static func key(for type: String) -> NativeListRendererKey { - NativeListRendererKey(rawValue: type) ?? .legacy - } static func reuseIdentifier(for key: NativeListRendererKey) -> String { "NativeList.\(key.rawValue)" } diff --git a/native-views/react-native-native-list/ios/NativeListRowHost.swift b/native-views/react-native-native-list/ios/NativeListRowHost.swift index 3ad6bf64f..c3527b109 100644 --- a/native-views/react-native-native-list/ios/NativeListRowHost.swift +++ b/native-views/react-native-native-list/ios/NativeListRowHost.swift @@ -1,7 +1,33 @@ import UIKit -// Common list-facing lifecycle. Legacy templates and migrated renderers share -// this contract, not their view allocation or reset implementation. +final class NativeListActionOrigin { + weak var sourceView: UIView? + weak var ownerCell: NativeListRowHost? + let bindingEpoch: Int + let source: String + let slot: Int? + // OneKey patch: expose the layout slot while preserving the larger hit target. + let anchorInset: CGFloat + var windowPoint: CGPoint? + + init( + sourceView: UIView, + ownerCell: NativeListRowHost, + bindingEpoch: Int, + source: String, + slot: Int? = nil, + anchorInset: CGFloat = 0 + ) { + self.sourceView = sourceView + self.ownerCell = ownerCell + self.bindingEpoch = bindingEpoch + self.source = source + self.slot = slot + self.anchorInset = anchorInset + } +} + +// Common list-facing lifecycle; each renderer owns its view allocation and reset. class NativeListRowHost: UICollectionViewCell { class func measure(_ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String) -> CGFloat? diff --git a/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift b/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift index 757fba462..f21ff33b8 100644 --- a/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift @@ -1,5 +1,94 @@ import UIKit +final class NativeListDottedUnderlineLabel: NativeListTextLabel { + var showsDottedUnderline = false { + didSet { setNeedsLayout() } + } + + // OneKey patch: migrated section titles include the source 3-point underline box. + var reservesDottedUnderlineSpace = false { + didSet { + invalidateIntrinsicContentSize() + setNeedsLayout() + setNeedsDisplay() + } + } + + override var intrinsicContentSize: CGSize { + var size = super.intrinsicContentSize + if reservesDottedUnderlineSpace && showsDottedUnderline { size.height += 3 } + return size + } + + override func drawText(in rect: CGRect) { + let textRect = + reservesDottedUnderlineSpace && showsDottedUnderline + ? CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: max(0, rect.height - 3)) + : rect + super.drawText(in: textRect) + } + + var dottedUnderlineColor: UIColor = .clear { + didSet { + dottedUnderlineLayer.strokeColor = dottedUnderlineColor.cgColor + setNeedsLayout() + } + } + + var dottedUnderlineVerticalOffset: CGFloat = 0 { + didSet { setNeedsLayout() } + } + + private let dottedUnderlineLayer = CAShapeLayer() + + override init(frame: CGRect) { + super.init(frame: frame) + dottedUnderlineLayer.fillColor = UIColor.clear.cgColor + dottedUnderlineLayer.lineWidth = 1.5 + dottedUnderlineLayer.lineCap = .round + dottedUnderlineLayer.lineDashPattern = [0, 4] + layer.addSublayer(dottedUnderlineLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + CATransaction.begin() + CATransaction.setDisableActions(true) + defer { CATransaction.commit() } + guard showsDottedUnderline, let attributedText else { + dottedUnderlineLayer.path = nil + dottedUnderlineLayer.isHidden = true + return + } + let textWidth = ceil( + attributedText.boundingRect( + with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: 24), + options: [.usesLineFragmentOrigin, .usesFontLeading], + context: nil + ).width) + dottedUnderlineLayer.frame = CGRect( + x: 0, + y: 0, + width: bounds.width, + height: bounds.height + 2 + dottedUnderlineVerticalOffset + ) + // OneKey patch: explicit header underline occupies the reserved final two points. + // let y = bounds.height + 1 + dottedUnderlineVerticalOffset + let y = + reservesDottedUnderlineSpace + ? bounds.height - 1 : bounds.height + 1 + dottedUnderlineVerticalOffset + let path = UIBezierPath() + path.move(to: CGPoint(x: 1, y: y)) + path.addLine(to: CGPoint(x: max(1, textWidth - 1), y: y)) + dottedUnderlineLayer.path = path.cgPath + dottedUnderlineLayer.isHidden = false + } +} + final class NativeListSectionHeaderCell: NativeListRendererCell { override class func appliesSizePreset(_ item: NativeListItem) -> Bool { !["summary", "gallery"].contains(item.data.string("variant")) diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index b6fe8c34d..046fc6984 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -62,8 +62,8 @@ final class NativeListView: UIView { private let flowLayout = NativeListFlowLayout() private lazy var collectionView = NativeListCollectionView(frame: .zero, collectionViewLayout: flowLayout) private let footerContainer = UIView() - private var footerCell: NativeListRowHost = NativeListRendererRegistry.create(.legacy) - private var footerRendererKey = NativeListRendererKey.legacy + private var footerCell: NativeListRowHost = NativeListRendererRegistry.create(.action) + private var footerRendererKey = NativeListRendererKey.action private let sectionIndexView = NativeListSectionIndexView() private let sectionIndexPreview = NativeListSectionIndexPreviewView() private var sectionIndexLayoutConstraints: [NSLayoutConstraint] = [] From cc409d4594ba46cbf163fea37742f4da2312c809 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 23:18:12 +0800 Subject: [PATCH 59/71] refactor(native-list): retire key styling and renderer fallbacks --- .../nativelist/NativeListRendererRegistry.kt | 8 -- .../nativelist/NativeListRendererRowView.kt | 2 +- .../onekey/nativelist/NativeListRowHost.kt | 2 +- .../NativeListSectionHeaderRenderer.kt | 20 +--- .../docs/MIGRATION_BASELINE.md | 30 ++++-- .../ios/NativeListSectionHeaderRenderer.swift | 8 +- .../src/__tests__/NativeList.web.test.ts | 35 +++++++ .../src/web/NativeListWebEngine.ts | 99 ++++--------------- .../src/web/templates/ActionRowRenderer.ts | 2 + .../src/web/templates/IdentityRowRenderer.ts | 8 ++ .../src/web/templates/RowElements.ts | 8 ++ .../src/web/templates/RowRendererRegistry.ts | 18 +++- .../web/templates/SectionHeaderRowRenderer.ts | 6 +- .../web/templates/WalletGroupRowRenderer.ts | 5 +- 14 files changed, 123 insertions(+), 128 deletions(-) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt index 5713b89b4..b96e3d0ce 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt @@ -40,11 +40,3 @@ internal object NativeListRendererRegistry { fun create(context: ThemedReactContext, viewType: Int): NativeListRowHost = factories.getValue(NativeListRendererKey.entries[viewType])(context) } - -// Compatibility belongs to list chrome, never to Message style resolution. -// docs/MIGRATION_BASELINE.md records the callers that must migrate before removal. -internal fun nativeListLegacyShowsSeparator(item: NativeListItem): Boolean = - item.json.optBoolean("separator") && - !item.key.startsWith("token-") && - !item.key.startsWith("balance-token-") && - item.key != "linear-custom-token" diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt index ed633f375..8032e3d25 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt @@ -359,7 +359,7 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th override fun dispatchDraw(canvas: Canvas) { super.dispatchDraw(canvas) - current?.takeIf(::nativeListLegacyShowsSeparator)?.let { + current?.takeIf { it.json.optBoolean("separator") }?.let { val start = listStyle ?.optJSONObject("separator") diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowHost.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowHost.kt index d733d5adc..d3980d84d 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowHost.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowHost.kt @@ -16,7 +16,7 @@ internal data class NativeListActionOrigin( val windowPointPixels: android.graphics.PointF? = null, ) -/** List-facing lifecycle shared by lightweight and legacy row implementations. */ +/** List-facing lifecycle shared by template renderers. */ internal abstract class NativeListRowHost(context: ThemedReactContext) : LinearLayout(context) { var bindingEpoch: Long = 0 protected set diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt index e78026075..ed367773c 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt @@ -64,9 +64,7 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : layout == "table" -> 28 item.json.optString("variant") == "summary" -> 80 item.json.optString("variant") == "gallery" -> 32 - item.json.optString("variant") == "history" || - item.sectionKey?.startsWith("history-") == true -> 16 - item.sectionKey in setOf("linear-tokens", "action-tokens") -> 30 + item.json.optString("variant") == "history" -> 16 item.json.optString("value").isNotEmpty() && item.json.optJSONObject("checkbox") != null -> 40 else -> 36 } @@ -223,7 +221,7 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : variant == "gallery" -> 18f variant == "summary" -> 16f layout == "table" -> 11f - variant == "history" || item.sectionKey?.startsWith("history-") == true -> 12f + variant == "history" -> 12f else -> 14f } ) @@ -237,8 +235,6 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : network -> NativeListFonts.medium(context) layout == "table" -> NativeListFonts.regular(context) variant == "summary" -> NativeListFonts.medium(context) - item.sectionKey in setOf("linear-tokens", "action-tokens") -> - NativeListFonts.regular(context) else -> NativeListFonts.semibold(context) } TextViewCompat.setLineHeight( @@ -248,7 +244,7 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : network && variant != "summary" -> 20 variant in setOf("gallery", "summary") -> 24 layout == "table" -> 14 - variant == "history" || item.sectionKey?.startsWith("history-") == true -> 16 + variant == "history" -> 16 else -> 20 } ), @@ -416,8 +412,7 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : title.setOnClickListener { emitAction(item, key, null, title, "leadingAction") } } val isNetworkSelector = item.json.optString("presentation") == "networkSelector" - val isHistory = variant == "history" || item.sectionKey?.startsWith("history-") == true - val isTokenManager = item.sectionKey in setOf("linear-tokens", "action-tokens") + val isHistory = variant == "history" val isExplicitNetworkHeader = isNetworkSelector && item.json.has("height") val hasDottedTitle = isSummary || @@ -460,11 +455,6 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : title.typeface = NativeListFonts.semibold(context) title.letterSpacing = 0.8f / sp(12f) TextViewCompat.setLineHeight(title, dp(16)) - } else if (isTokenManager) { - setPadding(dp(headerHorizontalInset), dp(10), dp(headerHorizontalInset), 0) - title.textSize = sp(14f) - title.typeface = NativeListFonts.regular(context) - TextViewCompat.setLineHeight(title, dp(20)) } else if (!isTable && !isGallery && !isSummary) { // SectionList.SectionHeader: h=36, px=20, headingSm 14/20 semibold. setPadding(dp(headerHorizontalInset), dp(8), dp(headerHorizontalInset), dp(8)) @@ -514,7 +504,7 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : if (isExplicitNetworkHeader) trailingViews[0].setPadding(0, 0, 0, 0) else trailingViews[0].setPadding(dp(14), dp(6), dp(14), dp(6)) trailingViews[0].layoutParams = wrap() - } else if (!isGallery && !isHistory && !isTokenManager) { + } else if (!isGallery && !isHistory) { val value = item.json.optString("value") val checkboxData = item.json.optJSONObject("checkbox") if (isExplicitNetworkHeader && checkboxData != null) { diff --git a/native-views/react-native-native-list/docs/MIGRATION_BASELINE.md b/native-views/react-native-native-list/docs/MIGRATION_BASELINE.md index 927345935..609fdb60b 100644 --- a/native-views/react-native-native-list/docs/MIGRATION_BASELINE.md +++ b/native-views/react-native-native-list/docs/MIGRATION_BASELINE.md @@ -14,11 +14,11 @@ sets. Generated bindings and examples do not define renderer semantics. | Rule | Existing behavior and source | Disposition | | --- | --- | --- | -| `history-` prefix | iOS `NativeListCell.bindSectionHeader` and `RNCNativeListView.rowHeight`: row key **or** section key implies history title/height. Web `estimateWebRowHeight`: same two keys imply 16-unit header height; its rendering still follows the explicit variant. Android `bindSectionHeader`/`applySize`: only **section key** implies uppercase 12/16 title, 0 padding and 16-unit minimum height. Explicit `variant: history` works independently. | Stage 4 moved these defaults into the SectionHeader renderers. Both example history headers and the validation fixture already set `variant: history`; no implicit history-header caller remains in this repository. Stage 6 still requires an external-consumer audit before deleting the compatibility fallback. | -| `token-`, `balance-token-`, exact `linear-custom-token` | Android `NativeListRowView.bind` suppresses `separator: true` for these keys across row types. iOS/Web have no equivalent key exception. | Preserve as an explicitly named Android legacy container policy, including Message. Do not embed it in Message style resolution. Stage 6 requires caller-owned `separator: false` before removal. | -| exact section keys `linear-tokens`, `action-tokens` | Android `bindSectionHeader`/`applySize` applies token-manager 14/20 regular typography, horizontal 12/top 10/bottom 0 padding and 30-unit minimum height (subject to earlier explicit presentation/variant branches). No matching iOS/Web rule. | Stage 4 migrated both example callers to an explicit 30-unit container height, bottom alignment, 12-unit horizontal / zero vertical padding and regular 14/20 title. The fallback remains in SectionHeader until stage 6 verifies external consumers. | +| `history-` prefix | iOS `NativeListCell.bindSectionHeader` and `RNCNativeListView.rowHeight`: row key **or** section key implies history title/height. Web `estimateWebRowHeight`: same two keys imply 16-unit header height; its rendering still follows the explicit variant. Android `bindSectionHeader`/`applySize`: only **section key** implies uppercase 12/16 title, 0 padding and 16-unit minimum height. Explicit `variant: history` works independently. | Stage 4 moved these defaults into the SectionHeader renderers. Both example history headers and the validation fixture already set `variant: history`; no implicit history-header caller remains in this repository. Stage 6 audited the app-monorepo callers and removes the compatibility fallback; only the explicit variant selects history styling. | +| `token-`, `balance-token-`, exact `linear-custom-token` | Android `NativeListRowView.bind` suppresses `separator: true` for these keys across row types. iOS/Web have no equivalent key exception. | Removed in stage 6 after auditing examples and app-monorepo: no matching caller sets `separator: true`. Separators now obey the explicit field on every platform. | +| exact section keys `linear-tokens`, `action-tokens` | Android `bindSectionHeader`/`applySize` applies token-manager 14/20 regular typography, horizontal 12/top 10/bottom 0 padding and 30-unit minimum height (subject to earlier explicit presentation/variant branches). No matching iOS/Web rule. | Stage 4 migrated both example callers to an explicit 30-unit container height, bottom alignment, 12-unit horizontal / zero vertical padding and regular 14/20 title. Stage 6 verified external consumers and removes the fallback. Renaming either key no longer changes typography or geometry. | | exact tail keys `market-loading-more`, `market-load-more-retry`, `market-end` | Android `NativeListView.isMarketPaginationUpdate` drops those trailing rows before checking a stable Market prefix when either snapshot has loadMore; this keeps the scroll anchor during pagination. It is a container update optimization, not a row renderer. | Preserve in the container. Replace with explicit structural-row recognition in a separately validated pagination change. | -| exact column key `asset` | Web `createDataRow` adds row badges only to that table column. Native table binders attach row badges to the first column instead. | Preserve until stage 3 audits DataRow callers; do not silently broaden every column's badge behavior. | +| exact column key `asset` | Web `createDataRow` adds row badges only to that table column. Native table binders attach row badges to the first column instead. | Retained as a documented DataRow compatibility rule. No app-monorepo DataRow caller was found in the stage 6 audit; changing badge placement requires a separate explicit API/caller migration. | There are no other business-key-dependent decisions in the audited production paths. Equality for identity/diffing, selection targets, section membership, @@ -26,6 +26,20 @@ scroll targets, reorder membership, action lookup, image recycling keys and stale-callback guards is intentional identity usage, not a business-key rule. Keyboard event keys and style-property whitelist keys are also unrelated. +## Stage 6 consumer audit + +Audited on 2026-09-23: local app-monorepo `67fcc71204` and `origin/x` +`62c645f906`. NativeList imports/builders in AccountSelector, UnifiedNetworkSelector +and Market have no history/token key styling dependencies. Repository token rows +omit `separator` (false); history headers specify `variant: history`; token +headers already specify their height, alignment, spacing and typography. +No external checkout changes were needed. This is a source audit of these refs, +not a claim about all published consumers. + +The two retained rules above are the container pagination optimization and +DataRow badge-column ownership. Neither chooses a renderer or maps `row.style` +fields. List event/selection/reorder identities continue to use keys normally. + ## Default geometry and precedence [STYLE_SPEC.md sections 8–9](STYLE_SPEC.md) inventory all twelve templates' row @@ -97,7 +111,7 @@ of its 30-unit row without requiring a new asymmetric-padding property. Those explicit style values use logical units on all platforms, replacing Android's old scaled default at these caller sites. -This repository audit does not establish that external app-monorepo consumers -have migrated. Keep the documented native/Web key fallbacks until stage 6 -checks those callers. WalletGroup's nested native Identity cells continue to -use the old implementation until the separately scoped stage 5 migration. +Stage 6 completed the external-consumer source audit described above and removed +the native/Web header key fallbacks. Stage 5 also migrated WalletGroup members +to independent Identity hosts. The historical defaults in this baseline remain +useful when reviewing explicitly styled caller behavior. diff --git a/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift b/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift index f21ff33b8..c16d184b8 100644 --- a/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift @@ -98,9 +98,7 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { ) -> CGFloat? { if layout == "table" { return 28 } if item.data.string("presentation") == "networkSelector" { return 47 } - if item.data.string("variant") == "history" || item.key.hasPrefix("history-") - || item.sectionKey?.hasPrefix("history-") == true - { + if item.data.string("variant") == "history" { return 16 } if item.data.string("variant") == "summary" { return 68 } @@ -345,9 +343,7 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { } titleLabel.reservesDottedUnderlineSpace = isExplicitNetworkHeader && !item.data.string("titleActionKey").isEmpty - let isHistory = - variant == "history" || item.key.hasPrefix("history-") - || (item.sectionKey?.hasPrefix("history-") ?? false) + let isHistory = variant == "history" let headerWeight: NativeListFontWeight = isSummary ? .medium diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index 1066d731f..92f71cdf2 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -191,6 +191,41 @@ describe('NativeList pure DOM web layout', () => { expect(webSectionIndexInverseScale(640, 0)).toBe(1); }); + it('uses explicit header variants instead of row or section key naming', () => { + const header = { + type: 'sectionHeader', + key: 'ordinary', + sectionKey: 'ordinary', + title: 'Date', + } as const; + const state = snapshot({ kind: 'sectioned' }, [header]); + const height = estimateWebRowHeight(header, state, 320); + for (const keys of [ + { key: 'history-today', sectionKey: 'ordinary' }, + { key: 'ordinary', sectionKey: 'history-today' }, + { key: 'ordinary', sectionKey: 'linear-tokens' }, + { key: 'ordinary', sectionKey: 'action-tokens' }, + ]) { + expect(estimateWebRowHeight({ ...header, ...keys }, state, 320)).toBe( + height + ); + expect( + estimateWebRowHeight( + { ...header, ...keys, variant: 'history' }, + state, + 320 + ) + ).toBe(16); + expect( + estimateWebRowHeight( + { ...header, ...keys, style: { container: { height: 30 } } }, + state, + 320 + ) + ).toBe(30); + } + }); + it('keeps the final indexed section active at the scroll limit', () => { const indexedRows: readonly RowModel[] = [ { diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index 65ebc70e2..fac978a2d 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -440,18 +440,15 @@ export function estimateWebRowHeight( snapshot: NativeListSnapshot, availableWidth: number ): number { - // OneKey patch: explicit selector height takes precedence over presets. - // if (row.type === 'system' && row.variant === 'spacer') return row.height; + // Explicit container/model height takes precedence over template presets. const explicitHeight = row.style?.container?.height ?? row.height; if (explicitHeight !== undefined) return explicitHeight; const registered = rowRenderer(row); - if (registered) - return Math.max( - 0, - registered.measure(availableWidth, snapshot.layout.kind) + - (registered.appliesSizePreset ? sizeModifier(row) : 0) - ); - return Math.max(0, 56 + sizeModifier(row)); + return Math.max( + 0, + registered.measure(availableWidth, snapshot.layout.kind) + + (registered.appliesSizePreset ? sizeModifier(row) : 0) + ); } function estimateHorizontalWidth(row: RowModel): number { @@ -1899,35 +1896,6 @@ function createAccessory( return element; } -/** - * Marks the element that renders one model field. Style keys name model fields, - * not views, and the view pool is shared. See docs/STYLE_SPEC.md §4. - */ - -// OneKey patch: SizableText enables tabular digits without replacing its font family. -function applySelectorTabularNumbers(body: HTMLElement, row: RowModel) { - if ( - !('presentation' in row) || - !['accountSelector', 'networkSelector', 'walletSidebar'].includes( - row.presentation ?? '' - ) - ) - return; - body.style.fontVariantNumeric = 'tabular-nums'; - body.querySelectorAll('span,button').forEach((text) => { - text.style.fontVariantNumeric = 'tabular-nums'; - }); -} - -/** Text owns a single wrapping budget, including its rich runs. */ - -/** - * Applies a row style once the template has been built. A style key names the - * model field it modifies and the element rendering that field carries - * `data-nl-slot`, because the view pool is shared across templates. Market - * keeps its own richer path. See docs/STYLE_SPEC.md §4 and §8. - */ - function rendererPrimitives(context: RenderContext) { return { background: resolvedTheme(context.snapshot).background, @@ -1953,10 +1921,7 @@ export function createRowBody( context: RenderContext, row: RowModel ): HTMLElement { - const registered = rowRenderer(row); - if (registered) - return registered.create(context.document, rendererPrimitives(context)); - throw new Error('No renderer registered for ' + row.type); + return rowRenderer(row).create(context.document, rendererPrimitives(context)); } export class NativeListWebEngine { @@ -1984,7 +1949,6 @@ export class NativeListWebEngine { }; private readonly mounted = new Map(); private readonly pool: Record = { - legacy: [], walletGroup: [], message: [], rail: [], @@ -2051,8 +2015,7 @@ export class NativeListWebEngine { private lastViewportHeight = -1; private destroyed = false; private sectionIndexAlignStart = false; - // OneKey patch: warning banners are measured after normal browser text wrapping. - private measuredWarningHeights = new Map(); + // Renderers report intrinsic height after browser text wrapping. private measuredRendererHeights = new Map< string, { signature: string; height: number } @@ -2465,7 +2428,6 @@ export class NativeListWebEngine { selectedKeys?: ReadonlySet ) { this.cancelMarketPointer(); - this.measuredWarningHeights.clear(); this.measuredRendererHeights.clear(); this.snapshot = snapshot; this.rows = effectiveRows(snapshot); @@ -2564,7 +2526,6 @@ export class NativeListWebEngine { viewportHeight !== this.lastViewportHeight) ) { this.invalidateActionAnchor('layout'); - this.measuredWarningHeights.clear(); this.measuredRendererHeights.clear(); } this.lastViewportWidth = viewportWidth; @@ -2573,7 +2534,6 @@ export class NativeListWebEngine { const measuredSnapshot: NativeListSnapshot = { ...this.snapshot, rows: this.snapshot.rows.map((row) => - rowRenderer(row) && row.style?.container?.height === undefined && row.height === undefined && this.measuredRendererHeights.get(row.key)?.signature === @@ -2582,12 +2542,6 @@ export class NativeListWebEngine { ...row, height: this.measuredRendererHeights.get(row.key)!.height, } - : row.type === 'system' && - row.variant === 'warning' && - row.style?.container?.height === undefined && - row.height === undefined && - this.measuredWarningHeights.has(row.key) - ? { ...row, height: this.measuredWarningHeights.get(row.key) } : row ), }; @@ -2722,7 +2676,7 @@ export class NativeListWebEngine { element.dataset.renderSignature = signature; } }); - let measuredWarningChanged = false; + let measuredHeightChanged = false; this.mounted.forEach((element, index) => { const row = this.rows[index]; const registered = row && rowRenderer(row); @@ -2740,26 +2694,12 @@ export class NativeListWebEngine { signature: webRowRenderSignature(row), height, }); - measuredWarningChanged = true; + measuredHeightChanged = true; } return; } - if ( - row?.type !== 'system' || - row.variant !== 'warning' || - row.style?.container?.height !== undefined || - row.height !== undefined - ) - return; - const height = - element.querySelector('.ok-native-list-warning') - ?.offsetHeight ?? 0; - if (height > 0 && height !== this.measuredWarningHeights.get(row.key)) { - this.measuredWarningHeights.set(row.key, height); - measuredWarningChanged = true; - } }); - if (measuredWarningChanged) { + if (measuredHeightChanged) { this.recomputeLayout(); return; } @@ -2784,7 +2724,7 @@ export class NativeListWebEngine { const registered = rowRenderer(row); const existingBody = element.firstElementChild as HTMLElement | null; const reusableBody = - registered && existingBody?.dataset.nlRenderer === registered.key + existingBody?.dataset.nlRenderer === registered.key ? existingBody : undefined; if (!reusableBody) disposeWebImageRetries(element); @@ -2836,9 +2776,7 @@ export class NativeListWebEngine { itemIndex: index, }; const body = reusableBody ?? createRowBody(context, row); - if (reusableBody && registered) - registered.bind(body, rendererPrimitives(context)); - applySelectorTabularNumbers(body, row); + if (reusableBody) registered.bind(body, rendererPrimitives(context)); // OneKey patch: explicit selector fields preserve original page geometry. element.style.contain = row.backgroundFullWidth ? 'layout style' : ''; if (row.backgroundColor) body.style.backgroundColor = row.backgroundColor; @@ -4258,11 +4196,11 @@ export class NativeListWebEngine { const row = source?.firstElementChild; if (!source || !row) return; const rect = source.getBoundingClientRect(); - const groupParent = row.querySelector( - '[data-native-list-group-parent="true"]>.ok-native-list-wallet-row' - ); - const previewRow = groupParent ?? row; - const previewHeight = groupParent ? 68 : rect.height; + const sourceRow = state.workingRows[state.currentIndex]; + const preview = + sourceRow && rowRenderer(sourceRow).reorderPreview(row as HTMLElement); + const previewRow = preview?.element ?? row; + const previewHeight = preview?.height ?? rect.height; state.previewOffsetX = state.startX - rect.left; state.previewOffsetY = Math.min( previewHeight, @@ -4286,7 +4224,6 @@ export class NativeListWebEngine { } configureWebAvatar(image, avatar.source, avatar.uri); }); - const sourceRow = state.workingRows[state.currentIndex]; const badgeText = sourceRow ? webWalletGroupReorderBadge(sourceRow) : undefined; diff --git a/native-views/react-native-native-list/src/web/templates/ActionRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/ActionRowRenderer.ts index c4cb0bd07..bdb9663dd 100644 --- a/native-views/react-native-native-list/src/web/templates/ActionRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/ActionRowRenderer.ts @@ -1,3 +1,4 @@ +import { applyTabularNumbers } from './RowElements'; import type { ActionRow } from '../../models'; import { RowVisualStyle, type RowPrimitives } from './RowVisual'; @@ -84,6 +85,7 @@ function bind(body: HTMLElement, row: ActionRow, primitives: RowPrimitives) { v.controls.appendChild(trailing); } v.controls.style.display = 'contents'; + if (row.presentation === 'accountSelector') applyTabularNumbers(body); } function create(document: Document, row: ActionRow, primitives: RowPrimitives) { const body = document.createElement('div'); diff --git a/native-views/react-native-native-list/src/web/templates/IdentityRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/IdentityRowRenderer.ts index 8e436d762..18606264f 100644 --- a/native-views/react-native-native-list/src/web/templates/IdentityRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/IdentityRowRenderer.ts @@ -1,3 +1,4 @@ +import { applyTabularNumbers } from './RowElements'; import type { IdentityRow, NativeListTextStyle, @@ -411,6 +412,13 @@ function bind(body: HTMLElement, row: IdentityRow, primitives: RowPrimitives) { body.replaceChildren(...Array.from(next.childNodes)); body.className = next.className; body.style.cssText = next.style.cssText; + if ( + row.presentation && + ['accountSelector', 'networkSelector', 'walletSidebar'].includes( + row.presentation + ) + ) + applyTabularNumbers(body); for (const name of body.getAttributeNames()) if (name.startsWith('data-') && name !== 'data-nl-renderer') body.removeAttribute(name); diff --git a/native-views/react-native-native-list/src/web/templates/RowElements.ts b/native-views/react-native-native-list/src/web/templates/RowElements.ts index 43470f98e..95b91c209 100644 --- a/native-views/react-native-native-list/src/web/templates/RowElements.ts +++ b/native-views/react-native-native-list/src/web/templates/RowElements.ts @@ -98,3 +98,11 @@ export function createBadge( setData(element, 'tone', badge.tone); return element; } + +// Selector digits retain their font family, including explicit button fonts. +export function applyTabularNumbers(body: HTMLElement): void { + body.style.fontVariantNumeric = 'tabular-nums'; + body.querySelectorAll('span,button').forEach((text) => { + text.style.fontVariantNumeric = 'tabular-nums'; + }); +} diff --git a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts index 01a667d17..1ec18544f 100644 --- a/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts +++ b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts @@ -33,6 +33,10 @@ type Renderer = { measure: (row: R, width: number, layout?: string) => number; measureRendered: (body: HTMLElement, row: R) => number | undefined; recycle: (body: HTMLElement) => void; + reorderPreview?: ( + body: HTMLElement, + row: R + ) => { element: HTMLElement; height: number } | undefined; }; function registration(renderer: Renderer, row: R) { return { @@ -45,6 +49,7 @@ function registration(renderer: Renderer, row: R) { measure: (width: number, layout?: string) => renderer.measure(row, width, layout), measureRendered: (body: HTMLElement) => renderer.measureRendered(body, row), + reorderPreview: (body: HTMLElement) => renderer.reorderPreview?.(body, row), }; } const factories = { @@ -67,7 +72,10 @@ const factories = { rail: (row: RowModel) => registration(railRowRenderer, row as RailRow), mediaTile: (row: RowModel) => registration(mediaTileRowRenderer, row as MediaTileRow), -}; +} satisfies Record< + RowModel['type'], + (row: RowModel) => ReturnType +>; const renderers = { walletGroup: walletGroupRowRenderer, identity: identityRowRenderer, @@ -82,16 +90,18 @@ const renderers = { rail: railRowRenderer, mediaTile: mediaTileRowRenderer, }; -export type RowRendererKey = 'legacy' | keyof typeof factories; +export type RowRendererKey = RowModel['type']; const isRegistered = (key: string): key is keyof typeof factories => Object.prototype.hasOwnProperty.call(factories, key); // Closed registration; a row's data key, styles and placement never affect reuse. export function rowRenderer(row: RowModel) { - return isRegistered(row.type) ? factories[row.type](row) : undefined; + if (!isRegistered(row.type)) + throw new Error('No renderer registered for ' + row.type); + return factories[row.type](row); } export function rowRendererKey(row: RowModel): RowRendererKey { - return isRegistered(row.type) ? row.type : 'legacy'; + return row.type; } export function recycleRowBody(body: HTMLElement): boolean { const key = body.dataset.nlRenderer ?? ''; diff --git a/native-views/react-native-native-list/src/web/templates/SectionHeaderRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/SectionHeaderRowRenderer.ts index e74195581..dcc5cba1f 100644 --- a/native-views/react-native-native-list/src/web/templates/SectionHeaderRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/SectionHeaderRowRenderer.ts @@ -1,3 +1,4 @@ +import { applyTabularNumbers } from './RowElements'; import type { SectionHeaderRow } from '../../models'; import { createElement, @@ -166,6 +167,7 @@ function bind( body.replaceChildren(...Array.from(next.childNodes)); body.className = next.className; body.style.cssText = next.style.cssText; + if (row.presentation === 'networkSelector') applyTabularNumbers(body); body.dataset.variant = row.variant ?? ''; body.removeAttribute('data-nl-container-background'); body.removeAttribute('data-nl-container-border'); @@ -223,9 +225,7 @@ export const sectionHeaderRowRenderer = { ? 28 : row.presentation === 'networkSelector' ? 47 - : row.variant === 'history' || - row.key.startsWith('history-') || - row.sectionKey.startsWith('history-') + : row.variant === 'history' ? 16 : row.variant === 'summary' ? 68 diff --git a/native-views/react-native-native-list/src/web/templates/WalletGroupRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/WalletGroupRowRenderer.ts index 162bd4885..c8a63566f 100644 --- a/native-views/react-native-native-list/src/web/templates/WalletGroupRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/WalletGroupRowRenderer.ts @@ -64,7 +64,6 @@ function bind( ); if (memberRow.style?.container?.cornerRadius !== undefined) wrapper.style.borderRadius = `${memberRow.style.container.cornerRadius}px`; - memberBody.style.fontVariantNumeric = 'tabular-nums'; if (memberRow.backgroundColor) memberBody.style.backgroundColor = memberRow.backgroundColor; applyRowContainerStyle(memberBody, memberRow); @@ -91,6 +90,10 @@ function recycle(body: HTMLElement) { } export const walletGroupRowRenderer = { key: 'walletGroup' as const, + reorderPreview: (body: HTMLElement, row: WalletGroupRow) => { + const parent = membersByBody.get(body)?.get(row.parent.key); + return parent ? { element: parent.body, height: 68 } : undefined; + }, create, bind, recycle, From 969f722a629bce23262fdbdb2549f308ec2694ca Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 23 Sep 2026 23:20:56 +0800 Subject: [PATCH 60/71] docs(native-list): complete renderer cleanup and acceptance --- .../react-native-native-list/docs/DESIGN.md | 60 +++++++++++++--- .../react-native-native-list/docs/SPEC.md | 45 ++++++++---- .../docs/STYLE_SPEC.md | 72 +++++++++---------- 3 files changed, 121 insertions(+), 56 deletions(-) diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index 8fb1c6a3b..967e2ff43 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -98,19 +98,18 @@ completion-once guards and stale-write invalidation. A text/style-only update retains the existing image slot and, when its effective image request is unchanged, the request. Geometry/content-fit changes may require the image module to decode/request again. The existing bounded source-fallback cache is -shared with legacy visuals. Image slots are lazy and bounded to the template's +shared by image primitives. Image slots are lazy and bounded to the template's visible assets; Message never allocates the unused legacy Market/Wallet/table subtrees. This is source-level ownership evidence, not a performance benchmark. The renderer resolves defaults from current data/theme/style rather than -capturing a previous row's view state. Legacy global slot maps no longer contain -Message or Rail. No public plugin API or Nitro schema is added. Remaining template maps -and partial updates are retired only with their own migration. See +capturing a previous row's view state. Global cross-template slot maps and legacy hosts are removed after all twelve +renderer migrations. No public plugin API or Nitro schema is added. See [SPEC.md](SPEC.md#conformance-and-six-stage-migration) for all six stages. Structural reuse now has twelve dedicated families on all platforms; see the current registry inventory in SPEC.md. WalletGroup composes keyed Identity -hosts. The old native classes remain only as stage 6 cleanup. +hosts. The old native classes and fallback registry entries are deleted in stage 6. Neither `row.key`, content, style, height nor placement changes the family. Web selects a compatible wrapper pool even when the row at a mounted index changes family. iOS registers separate reuse identifiers and reloads retained @@ -535,6 +534,51 @@ seven tests in three suites. Web tests additionally assert keyed member/image retention, removed-member disposal, style clearing and disabled menu dispatch. The validation runtime's `stage5` directory contains fixture, scripts, screenshots, accessibility trees and iOS drag recordings. This is functional/visual simulator -acceptance, not a performance benchmark. Stage 6 alone remains in this six-stage -architecture plan: remove dead native composite/global maps and retire the -inventoried compatibility policies after checking their callers. +acceptance, not a performance benchmark. Stage 6 completion and retained compatibility boundaries are recorded below. + +### Stage 6: remove migration scaffolding (2026-09-23) + +Deleted `NativeListCell.swift` and `NativeListRowView.kt`, their global style-slot +maps, and the obsolete slot-map tests. Still-used fonts, icons, controls and +layout helpers keep their existing behavior in shared primitive/host files. +The registry has only the twelve supported families; native parsing rejects +unknown types. Footer initialization starts with the lightweight Action host. + +Web removes the legacy pool and warning-specific container measurement cache. +Selector tabular typography belongs to Identity, Header and Action. WalletGroup +provides its compact parent preview through the internal renderer registration; +the container no longer selects a parent DOM node from the composite internals. +Common event hit-testing, selection primitives and cloned-image leases remain +container responsibilities. + +The source audit at the app-monorepo refs in MIGRATION_BASELINE.md found no +callers dependent on history/token key styling or separator suppression. +Removed those three inference rules. Explicit variant/style/separator fields +now determine the behavior. The audited Market pagination-tail optimization and +Web DataRow `asset` badge placement remain documented compatibility boundaries; +this cleanup does not alter pagination or introduce a new column-badge API. + +Acceptance ran on the same task-owned external-drive iOS 26.5 and Android API 36 +simulators and headed Chrome. The same row key cycles through all twelve families; +each survives an explicit styled height/background and clearing back to its +original geometry. Ordinary/history-named headers match; explicit history uses +16 units, and explicit token-header styles use 30. Action → System retry → Action +footers render and emit their current action. Scrolling/reuse and member/menu +routing pass. WalletGroup drag previews remain 68 high with +1 for one draggable +child, excluded add-wallet members do not reorder, and dropping restores default +274 or explicit 340 heights. Web also checks a 390px viewport and changing family +while scrolled. Screenshots show controls/footer within the viewport; list content +scrolls normally. No stale prior-template content or cleared-style leakage was +observed in these scenarios. + +Validation: TypeScript, 164 tests in five suites, focused ESLint (zero errors, +two pre-existing shadowing warnings), iOS Debug build/link and Android Debug APK +plus five unit tests in two suites. The two deleted tests only exercised the +removed global slot mapper. The source audit finds no legacy host/registry/global +slot references in production code and no header/separator business-key styling. + +Fixtures, scripts, build/test logs, screenshots, accessibility trees, event/geometry +results and iOS drag recordings are saved under the validation runtime's `stage6` +directory. This completes all six architecture stages, with the two explicit +compatibility boundaries above. It is functional/visual simulator acceptance, +not a scrolling-performance benchmark or a full app-monorepo release signoff. diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index f8d81ccbc..932fcaf30 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -82,13 +82,13 @@ separate workload and measurements. | 3. Simple templates | Complete (7/7): Rail, MediaTile, Action, System, Activity, DataRow and MetricCard migrated on all three platforms | Migrate mediaTile, rail, action, system, activity, dataRow and metricCard; remove each old dispatch/reset path | | 4. Complex templates | Complete (3/3): Market, Identity and SectionHeader migrated on all three platforms | Migrate Market, Identity and SectionHeader while preserving quote, selection and header behavior | | 5. WalletGroup | Complete: registered composites and independent keyed Identity members on all three platforms | Independent member renderers; verify member events/styles, compact dragging and height restoration | -| 6. Cleanup | In progress: remove obsolete hosts/registries, then key-based styling and container fallbacks | Remove migrated key inference and global style maps; container no longer manipulates template internals | +| 6. Cleanup | Complete: obsolete hosts/maps/fallback pools and audited key styling removed | Remove migrated key inference and global style maps; container no longer manipulates template internals | The closed internal registry selects all twelve renderers by template type. No supported template dispatches to the legacy native tree. WalletGroup owns composition and compact appearance; its members use independent Identity -renderers. Legacy native classes/global maps remain unreachable for supported -rows and will be removed in stage 6. Web preserves compatible bodies and +renderers. Legacy native classes, global style maps and fallback registry entries +are removed, including footer initialization. Web preserves compatible bodies and unchanged member image elements across rebinding. Message resolves text/spacing/image inputs from data/theme/styles, classifies @@ -97,8 +97,8 @@ using those resolved values. Image slots retain an unchanged effective request; source/row/slot or image-request-option changes invalidate old callbacks and retries. Image request epochs are independent of row action-anchor epochs, so text-only updates preserve in-flight images. Legacy Message binding, slot-map -and measurement branches have been removed. The named Android separator-key -compatibility policy is retained outside the renderer's style resolver. +and measurement branches have been removed. Stage 6 also removes the audited Android separator-key +compatibility policy; the explicit separator field controls visibility. Message intrinsic measurement now uses its actual resolved view metrics rather than the historical estimator-only defaults listed in MIGRATION_BASELINE.md. @@ -157,9 +157,9 @@ composite cards expose the heading slot and keep metric internals fixed as defined in STYLE_SPEC.md. Image slots retain unchanged requests across rebinding and reset when a source disappears or its slot identity changes. -Stage 3 completion is not completion of the six-stage architecture plan. -Stages 4 and 5 are recorded below. Stage 6 remains: retire the remaining -compatibility inference/style maps and container access to internals. +All six stages are complete. Stages 4–6 and their acceptance scopes are recorded +below; the baseline inventory explicitly identifies two retained compatibility +policies outside renderer selection/style mapping. No migrated simple template dispatches through a legacy binder. ### Stage 4 acceptance scope @@ -179,16 +179,15 @@ measurement. Native hosts share bounded leading/image/accessory primitives. Web retains the compatible body and unchanged image elements; loaded image state is not reset when geometry is restored. Quote and summary updates keep the latest row model for subsequent action/selection dispatch. No new public API or Nitro -schema is introduced. Stage 5 below also replaces the native WalletGroup composite tree. The now -unreachable legacy classes remain for stage 6 cleanup. +schema is introduced. Stage 5 below also replaces the native WalletGroup composite tree. Stage 6 below removes the +obsolete native classes and maps. Stage 4 validation: package typecheck and all 161 tests in five suites pass; focused lint has zero errors and two existing shadowing warnings. iOS Debug build/link and Android Debug APK plus seven unit tests pass. Both dedicated native simulators and headed Chrome exercised the acceptance cases above. The section fixture additionally verified imperative scroll pinning and active -index updates after the container fixes. Stage 5 acceptance follows below; stage 6 compatibility/global-map cleanup -remains. This is not a performance signoff. +index updates after the container fixes. Stages 5 and 6 acceptance follow below. This is not a performance signoff. ### Stage 5 ownership and acceptance @@ -223,3 +222,25 @@ unit tests pass. Headed Chrome includes a 390px viewport and in-drag captures. Native simulator captures, action payloads and iOS recordings are stored with the stage 5 harness in the external validation runtime. These are standalone NativeList checks, not a consumer-app release or scrolling-performance signoff. + +### Stage 6 cleanup and acceptance + +The native monoliths and global style-slot maps are deleted. Their still-used +fonts, icons, layout helpers, action origins and controls remain shared primitives. +All twelve registry families are explicit; unsupported native model types are +rejected during parsing. Footer initialization uses a registered Action host. +Web has no legacy pool or warning-specific container measurement path; template +renderers own selector typography and WalletGroup compact-preview lookup. + +The consumer audit in MIGRATION_BASELINE.md allows removal of history-key header +inference, Android token-section typography inference, and separator-key exceptions. +Callers choose `variant`, `style` and `separator` explicitly. Market pagination-tail +recognition and the existing Web DataRow `asset` badge column are deliberately +retained under the separately documented migration boundaries. + +Validation covers same-key cycling through twelve templates, style set/clear, +ordinary/history-named/explicit-history headers, explicit 30-unit token headers, +Action/System footer replacement and callbacks, scrolling/reuse and WalletGroup +member actions and compact dragging. Source/type checks and runtime evidence +are recorded separately in DESIGN.md. This completes the architecture migration; +it does not add automatic overflow fitting or claim a performance benchmark. diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index 8fce50b05..8eec8b849 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -116,15 +116,16 @@ object identity. | Legacy baseline dimensions | Inventory in §4/§6; explicit style values use logical units independently of Android's legacy list scale | | Rendered acceptance | The source contract and build checks do not establish full native interaction/pixel acceptance (§9) | -The field-to-view mapping exists twice, once per native language — `nativeListStyleSlot` -in `NativeListModels.kt` and `styleSlot` in `NativeListCell.swift`. §4 is the source of -truth for both; the Kotlin copy is unit-tested, including a check that no template maps -two style keys onto one view. +Each renderer maps its own model fields to owned views. There is no global +cross-template style-slot map or shared legacy text-view pool. §4 remains the +contract for the Swift, Kotlin and Web implementations; regression tests check +semantic isolation and style set/clear behavior. The example application's **Native List Row Style** page has plain/styled pairs for all 12 types, plus list chrome and an explicit-height example. Its wallet group styles the parent member while leaving a child unstyled to expose leakage. -Variant and rendered acceptance coverage is still pending (§9). The catalog +Migration/runtime acceptance is recorded in DESIGN.md; the checklist in §9 still +applies to future changes. The catalog illustrations are structural diagrams, not screenshots or proof of rendering parity. ## 3. T1 — Design tokens @@ -637,8 +638,8 @@ that cites this table and carries device verification. ### 6.1 Row heights Row height is computed from data by three independent implementations — -`RNCNativeListView.rowHeight()`, `NativeListRowView.applySize()`, and -`estimateWebRowHeight()`. They disagree: +the iOS registered renderer measurement, Android registered row measurement, and +Web registered estimate/DOM measurement. Preserved defaults still differ: | Template | iOS | Android | Web | | --- | --- | --- | --- | @@ -683,7 +684,7 @@ exposed — a hairline is correct on iOS and a whole pixel is correct elsewhere. ### 6.3 Behavioral - **Android pinned headers reuse normal row rendering.** The old Canvas-only - `StickySectionHeaderDecoration` is replaced by an overlay `NativeListRowView`. + `StickySectionHeaderDecoration` is replaced by an overlay registered `NativeListSectionHeaderRowView`. Typography, local metrics, values, checkbox/action callbacks and theme now follow the same binding path. The overlay pushes away for the next sticky header and forwards drag gestures to the RecyclerView. Summary and `sticky: false` headers @@ -817,35 +818,34 @@ verify the result. Do not describe numeric bounds as a clipping-prevention featu The style pass belongs after template and presentation defaults. All 12 row types retain their own binders; adding a local style must not replace their structure. -All three are implemented as `applyRowStyle`. - -| Platform | Anchor | Note | +| Platform | Owner | Order and reset | | --- | --- | --- | -| iOS | `NativeListCell.bind()`, after the `switch item.type` | The style pass updates specified attributed-string properties while preserving omitted template attributes | -| Android | `NativeListRowView.bind()`, **after** `applySize(item)` | `applySize` re-dispatches font size and typeface by row type and would otherwise overwrite the style | -| Web | `renderElement()`, after template and presentation defaults | Wallet-group members apply their own local pass before the group pass | - -Both native legacy hosts use `resetRowStyle` before the binder, per §7 rule 2. -They capture the bound text view's defaults before modifying it and restore that -state before the normal template reset. This includes text metrics, color and -alignment; iOS also restores attributed text. This avoids guessing one baseline -for views shared by different templates. Device reuse checks remain required. - -Message completes the first renderer pilot. Its native registry creates a -lightweight host, and its renderer owns the title/body/time column and optional -image slots. Text, box and image styles resolve from current defaults/data/theme; -clearing a style rebinds those defaults without using the legacy restoration map. -Native measurement and binding share resolved inputs; Web keeps a persistent -body and corrects intrinsic estimates from DOM measurements. Unchanged effective -image requests survive text-only binding. All three platforms separate Message -from legacy reuse; cross-family changes replace the host. See -[DESIGN.md](DESIGN.md#incremental-renderer-migration) for source boundaries and -runtime acceptance. Stages 3–6 remain pending. - -The existing market helpers generalize rather than being rewritten: -`applyMarketTextStyle` / `applyMarketButtonStyle` / `marketAttributedText` (iOS), -`applyMarketTextStyle` / `applyMarketTextMetrics` (Android), -`applyMarketTextStyle` / `resolveWebMarketLayoutStyle` (Web). +| iOS | Registered template renderer and `NativeListRendererCell` | Renderer restores/resolves current template defaults, then applies semantic text/image/spacing styles; host owns common container appearance and action epochs | +| Android | Registered template renderer and `NativeListRendererRowView` | Renderer applies current defaults and local styles; shared host owns allocation, container appearance and separators | +| Web | Registered template `bind()` plus `RowContainerStyle` | Template resets its own nodes and applies local styles; engine applies common row chrome. WalletGroup delegates each member to Identity before applying its group container | + +All twelve templates have dedicated renderer families. Bind must restore omitted +style fields even without a prior recycle call; same-key template changes replace +an incompatible host/body. No global restoration/slot map remains. A renderer +may retain a bounded, template-local typography baseline where its platform +needs it; it must never obtain defaults from another template's previous binding. + +Measurement uses the current data/theme/style inputs. Web renderers report +rendered intrinsic heights through the registry; the container has no separate +warning-view measurement fallback. Image primitives retain unchanged effective +requests and invalidate callbacks on source/slot replacement or recycle. + +Selector tabular typography belongs to Identity, SectionHeader and Action. +WalletGroup supplies its own compact preview through the internal renderer +contract; the container does not search for a parent view inside its DOM. +Native containers likewise call the row-host lifecycle instead of casting to +concrete template views. Shared action hit-testing, checkbox/selection routing +and image leases remain common list capabilities. + +The six-stage migration and dated runtime results are recorded in +[DESIGN.md](DESIGN.md#incremental-renderer-migration) and SPEC.md. New templates +must implement the same binding/reset/measurement lifecycle and register a +family on all three platforms; there is no legacy fallback or public plugin API. ## 9. Review checklist From 8370509d243bf6e8e4aa5ec2c8f812dc7ae99bf2 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 07:44:21 +0800 Subject: [PATCH 61/71] style(native-list): remove extra blank lines at EOF --- .../src/main/java/com/onekey/nativelist/NativeListTextView.kt | 1 - .../react-native-native-list/ios/NativeListTextLabel.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListTextView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListTextView.kt index e6f82dcc6..667018e17 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListTextView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListTextView.kt @@ -23,4 +23,3 @@ internal open class NativeListTextView(context: android.content.Context) : TextV canvas.restoreToCount(checkpoint) } } - diff --git a/native-views/react-native-native-list/ios/NativeListTextLabel.swift b/native-views/react-native-native-list/ios/NativeListTextLabel.swift index 7b4162b75..ee982e3de 100644 --- a/native-views/react-native-native-list/ios/NativeListTextLabel.swift +++ b/native-views/react-native-native-list/ios/NativeListTextLabel.swift @@ -40,4 +40,3 @@ final class NativeListInsetLabel: NativeListTextLabel { )) } } - From 221730bc43f035de8e2f9a654888a2db655d20cf Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 10:46:22 +0800 Subject: [PATCH 62/71] fix(native-list/android): restore legacy row rendering in renderer registry Keep the renderer architecture but restore the unstyled rendering and behavior of the deleted NativeListRowView. - Fix IllegalStateException when a leading visual reuses an image slot after its overlay frame was dropped. - Register the sticky-header pre-draw listener only while attached, on the captured observer, so disposed lists no longer leak through the window. - Use each template's default separator inset (Identity 60). - Include resolved vertical padding in WalletGroup height; restore legacy compact state, reorder finish, cancelled expansion and draggable-only drag gating. - Restore legacy defaults across templates: 12dp pressed corners, #000000DF primary fallback, Activity footer/amount/badge, Rail and Identity empty leading slots, MediaTile badge and placeholder, DataRow linear single label and fixed table columns, MetricCard/Identity ellipsis, Action and System typography, retry label and skeleton radius. - Restore legacy disabled-row action gating, summary visibility on selection echo, pressed state on move-out and retry source scaling. Co-Authored-By: Claude Opus 5.5 --- .../nativelist/NativeListAccessoryStack.kt | 23 ++- .../nativelist/NativeListActionRenderer.kt | 55 ++++++- .../nativelist/NativeListActivityRenderer.kt | 20 ++- .../nativelist/NativeListDataRowRenderer.kt | 124 ++++++++++++-- .../nativelist/NativeListIdentityRenderer.kt | 35 +++- .../nativelist/NativeListLeadingVisual.kt | 57 +++++-- .../nativelist/NativeListMarketRenderer.kt | 1 + .../nativelist/NativeListMediaTileRenderer.kt | 20 ++- .../NativeListMetricCardRenderer.kt | 6 +- .../nativelist/NativeListRailRenderer.kt | 18 +-- .../nativelist/NativeListRendererRowView.kt | 31 +++- .../NativeListSectionHeaderRenderer.kt | 21 ++- .../nativelist/NativeListSystemRenderer.kt | 31 +++- .../com/onekey/nativelist/NativeListView.kt | 40 ++++- .../NativeListWalletGroupRenderer.kt | 152 +++++++++++++----- 15 files changed, 510 insertions(+), 124 deletions(-) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt index 376ae057d..b4e2426e9 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt @@ -51,6 +51,10 @@ internal class NativeListAccessoryStack(context: android.content.Context) : Line get() = trailingViews.filter { it.visibility == VISIBLE } var endInset: Int? = null + + /** Legacy: overhanging selector controls require the host row not to clip its padding. */ + var requestsUnclippedHost = false + private set var onAction: ((String, View, Int?, NativeSelectionTarget?) -> Unit)? = null private val selectorAccessibilityDelegate = object : View.AccessibilityDelegate() { @@ -84,7 +88,8 @@ internal class NativeListAccessoryStack(context: android.content.Context) : Line selectorUsesSourceScale = sourceScale reset() tag = item - checkboxCheckedColor = color(theme, "primaryText", "#1D1D1D") + val primaryText = color(theme, "primaryText", "#000000DF") + checkboxCheckedColor = primaryText checkboxUncheckedColor = color(theme, "inverseText", "#FCFCFC") checkboxIconColor = checkboxUncheckedColor checkboxBorderColor = Color.argb(0x31, 0, 0, 0) @@ -100,7 +105,8 @@ internal class NativeListAccessoryStack(context: android.content.Context) : Line (0 until descriptors.length()).map { descriptors.getJSONObject(it).optString("kind") } orientation = if (horizontal) HORIZONTAL else VERTICAL gravity = Gravity.END or Gravity.CENTER_VERTICAL - trailingViews.forEach { it.setTextColor(checkboxCheckedColor) } + // Legacy: trailing text uses primaryText, never the selector checkbox fill. + trailingViews.forEach { it.setTextColor(primaryText) } bindAccessories(item, descriptors, theme, checkboxState) if (horizontal && "checkbox" in kinds) (checkbox.layoutParams as LayoutParams).marginStart = dp(12) @@ -153,6 +159,9 @@ internal class NativeListAccessoryStack(context: android.content.Context) : Line fun reset() { tag = null endInset = null + requestsUnclippedHost = false + clipChildren = true + clipToPadding = true semanticValueViews.clear() boundCheckboxData = null trailingViews.forEach { v -> @@ -162,11 +171,12 @@ internal class NativeListAccessoryStack(context: android.content.Context) : Line v.setOnClickListener(null) v.background = null v.setPadding(0, 0, 0, 0) - v.gravity = Gravity.END or Gravity.CENTER_VERTICAL + // Legacy trailing text: END gravity, one line, no ellipsis. + v.gravity = Gravity.END v.opticalOffsetY = 0f v.maxLines = 1 v.setHorizontallyScrolling(false) - v.ellipsize = TextUtils.TruncateAt.END + v.ellipsize = null v.setLineSpacing(0f, 1f) v.letterSpacing = 0f } @@ -195,7 +205,8 @@ internal class NativeListAccessoryStack(context: android.content.Context) : Line source: String, slot: Int? = null, ) { - if (!item.json.optBoolean("disabled")) onAction?.invoke(key, view, slot, target) + // Row-level disabled gating is per control (see showTrailing/bindCheckbox), as in legacy. + onAction?.invoke(key, view, slot, target) } private fun dp(value: Int) = @@ -311,6 +322,7 @@ internal class NativeListAccessoryStack(context: android.content.Context) : Line if (usesSourceCheckboxGeometry) { // OneKey patch: source Yoga children may round into padding; retain their complete border. clipToPadding = false + requestsUnclippedHost = true // OneKey patch: even a transparent source border changes RN's background clipping path. checkbox.background = null BackgroundStyleApplicator.setBackgroundColor( @@ -496,6 +508,7 @@ internal class NativeListAccessoryStack(context: android.content.Context) : Line // the pressed circle down to a 24dp-wide pill. this@NativeListAccessoryStack.clipChildren = false clipToPadding = false + requestsUnclippedHost = true if (isSourceMenu && data.optString("actionKey").isNotEmpty()) { icon.background = StateListDrawable().apply { diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActionRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActionRenderer.kt index 29cecc56e..08ef43962 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActionRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActionRenderer.kt @@ -25,6 +25,41 @@ internal class NativeListActionRowView(context: ThemedReactContext) : } } + private val titlePaintFlags = title.paintFlags + + // Legacy selector typography: whole-pixel source font size, unhinted + // fractional advances and the original line box. + private fun applySourceTypography(view: NativeListTextView) { + val line = view.lineHeight + view.paintFlags = + view.paintFlags or android.graphics.Paint.SUBPIXEL_TEXT_FLAG or + android.graphics.Paint.LINEAR_TEXT_FLAG + view.setTextSize( + android.util.TypedValue.COMPLEX_UNIT_PX, + kotlin.math + .ceil( + (view.textSize * resources.displayMetrics.density / resources.displayMetrics.scaledDensity) + .toDouble() + ) + .toFloat(), + ) + view.letterSpacing = 0f + if (view.text.isNotEmpty()) { + val text = android.text.SpannableStringBuilder(view.text) + text + .getSpans(0, text.length, NativeListLineHeightSpan::class.java) + .forEach(text::removeSpan) + text.setSpan( + NativeListLineHeightSpan(line), + 0, + text.length, + android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + view.setLineSpacing(0f, 1f) + view.text = text + } + } + override fun defaultHeight(item: NativeListItem, layout: String) = when { item.json.optString("presentation") == "accountSelector" -> 48 @@ -54,6 +89,8 @@ internal class NativeListActionRowView(context: ThemedReactContext) : ?: item.json.optJSONArray("trailing") ?: JSONArray() accessories.bind(item, descriptors, theme, style, sourceScale, checkboxState) + // Legacy: overhanging selector icons/checkboxes must not be clipped by row padding. + clipToPadding = !accessories.requestsUnclippedHost val hp = if (style.has("horizontalPadding")) stylePx(style.optDouble("horizontalPadding")) else dp(if (layout == "table") 16 else 12) @@ -61,7 +98,8 @@ internal class NativeListActionRowView(context: ThemedReactContext) : if (style.has("verticalPadding")) stylePx(style.optDouble("verticalPadding")) else dp(8) setPadding(hp, vp, if (style.has("horizontalPadding")) hp else accessories.endInset ?: hp, vp) val titleParams = title.layoutParams as LayoutParams - titleParams.marginEnd = if (accessories.visibility == GONE) 0 else dp(12) + // Legacy: the weighted title abuts the trailing accessories without a gap. + titleParams.marginEnd = 0 title.layoutParams = titleParams val tone = item.json.optString("tone") val token = @@ -69,7 +107,7 @@ internal class NativeListActionRowView(context: ThemedReactContext) : else if (!selector && tone == "danger") "negative" else "primaryText" val fallback = if (token == "negative") "#C40006D3" - else if (token == "secondaryText") "#0000009B" else "#1D1D1D" + else if (selector || token == "secondaryText") "#0000009B" else "#000000DF" NativeListResolvedText.resolve( context, item.json.optString("title"), @@ -82,8 +120,12 @@ internal class NativeListActionRowView(context: ThemedReactContext) : sourceScale, ) .bind(title) - title.ellipsize = android.text.TextUtils.TruncateAt.END + title.paintFlags = titlePaintFlags + if (selector && sourceScale) applySourceTypography(title) visual.visibility = if (icon == null) GONE else VISIBLE + // Legacy: the 1px icon outline is kept only for a non-selector icon with an + // explicit background; selector icons use a plain 8dp rounded fill. + visual.iconBorder = !selector && icon?.has("backgroundColor") == true if (icon != null) { val source = JSONObject(icon.toString()) if (!source.has("backgroundColor")) @@ -94,7 +136,7 @@ internal class NativeListActionRowView(context: ThemedReactContext) : ) val image = JSONObject(style.optJSONObject("image")?.toString() ?: "{}") if (selector && !image.has("cornerRadius") && !image.has("shape")) - image.put("cornerRadius", 8) + image.put("cornerRadius", scaledDp(8f) / resources.displayMetrics.density) visual.layoutParams = LayoutParams( if (image.has("width")) stylePx(image.optDouble("width")) @@ -104,7 +146,10 @@ internal class NativeListActionRowView(context: ThemedReactContext) : ) .apply { marginEnd = - if (style.has("leadingGap")) stylePx(style.optDouble("leadingGap")) else dp(12) + if (style.has("leadingGap")) stylePx(style.optDouble("leadingGap")) + // Legacy: Yoga rounds the cumulative selector edges, not each gap. + else if (selector && item.json.has("height")) dp(56) - dp(12) - dp(32) + else dp(12) } visual.bind(source, image, item.key, theme, false, sourceScale) } else visual.recycle() diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt index 45afce88c..45d97f0d7 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt @@ -100,11 +100,14 @@ internal class NativeListActivityRowView(context: ThemedReactContext) : if (textStyle.optString("truncate") == "clip") null else TextUtils.TruncateAt.END } orientation = VERTICAL + val hasFooterActions = (data.optJSONArray("footerActions")?.length() ?: 0) > 0 gravity = when (style.optJSONObject("container")?.optString("contentVerticalAlignment")) { "bottom" -> Gravity.BOTTOM "top" -> Gravity.TOP - else -> Gravity.CENTER_VERTICAL + "center" -> Gravity.CENTER_VERTICAL + // Legacy: the footer-action column is top/start aligned. + else -> if (hasFooterActions) Gravity.START else Gravity.CENTER_VERTICAL } content.gravity = when (style.optJSONObject("container")?.optString("contentVerticalAlignment")) { @@ -123,7 +126,8 @@ internal class NativeListActivityRowView(context: ThemedReactContext) : val gap = if (style.has("leadingGap")) stylePx(style.optDouble("leadingGap")) else dp(12) visual.layoutParams = LayoutParams(iw, ih).apply { marginEnd = gap } visual.bind( - data.optJSONObject("leading") ?: JSONObject(), + // Legacy: a missing visual still reserves an empty, transparent slot. + data.optJSONObject("leading") ?: JSONObject().put("backgroundColor", "#00000000"), image, item.key, theme, @@ -155,7 +159,7 @@ internal class NativeListActivityRowView(context: ThemedReactContext) : failure.setPadding(dp(8), dp(2), dp(8), dp(2)) (failure.layoutParams as LayoutParams).apply { marginStart = - if (style.has("titleBadgeGap")) stylePx(style.optDouble("titleBadgeGap")) else dp(8) + if (style.has("titleBadgeGap")) stylePx(style.optDouble("titleBadgeGap")) else 0 } for (view in listOf(description, status)) (view.layoutParams as LayoutParams).apply { topMargin = if (style.has("lineGap")) stylePx(style.optDouble("lineGap")) else 0 @@ -173,7 +177,8 @@ internal class NativeListActivityRowView(context: ThemedReactContext) : if (value.startsWith("+")) "positive" else "primaryText", if (value.startsWith("+")) "#00713FDE" else "#000000DF", ) - else color("secondaryText", "#0000009B"), + // Legacy: both amounts default to primaryText; only "+" amounts are positive. + else color("primaryText", "#000000DF"), line = if (index == 0) 24 else 20, align = "end", ) @@ -201,13 +206,14 @@ internal class NativeListActivityRowView(context: ThemedReactContext) : context, action.optString("label"), null, - 12f, - "medium", + // Legacy footer action: 14sp semibold on a 20dp line. + 14f, + "semibold", color( if (action.optString("tone") == "danger") "negative" else "primaryText", if (action.optString("tone") == "danger") "#C40006D3" else "#000000DF", ), - 0, + 20, 1, sourceScale, ) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt index cfb84d844..8f33f477c 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt @@ -134,29 +134,46 @@ internal class NativeListDataRowView(context: ThemedReactContext) : cell.visibility = if (index < values.length()) VISIBLE else GONE if (index < values.length()) { val value = values.getJSONObject(index) - cell.bind( - value, - if (index == 0) data.optJSONArray("badges") else null, - dataColor(value.optString("tone"), theme), - dataColor(value.optString("secondaryTone", "secondary"), theme), - parseNativeListColor(theme?.optString("info", "#006DCBF2") ?: "#006DCBF2"), - if (layout == "table") 14f else 16f, - style, - ) + val unstyled = style.length() == 0 + val infoColor = parseNativeListColor(theme?.optString("info", "#006DCBF2") ?: "#006DCBF2") + if (unstyled && layout != "table") + // Legacy linear default: one label per column with appended spans. + cell.bindLegacyLinear( + value, + if (index == 0) data.optJSONArray("badges") else null, + dataColor(value.optString("tone"), theme), + dataColor(value.optString("secondaryTone", "secondary"), theme), + infoColor, + ) + else + cell.bind( + value, + if (index == 0) data.optJSONArray("badges") else null, + dataColor(value.optString("tone"), theme), + dataColor(value.optString("secondaryTone", "secondary"), theme), + infoColor, + if (layout == "table") 14f else 16f, + style, + fixedTableGeometry = unstyled, + ) + // Legacy table default: every column is a fixed 40dp, top-aligned block. cell.layoutParams = - LayoutParams(0, LayoutParams.WRAP_CONTENT, value.optInt("weight", 1).toFloat()) + LayoutParams( + 0, + if (unstyled && layout == "table") dp(40) else LayoutParams.WRAP_CONTENT, + value.optInt("weight", 1).toFloat(), + ) } else cell.reset() } } private fun dataColor(tone: String, theme: JSONObject?): Int { + // Legacy tone mapping: any other tone renders as primaryText. val token = when (tone) { "positive" -> "positive" "negative" -> "negative" "secondary" -> "secondaryText" - "disabled" -> "disabledText" - "caution" -> "caution" else -> "primaryText" } val fallback = @@ -164,8 +181,6 @@ internal class NativeListDataRowView(context: ThemedReactContext) : "positive" -> "#00713FDE" "negative" -> "#C40006D3" "secondaryText" -> "#0000009B" - "disabledText" -> "#00000072" - "caution" -> "#AB6400" else -> "#000000DF" } return parseNativeListColor(theme?.optString(token, fallback) ?: fallback) @@ -249,6 +264,10 @@ private class NativeListTableColumnView(context: android.content.Context) : Line fun reset() { primaryLine.layoutParams.width = LayoutParams.WRAP_CONTENT + primaryLine.layoutParams.height = LayoutParams.WRAP_CONTENT + secondaryLine.layoutParams.height = LayoutParams.WRAP_CONTENT + badges.layoutParams.height = LayoutParams.WRAP_CONTENT + secondaryLeading.layoutParams.height = LayoutParams.WRAP_CONTENT primary.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) secondary.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { @@ -274,6 +293,7 @@ private class NativeListTableColumnView(context: android.content.Context) : Line infoColor: Int, primarySize: Float, style: JSONObject, + fixedTableGeometry: Boolean = false, ) { reset() gravity = @@ -356,6 +376,82 @@ private class NativeListTableColumnView(context: android.content.Context) : Line secondaryLine.visibility = VISIBLE } if (style.length() > 0) applyStyle(style) + if (fixedTableGeometry) { + // Legacy table lines: fixed 20dp primary and 16dp secondary/badge boxes. + primaryLine.layoutParams.height = dp(20) + primary.layoutParams.height = dp(20) + badges.layoutParams.height = dp(16) + for (index in 0 until badges.childCount) badges.getChildAt(index).layoutParams.height = dp(16) + secondaryLine.layoutParams.height = dp(16) + secondaryLeading.layoutParams.height = dp(16) + secondary.layoutParams.height = dp(16) + } + } + + fun bindLegacyLinear( + column: JSONObject, + rowBadges: JSONArray?, + primaryColor: Int, + secondaryColor: Int, + infoColor: Int, + ) { + reset() + gravity = Gravity.START + val value = android.text.SpannableStringBuilder(column.optString("text")) + value.setSpan( + android.text.style.ForegroundColorSpan(primaryColor), + 0, + value.length, + android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + if (rowBadges != null) + for (index in 0 until minOf(2, rowBadges.length())) { + val start = value.length + value.append(" ${rowBadges.getJSONObject(index).optString("text")} ") + value.setSpan( + android.text.style.ForegroundColorSpan(infoColor), + start, + value.length, + android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + value.setSpan( + android.text.style.BackgroundColorSpan(parseNativeListColor("#008FF519")), + start, + value.length, + android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + val secondaryText = column.optString("secondaryText") + if (secondaryText.isNotEmpty()) { + val start = value.length + value.append("\n$secondaryText") + value.setSpan( + android.text.style.ForegroundColorSpan(secondaryColor), + start, + value.length, + android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + primaryLine.layoutParams.width = LayoutParams.MATCH_PARENT + primary.layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) + primary.includeFontPadding = true + primary.fontFeatureSettings = "tnum" + primary.textSize = sp(16f) + primary.typeface = NativeListFonts.medium(context) + primary.setLineSpacing(0f, 1f) + primary.letterSpacing = 0f + primary.opticalOffsetY = 0f + primary.setHorizontallyScrolling(false) + primary.ellipsize = null + primary.maxLines = if (secondaryText.isEmpty()) 1 else 2 + primary.gravity = + when (column.optString("alignment", "start")) { + "center" -> Gravity.CENTER + "end" -> Gravity.END + else -> Gravity.START + } + primary.text = value + primary.visibility = VISIBLE } private fun applyStyle(style: JSONObject) { diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt index 853d46a07..59e6b1d49 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt @@ -190,6 +190,8 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : tertiary.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) badgeLine.typeface = NativeListFonts.medium(context) badgeLine.textSize = sp(12f) + // Legacy: tertiary always ellipsizes at the end. + tertiary.ellipsize = TextUtils.TruncateAt.END mainColumn.addView(titleLine) mainColumn.addView(subtitle) mainColumn.addView(tertiary) @@ -245,6 +247,8 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : else -> Gravity.CENTER_VERTICAL } applySelectorTypography(item) + // Legacy: overhanging selector icons/checkboxes must not be clipped by row padding. + if (trailingColumn.parent === this && trailingColumn.requestsUnclippedHost) clipToPadding = false } private fun styleGap(column: LinearLayout, gap: Int) { @@ -302,17 +306,25 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : visit(this) } - private fun addLeading(descriptor: JSONObject?, sizeDp: Int, spacingDp: Int = 12) { - if (descriptor == null) { - leadingFrame.recycle() - return - } + private fun addLeading(visual: JSONObject?, sizeDp: Int, spacingDp: Int = 12) { + // Legacy: a missing visual still reserves an empty, transparent leading slot. + val descriptor = visual ?: JSONObject().put("backgroundColor", "#00000000") + if (visual == null) leadingFrame.recycle() val item = currentItem!! val style = item.json.optJSONObject("style")?.optJSONObject("image") ?: JSONObject() val wallet = item.json.optString("presentation") == "walletSidebar" && item.json.has("height") + // Legacy wallet-sidebar glyph/overlay sizing applies with source scaling. + val walletSource = item.json.optString("presentation") == "walletSidebar" && sourceScale + val fallbackIconName = + descriptor.optJSONObject("fallbackIcon")?.takeIf { descriptor.optJSONObject("image") == null }?.optString("name") leadingFrame.glyphSize = - if (wallet && descriptor.optJSONObject("fallbackIcon")?.optString("name") == "LockSolid") 40 + if (walletSource && fallbackIconName == "LockSolid") 40 + else if (walletSource && fallbackIconName == "PlusSmallOutline") 24 else 18 + leadingFrame.walletTextOverlays = walletSource + leadingFrame.roundedImageRadius = + if (item.json.optString("presentation") == "accountSelector" && item.json.has("height")) 8 + else 10 leadingFrame.dashedBorderWidth = if (wallet) 1 else 2 leadingFrame.overlayTextFontSize = if (wallet) 12f else 10f leadingFrame.overlayTextLineHeight = if (wallet) 16 else null @@ -326,7 +338,16 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : if (style.has("width")) stylePx(style.optDouble("width")) else dp(sizeDp), if (style.has("height")) stylePx(style.optDouble("height")) else dp(sizeDp), ) - .apply { marginEnd = dp(spacingDp) }, + .apply { + // Legacy: Yoga rounds cumulative selector edges, not each gap separately. + marginEnd = + if ( + item.json.has("height") && + item.json.optString("presentation") in setOf("accountSelector", "networkSelector") + ) + dp(12 + sizeDp + spacingDp) - dp(12) - dp(sizeDp) + else dp(spacingDp) + }, ) } diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt index 3e7f0799c..47b53433a 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt @@ -39,10 +39,28 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont var overlayTextLineHeight: Int? = null var bitmapBorderWidth = 0 + /** Legacy wallet-sidebar text overlays: 16dp tall pills that wrap their text. */ + var walletTextOverlays = false + + private fun isWalletText(data: JSONObject) = + walletTextOverlays && + data.optString("text").isNotEmpty() && + data.optJSONObject("image") == null && + data.optString("name").isEmpty() + + private fun overlayHeight(data: JSONObject) = + data.optInt("height", if (isWalletText(data)) 16 else data.optInt("size", 20)) + fun layoutNetworkBackdrop(top: Int, height: Int) { networkBackdrop.layout(networkBackdrop.left, top, networkBackdrop.right, top + height) } + // Legacy: loaded images in a "rounded" slot clip at a fixed radius (10dp; + // 8dp for explicit-height account selectors), independent of slot size. + var roundedImageRadius = 10 + + /** Market resolves its own radius; legacy still decoded circular bitmaps by shape. */ + var roundFollowsVisualShape = false var glyphSize = 18 var roundedGlyphOrigin = false var iconBorder = true @@ -191,8 +209,7 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont placeholder, round = visual.optString("shape", if (kind == "image") "rounded" else "circle") == "circle" && - !style.has("shape") && - !style.has("cornerRadius"), + (roundFollowsVisualShape || !style.has("shape") && !style.has("cornerRadius")), ) { loaded -> slot.view.visibility = if (!loaded && (index > 0 || sourceFallback)) INVISIBLE else VISIBLE @@ -245,7 +262,7 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont color(data.optString("backgroundColor", "#00000000")), minOf( dp(data.optInt("width", data.optInt("size", 20))), - dp(data.optInt("height", data.optInt("size", 20))), + dp(overlayHeight(data)), ) / 2f, ) frame.outlineProvider = ViewOutlineProvider.BACKGROUND @@ -271,7 +288,9 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont overlayTextLineHeight?.let { androidx.core.widget.TextViewCompat.setLineHeight(this, dp(it)) } - typeface = NativeListFonts.medium(context) + typeface = + if (isWalletText(data)) NativeListFonts.regular(context) + else NativeListFonts.medium(context) setTextColor(color(data.optString("tintColor", "#0000009B"))) } } else { @@ -283,15 +302,22 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont } } val inset = data.optInt("padding", 0) - frame.setPadding(dp(inset), dp(inset), dp(inset), dp(inset)) + if (isWalletText(data)) frame.setPadding(dp(2), 0, dp(2), 0) + else frame.setPadding(dp(inset), dp(inset), dp(inset), dp(inset)) if (child.parent !== frame) { frame.removeAllViews() + // A reused slot view may still belong to a frame dropped by an earlier bind. + (child.parent as? android.view.ViewGroup)?.removeView(child) frame.addView(child, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) } if (frame.parent == null) addView(frame) overlays.add(frame to data) } - previousOverlays.drop(descriptors.size).forEach { removeView(it.first) } + previousOverlays.drop(descriptors.size).forEach { (frame, _) -> + // Release the overlay child so its image slot can join a later frame. + frame.removeAllViews() + removeView(frame) + } overlaySlots.drop(descriptors.size).forEach { it.recycle() } unread.bringToFront() requestLayout() @@ -371,7 +397,11 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont ) val inset = if (index == 0) bitmapBorderWidth else 0 place(slot.view, x + inset, y + inset, imageWidth - 2 * inset, size - 2 * inset) - val r = if (index == 0 && (sources.size == 1 || tokenPair)) radius else size / 2f + val imageRadius = + if (shape == "rounded" && !style.has("shape") && !style.has("cornerRadius")) + dp(roundedImageRadius).toFloat() + else radius + val r = if (index == 0 && (sources.size == 1 || tokenPair)) imageRadius else size / 2f slot.view.outlineProvider = object : ViewOutlineProvider() { override fun getOutline(view: View, outline: Outline) { @@ -388,8 +418,17 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont place(unread, w - dp(8), 0, dp(8), dp(8)) overlays.forEach { (frame, data) -> val size = data.optInt("size", 20) - val ow = dp(data.optInt("width", size)) - val oh = dp(data.optInt("height", size)) + val oh = dp(overlayHeight(data)) + val ow = + if (isWalletText(data) && !data.has("width")) { + // Legacy: wallet text pills wrap their label plus horizontal padding. + val child = frame.getChildAt(0) + child?.measure( + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), + MeasureSpec.makeMeasureSpec(oh, MeasureSpec.EXACTLY), + ) + (child?.measuredWidth ?: 0) + frame.paddingLeft + frame.paddingRight + } else dp(data.optInt("width", size)) val x = dp(data.optInt("offsetX", data.optInt("offset", 2))) val y = dp(data.optInt("offsetY", data.optInt("offset", 2))) val ox = if (data.optString("position") == "topLeft") -x else w - ow + x diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMarketRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMarketRenderer.kt index 4bfd039b7..7d32d687f 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMarketRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMarketRenderer.kt @@ -544,6 +544,7 @@ internal class NativeListMarketRowView(context: ThemedReactContext) : val resolved = JSONObject(imageStyle?.toString() ?: "{}") resolved.put("cornerRadius", cornerRadius.toDouble()) leadingFrame.bitmapBorderWidth = if (leading.optString("borderColor").isEmpty()) 0 else dp(1) + leadingFrame.roundFollowsVisualShape = true leadingFrame.bind(leading, resolved, item.key, theme, false, true) addView( leadingFrame, diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt index 58c7b9456..03704432c 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt @@ -117,6 +117,11 @@ internal class NativeListMediaTileRowView(context: ThemedReactContext) : sourceScale, ) .bind(badge) + if (style.optJSONObject("badge") == null) { + // Legacy badge text keeps the platform font padding and proportional digits. + badge.includeFontPadding = true + badge.fontFeatureSettings = null + } if (!style.optJSONObject("badge").let { it?.has("alignment") == true }) badge.gravity = Gravity.CENTER badge.setPadding(dp(8), 0, dp(8), 0) @@ -124,7 +129,7 @@ internal class NativeListMediaTileRowView(context: ThemedReactContext) : GradientDrawable().apply { setColor(parseNativeListColor("#000000DF")) cornerRadius = dp(10).toFloat() - setStroke(dp(1), Color.WHITE) + setStroke(dp(2), Color.WHITE) } badge.layoutParams = FrameLayout.LayoutParams(-2, dp(24), Gravity.END or Gravity.BOTTOM) title.layoutParams = LayoutParams(-1, -2).apply { topMargin = size("lineGap", 0) } @@ -141,13 +146,18 @@ internal class NativeListMediaTileRowView(context: ThemedReactContext) : item.json.getJSONObject("image"), "${item.key}:media", fit = imageStyle.optString("contentFit").takeIf { it.isNotEmpty() }, + placeholder = + theme?.optString("strongBackground", "#0000000F")?.takeIf(String::isNotEmpty) + ?: "#0000000F", ) else image.recycle() + // Legacy: only the empty/error states paint a slot fill; loaded images show + // their own placeholder without a white backing. image.view.background = - GradientDrawable().apply { - setColor( - if (state == "error") tint("strongBackground", "#0000000F") else Color.WHITE - ) + when (state) { + "error" -> GradientDrawable().apply { setColor(tint("strongBackground", "#0000000F")) } + "empty" -> GradientDrawable().apply { setColor(Color.WHITE) } + else -> null } image.view.outlineProvider = object : ViewOutlineProvider() { diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMetricCardRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMetricCardRenderer.kt index e9f5b2be4..a1a560aff 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMetricCardRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMetricCardRenderer.kt @@ -169,6 +169,9 @@ internal class NativeListMetricCardRowView(context: ThemedReactContext) : ) .bind(heading) heading.letterSpacing = spacing + // Legacy metricText headings always ellipsize unless a style clips them. + if (style.optJSONObject("title")?.optString("truncate") != "clip") + heading.ellipsize = TextUtils.TruncateAt.END } if (style.has("lineGap")) { val gap = stylePx(style.optDouble("lineGap")) @@ -237,7 +240,8 @@ internal class NativeListMetricCardRowView(context: ThemedReactContext) : sourceScale, ) .bind(badge) - (badge.layoutParams as LayoutParams).marginStart = dp(8) + // Legacy: the badge follows the weighted title without an extra margin. + (badge.layoutParams as LayoutParams).marginStart = 0 for (view in listOf(label, trend, detail)) (view.layoutParams as LayoutParams).topMargin = if (style.has("lineGap")) stylePx(style.optDouble("lineGap")) else 0 (column.parent as? android.view.ViewGroup)?.removeView(column) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRailRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRailRenderer.kt index 159d60076..358bfd49b 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRailRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRailRenderer.kt @@ -110,17 +110,13 @@ internal class NativeListRailRowView(context: ThemedReactContext) : val image = style.optJSONObject("image") ?: JSONObject() leadingWidth = if (image.has("width")) stylePx(image.optDouble("width")) else dp(20) val imageHeight = if (image.has("height")) stylePx(image.optDouble("height")) else dp(20) - val source = item.json.optJSONObject("visual") - if (source != null) { - val view = visual ?: NativeListLeadingVisual(reactContext).also { visual = it } - view.bind(source, image, item.key, theme, false, sourceScale) - val params = LayoutParams(leadingWidth, imageHeight).apply { marginEnd = leadingGap } - if (view.parent == null) addView(view, 0, params) else view.layoutParams = params - } else - visual?.let { - it.recycle() - removeView(it) - } + // Legacy: a rail without a visual still reserves its empty 20dp slot and gap. + val source = + item.json.optJSONObject("visual") ?: JSONObject().put("backgroundColor", "#00000000") + val view = visual ?: NativeListLeadingVisual(reactContext).also { visual = it } + view.bind(source, image, item.key, theme, false, sourceScale) + val params = LayoutParams(leadingWidth, imageHeight).apply { marginEnd = leadingGap } + if (view.parent == null) addView(view, 0, params) else view.layoutParams = params } // Retain the legacy 6-unit trailing allowance when a status is present. diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt index 8032e3d25..514bd4162 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt @@ -37,7 +37,9 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th protected abstract fun disposeContent() - protected open fun usesSourceScale(item: NativeListItem, provided: Boolean) = provided + // Evaluated on every bind (including unchanged rebinds), before bindContent. + protected open fun usesSourceScale(item: NativeListItem, provided: Boolean) = + item.usesSelectorSourceScale || provided protected open fun onRowTouch(event: MotionEvent) {} @@ -137,6 +139,10 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th if (sourceScale) (value * resources.displayMetrics.density).roundToInt() else NativeListScale.dp(resources, value) + protected fun scaledDp(value: Float) = + if (sourceScale) value * resources.displayMetrics.density + else NativeListScale.dp(resources, value) + protected fun stylePx(value: Double) = (value * resources.displayMetrics.density).roundToInt() private fun color(value: String, fallback: Int = Color.TRANSPARENT) = @@ -151,13 +157,19 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th ?.let { onRowPress?.invoke(it, NativeListActionOrigin(this, this, bindingEpoch, "row")) } } setOnTouchListener { _, event -> + val wasPressed = touchPressed when (event.actionMasked) { MotionEvent.ACTION_DOWN -> touchPressed = current?.isRowPressEnabled == true + // Legacy: leaving the row clears the pressed highlight before release. + MotionEvent.ACTION_MOVE -> + if (event.x < 0 || event.y < 0 || event.x >= width || event.y >= height) + touchPressed = false MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> touchPressed = false } onRowTouch(event) - appearance() + // Rebuild drawables only when the pressed state actually changes. + if (touchPressed != wasPressed) appearance() false } } @@ -261,7 +273,10 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th anchorInset: Int = 0, ) { val item = current ?: return - if (key.isNotEmpty() && !item.json.optBoolean("disabled")) + // Legacy: row disabled/deprecated state only gates the controls that opted + // into it (text accessories, checkboxes, footer actions, retry); icon menus, + // leading actions, badges and close controls keep working. + if (key.isNotEmpty()) onAction?.invoke( item, key, @@ -307,7 +322,13 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th container.has("cornerRadius") || defaultCornerRadius > 0 || position in setOf("last", "single") - val radii = FloatArray(8) { if (it < 4 && top || it >= 4 && bottom) radius else 0f } + // Legacy: the pressed fill is always a fully rounded 12dp "single" shape, + // independent of the resting group position. + val pressedShape = + pressed && pressChangesBackground && !container.has("cornerRadius") && defaultCornerRadius <= 0 + val radii = + if (pressedShape) FloatArray(8) { scaledDp(12f) } + else FloatArray(8) { if (it < 4 && top || it >= 4 && bottom) radius else 0f } outlineProvider = ViewOutlineProvider.BACKGROUND clipToOutline = container.has("cornerRadius") background = @@ -364,7 +385,7 @@ internal abstract class NativeListRendererRowView(protected val reactContext: Th listStyle ?.optJSONObject("separator") ?.takeIf { it.has("inset") } - ?.let { stylePx(it.optDouble("inset")) } ?: dp(12) + ?.let { stylePx(it.optDouble("inset")) } ?: dp(defaultSeparatorInset) canvas.drawLine(start.toFloat(), height - 1f, width.toFloat(), height - 1f, separator) } } diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt index ed367773c..1d6dc7d92 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt @@ -334,8 +334,22 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : if (item.json.optString("variant") != "summary" || !retainBoundItem(item)) return currentItem = item contentDescription = accessibilityText(item) - title.text = item.json.optString("title") - trailingViews[0].text = item.json.optString("value") + // The retained item makes the next full bind a no-op, so this path must + // leave text and visibility exactly as bindSectionHeader would. + showText(title, item.json.optString("title"), 1) + val value = item.json.optString("value") + if (value.isEmpty()) { + trailingViews[0].text = "" + trailingViews[0].visibility = GONE + } else { + showTrailing( + 0, + value, + true, + item.json.optString("valueActionKey"), + color(currentTheme, "secondaryText", "#0000009B"), + ) + } applyValueSegments(trailingViews[0], item.json.optJSONArray("valueSegments")) applyTextStyle(item) applySelectorTypography(item) @@ -404,6 +418,7 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : val isSummary = variant == "summary" val isGallery = variant == "gallery" val isTable = currentLayout == "table" + clipToPadding = true // OneKey patch: title help emits its own frame instead of toggling the section. item.json .optString("titleActionKey") @@ -542,6 +557,8 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : } } checkboxData?.let { bindCheckbox(item, it, checkboxState) } + // Legacy: selector checkbox borders may round into the row padding. + if (checkboxControl.requestsUnclippedHost) clipToPadding = false } applyValueSegments(trailingViews[0], item.json.optJSONArray("valueSegments")) } diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSystemRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSystemRenderer.kt index 344c9b83b..974c7a61b 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSystemRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSystemRenderer.kt @@ -58,6 +58,13 @@ internal class NativeListSystemRowView(context: ThemedReactContext) : } override fun modelHeight(item: NativeListItem, layout: String): Int? { + // Legacy market retry/noMatch: round(height * density). + if ( + item.json.has("height") && + item.json.optString("presentation") == "market" && + item.json.optString("variant") in setOf("retry", "noMatch") + ) + return stylePx(item.json.optDouble("height")) if ( sourceScale && layout == "sectioned" && @@ -79,6 +86,11 @@ internal class NativeListSystemRowView(context: ThemedReactContext) : private fun wrap() = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + // Legacy: market retry rows always use source (unscaled) dimensions. + override fun usesSourceScale(item: NativeListItem, provided: Boolean) = + (item.json.optString("presentation") == "market" && item.json.optString("variant") == "retry") || + super.usesSourceScale(item, provided) + override fun bindContent( item: NativeListItem, theme: JSONObject?, @@ -89,7 +101,6 @@ internal class NativeListSystemRowView(context: ThemedReactContext) : val data = item.json val variant = data.optString("variant") val market = data.optString("presentation") == "market" - if (market && variant == "retry") sourceScale = true val style = data.optJSONObject("style") ?: JSONObject() orientation = HORIZONTAL gravity = Gravity.CENTER_VERTICAL @@ -97,7 +108,7 @@ internal class NativeListSystemRowView(context: ThemedReactContext) : setPadding(hp, dp(8), hp, dp(8)) if (market) setPadding(dp(20), dp(12), dp(20), dp(12)) column.gravity = Gravity.CENTER_VERTICAL - val primary = color(theme, "primaryText", "#1D1D1D") + val primary = color(theme, "primaryText", "#000000DF") val secondary = color(theme, "secondaryText", "#0000009B") fun text( view: NativeListTextView, @@ -181,7 +192,10 @@ internal class NativeListSystemRowView(context: ThemedReactContext) : dp(if (value.isEmpty()) 11 else 27), ) if (value.isNotEmpty()) { - text(title, value, "message", 16f, line = 24, alignment = "center", physical = true) + // Legacy market text: one line, ellipsized unless a style overrides it. + text(title, value, "message", 16f, line = 24, lines = 1, alignment = "center", physical = true) + if (style.optJSONObject("message")?.has("lines") != true) + title.ellipsize = android.text.TextUtils.TruncateAt.END column.addView(title) addView( column, @@ -199,6 +213,7 @@ internal class NativeListSystemRowView(context: ThemedReactContext) : "center", physical = true, ) + action.ellipsize = android.text.TextUtils.TruncateAt.END val density = resources.displayMetrics.density val width = (ceil(action.paint.measureText(action.text.toString()).toDouble()) + 18 * density) @@ -220,6 +235,8 @@ internal class NativeListSystemRowView(context: ThemedReactContext) : alignment = "center", physical = true, ) + if (style.optJSONObject("message")?.has("lines") != true) + title.ellipsize = android.text.TextUtils.TruncateAt.END column.addView(title) addView(column, wrap()) } @@ -285,7 +302,7 @@ internal class NativeListSystemRowView(context: ThemedReactContext) : column.addView(title) if (variant == "loading") bars.forEachIndexed { index, view -> - view.background = fill(color(theme, "strongBackground", "#0000000F"), 4) + view.background = fill(color(theme, "strongBackground", "#0000000F"), 6) column.addView( view, LayoutParams(dp(if (index == 0) 120 else 80), dp(12)).apply { @@ -305,7 +322,8 @@ internal class NativeListSystemRowView(context: ThemedReactContext) : if (variant == "retry") { text( action, - data.optString("actionText", "Retry"), + // Legacy: only the market retry reads actionText; this label is fixed. + "Retry", "actionText", 14f, "medium", @@ -321,7 +339,8 @@ internal class NativeListSystemRowView(context: ThemedReactContext) : } } action.setOnClickListener { - emitAction(data.optString("actionKey"), action, "trailingAccessory", 0) + // Legacy Retry was a text accessory: gated by the row's disabled state. + if (!data.optBoolean("disabled")) emitAction(data.optString("actionKey"), action, "trailingAccessory", 0) } if (style.has("horizontalPadding")) { val padding = stylePx(style.optDouble("horizontalPadding")) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt index ac6d86ebe..50f0eaa76 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt @@ -252,9 +252,7 @@ class NativeListView( MotionEvent.ACTION_MOVE -> { val initial = down if (initial != null && kotlin.math.abs(event.y - initial.y) > pagerGestureTouchSlop) { - forward(initial) - forwarding = true - parent?.requestDisallowInterceptTouchEvent(true) + startForwarding(initial, event) return true } } @@ -263,6 +261,15 @@ class NativeListView( return false } + // The intercepting MOVE is not redelivered to onTouchEvent, so forward it + // together with the initial DOWN to keep the list's drag continuous. + private fun startForwarding(initial: MotionEvent, event: MotionEvent) { + forward(initial) + forward(event) + forwarding = true + parent?.requestDisallowInterceptTouchEvent(true) + } + override fun onTouchEvent(event: MotionEvent): Boolean { if (!forwarding) return super.onTouchEvent(event) forward(event) @@ -279,6 +286,9 @@ class NativeListView( updateStickyHeader() true } + // Registered only while attached, on the captured window observer, so detach + // removes the same instance the listener was merged into. + private var stickyObservedTree: ViewTreeObserver? = null private var spacingDecoration: ItemSpacingDecoration? = null private var itemTouchHelper: ItemTouchHelper? = null private var reorderTouchListener: RecyclerView.OnItemTouchListener? = null @@ -357,7 +367,6 @@ class NativeListView( stickyHeaderHost.visibility = GONE stickyHeaderHost.addView(stickyHeaderView, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT)) contentContainer.addView(stickyHeaderHost, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.TOP)) - recyclerView.viewTreeObserver.addOnPreDrawListener(stickyPreDraw) contentContainer.addView( sectionIndexView, FrameLayout.LayoutParams( @@ -513,6 +522,7 @@ class NativeListView( override fun onAttachedToWindow() { super.onAttachedToWindow() + startStickyHeaderTracking() updateSectionIndexAttachment() val first = config?.items?.firstOrNull() if (recyclerView.isLayoutRequested && (first?.type == "market" || first?.json?.optString("presentation") == "market")) { @@ -521,6 +531,7 @@ class NativeListView( } override fun onDetachedFromWindow() { + stopStickyHeaderTracking() stopSectionIndexAttachmentTracking() scheduleSectionIndexAttachmentToList() updateRefreshIndicatorOffset(0) @@ -1175,7 +1186,7 @@ class NativeListView( itemTouchHelper = null footerView.recycle() footerView.dispose() - recyclerView.viewTreeObserver.removeOnPreDrawListener(stickyPreDraw) + stopStickyHeaderTracking() stickyHeaderView.dispose() stickyHeaderHost.visibility = GONE recyclerView.swapAdapter(null, false) @@ -1229,7 +1240,10 @@ class NativeListView( return } val width = recyclerView.width - recyclerView.paddingLeft - recyclerView.paddingRight - if (width <= 0) return + if (width <= 0) { + stickyHeaderHost.visibility = GONE + return + } val signature = "${item.content}:$width:${recyclerView.layoutDirection}:$usesSelectorSourceScale" if (stickyHeaderConfig !== current || stickyHeaderSignature != signature) { stickyHeaderConfig = current @@ -1373,6 +1387,20 @@ class NativeListView( } } + private fun startStickyHeaderTracking() { + if (resourcesDisposed) return + val observer = viewTreeObserver + if (stickyObservedTree === observer) return + stopStickyHeaderTracking() + observer.addOnPreDrawListener(stickyPreDraw) + stickyObservedTree = observer + } + + private fun stopStickyHeaderTracking() { + stickyObservedTree?.takeIf(ViewTreeObserver::isAlive)?.removeOnPreDrawListener(stickyPreDraw) + stickyObservedTree = null + } + private fun startSectionIndexAttachmentTracking() { val observer = viewTreeObserver if (sectionIndexObservedTree === observer) return diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListWalletGroupRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListWalletGroupRenderer.kt index 38ae92257..a738e8f38 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListWalletGroupRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListWalletGroupRenderer.kt @@ -4,7 +4,6 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.TimeInterpolator import android.animation.ValueAnimator -import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.view.Gravity import android.view.View @@ -34,16 +33,14 @@ internal class NativeListWalletGroupRowView(context: ThemedReactContext) : private var currentTheme: JSONObject? = null private var currentLayout = "linear" override val defaultCornerRadius = 12 - override val defaultBorderWidth = 1 override val pressChangesBackground = false + private var containerStyle = JSONObject() + private var compactHeight = 0 override val showsSelection = false override fun usesSourceScale(item: NativeListItem, provided: Boolean) = item.usesSelectorSourceScale || provided - override fun defaultBorderColor(theme: JSONObject?) = - parseNativeListColor(theme?.optString("separator", "#0000001F") ?: "#0000001F") - override fun unselectedBackground( item: NativeListItem, theme: JSONObject?, @@ -52,7 +49,7 @@ internal class NativeListWalletGroupRowView(context: ThemedReactContext) : ) = parseNativeListColor(theme?.optString("subduedBackground", "#F9F9F9") ?: "#F9F9F9") override fun resolvedMeasureHeight(explicit: Int?): Int? = - if (compact) dp(68) else animatedHeight ?: explicit ?: expandedHeight + if (compact) compactHeight else animatedHeight ?: explicit ?: expandedHeight override fun accessibilityText(item: NativeListItem) = item.json.optString( @@ -66,21 +63,18 @@ internal class NativeListWalletGroupRowView(context: ThemedReactContext) : (0 until children.length()).map { NativeListItem.parse(children.getJSONObject(it)) } } + private fun defaultMemberHeight(item: NativeListItem) = + item.json.optInt("height", if ((item.json.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68) + private fun memberHeight(item: NativeListItem) = - item.styledHeight?.let(::stylePx) - ?: dp( - item.json.optInt( - "height", - if ((item.json.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68, - ) - ) + item.styledHeight?.let(::stylePx) ?: dp(defaultMemberHeight(item)) init { orientation = VERTICAL addView(membersColumn, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) addView(compactContainer, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) dragBadge.gravity = Gravity.CENTER - dragBadge.typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + dragBadge.typeface = NativeListFonts.semibold(context) compactContainer.addView(dragBadge) } @@ -104,14 +98,15 @@ internal class NativeListWalletGroupRowView(context: ThemedReactContext) : } } val style = item.json.optJSONObject("style") + containerStyle = style?.optJSONObject("container") ?: JSONObject() val inset = if (members.first().json.has("height")) dp(1) else 0 val horizontal = style ?.takeIf { it.has("horizontalPadding") } ?.let { stylePx(it.optDouble("horizontalPadding")) } ?: inset - val vertical = + val styledVertical = style?.takeIf { it.has("verticalPadding") }?.let { stylePx(it.optDouble("verticalPadding")) } - ?: inset + val vertical = styledVertical ?: inset membersColumn.setPadding(horizontal, vertical, horizontal, vertical) setPadding(0, 0, 0, 0) gravity = @@ -124,16 +119,27 @@ internal class NativeListWalletGroupRowView(context: ThemedReactContext) : expandedHeight = item.styledHeight?.let(::stylePx) ?: modelHeight(item, layout) - ?: (members.sumOf(::memberHeight) + dp((members.size - 1) * 12) + inset * 2) + ?: if (styledVertical == null && members.none { it.styledHeight != null }) { + // Legacy default: one rounding pass over the whole unstyled group; the + // row size modifier can only raise the minimum. + val base = + members.sumOf(::defaultMemberHeight) + (members.size - 1) * 12 + if (inset > 0) 2 else 0 + val sizeDelta = + when (item.json.optString("size")) { + "small" -> -8 + "large" -> 12 + else -> 0 + } + maxOf(dp(base), dp((base + sizeDelta).coerceAtLeast(0))) + } else members.sumOf(::memberHeight) + dp((members.size - 1) * 12) + vertical * 2 members.forEachIndexed { index, member -> val view = memberViews.getOrPut(member.key) { NativeListIdentityRowView(reactContext).also { view -> - view.onRowPress = { source, origin -> - if (isEnabled) onRowPress?.invoke(source, origin) - } + // Legacy: members own their disabled state; the group never gates them. + view.onRowPress = { source, origin -> onRowPress?.invoke(source, origin) } view.onAction = { source, key, target, origin -> - if (isEnabled) onAction?.invoke(source, key, target, origin) + onAction?.invoke(source, key, target, origin) } view.onBindingInvalidated = { source, epoch -> onBindingInvalidated?.invoke(source, epoch) @@ -182,7 +188,11 @@ internal class NativeListWalletGroupRowView(context: ThemedReactContext) : checkboxState, sourceScale, ) - compactContainer.layoutParams.height = dp(68) + // Legacy compact drag: the parent keeps its own height (92 with badges) plus + // the group inset, never less than the 68dp allocation. + compactHeight = maxOf(dp(68), memberHeight(parent) + vertical * 2) + compactContainer.setPadding(horizontal, vertical, horizontal, vertical) + compactContainer.layoutParams.height = compactHeight val count = members.drop(1).count { it.json.optBoolean("draggable", true) } dragBadge.text = "+$count" dragBadge.visibility = if (count == 0) GONE else VISIBLE @@ -206,8 +216,9 @@ internal class NativeListWalletGroupRowView(context: ThemedReactContext) : dragBadge.layoutParams = FrameLayout.LayoutParams(LayoutParams.WRAP_CONTENT, dp(24), Gravity.END or Gravity.BOTTOM) .apply { - rightMargin = dp(4) - bottomMargin = dp(4) + // Legacy: the badge is inset 4dp from the group's own edges. + rightMargin = dp(4) - horizontal + bottomMargin = dp(4) - vertical } } @@ -243,32 +254,79 @@ internal class NativeListWalletGroupRowView(context: ThemedReactContext) : members.forEach { member -> val view = memberViews[member.key] ?: return@forEach if (y >= view.top && y < view.bottom) - return member.json.optBoolean("draggable", true) && !member.json.optBoolean("disabled") + // Legacy: only `draggable: false` excludes a member from starting a group drag. + return member.json.optBoolean("draggable", true) } return true } + private var expansionCompletion: (() -> Unit)? = null + + // Legacy group chrome: subdued fill with the separator border drawn beneath the + // members (not as a foreground); row-level backgroundColor does not apply. override fun appearance() { super.appearance() - if (compact) { - background = null - foreground = null + val theme = currentTheme + fun parse(value: String, fallback: String) = + runCatching { parseNativeListColor(value) }.getOrElse { parseNativeListColor(fallback) } + val fill = + if (containerStyle.has("backgroundColor")) + parse(containerStyle.optString("backgroundColor"), "#F9F9F9") + else parse(theme?.optString("subduedBackground", "#F9F9F9") ?: "#F9F9F9", "#F9F9F9") + val stroke = + if (containerStyle.has("borderColor")) parse(containerStyle.optString("borderColor"), "#0000001F") + else parse(theme?.optString("separator", "#0000001F") ?: "#0000001F", "#0000001F") + background = + GradientDrawable().apply { + setColor(fill) + cornerRadius = + if (containerStyle.has("cornerRadius")) stylePx(containerStyle.optDouble("cornerRadius")).toFloat() + else dp(12).toFloat() + setStroke( + if (containerStyle.has("borderWidth")) stylePx(containerStyle.optDouble("borderWidth")) else dp(1), + stroke, + ) + } + foreground = null + } + + private fun showExpandedMembers(restAlpha: Float) { + membersColumn.visibility = VISIBLE + membersColumn.alpha = 1f + for (index in 0 until membersColumn.childCount) { + membersColumn.getChildAt(index).apply { + visibility = VISIBLE + alpha = if (index == 0) 1f else restAlpha + } } } + // Cancelling still settles the expanded state and runs the pending completion, + // so the container's post-drop relayout is never lost. private fun cancelExpansion() { - expandAnimator?.removeAllListeners() - expandAnimator?.cancel() + val completion = expansionCompletion + expansionCompletion = null + val animator = expandAnimator expandAnimator = null animatedHeight = null + if (animator != null) { + animator.removeAllListeners() + animator.cancel() + showExpandedMembers(1f) + requestLayout() + } + completion?.invoke() } override fun setReorderActive(active: Boolean) { if (compact == active && expandAnimator == null) return cancelExpansion() compact = active - membersColumn.visibility = if (active) GONE else VISIBLE - membersColumn.alpha = 1f + if (active) { + membersColumn.visibility = GONE + } else { + showExpandedMembers(1f) + } compactContainer.visibility = if (active) VISIBLE else GONE compactContainer.alpha = 1f compactParent?.setReorderActive(active) @@ -282,32 +340,44 @@ internal class NativeListWalletGroupRowView(context: ThemedReactContext) : completion: (() -> Unit)?, ) { cancelExpansion() - val startHeight = height + val startHeight = height.coerceAtLeast(dp(68)) compact = false compactParent?.setReorderActive(false) - membersColumn.visibility = VISIBLE - membersColumn.alpha = 0f + compactContainer.visibility = GONE + compactContainer.alpha = 1f + // Legacy: the first member stays opaque while the remaining members fade in. + showExpandedMembers(0f) appearance() + if (expandedHeight <= startHeight) { + showExpandedMembers(1f) + requestLayout() + completion?.invoke() + return + } + animatedHeight = startHeight + requestLayout() + expansionCompletion = completion expandAnimator = ValueAnimator.ofInt(startHeight, expandedHeight).apply { duration = durationMs this.interpolator = interpolator addUpdateListener { animatedHeight = it.animatedValue as Int - membersColumn.alpha = it.animatedFraction - compactContainer.alpha = 1f - it.animatedFraction + val fraction = it.animatedFraction + for (index in 1 until membersColumn.childCount) membersColumn.getChildAt(index).alpha = fraction requestLayout() } addListener( object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { + if (expandAnimator !== animation) return expandAnimator = null animatedHeight = null - membersColumn.alpha = 1f - compactContainer.visibility = GONE - compactContainer.alpha = 1f + showExpandedMembers(1f) requestLayout() - completion?.invoke() + val pending = expansionCompletion + expansionCompletion = null + pending?.invoke() } } ) From 45818d477b7684deec2ee17e7adaf60baf30dc35 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 10:46:23 +0800 Subject: [PATCH 63/71] fix(native-list/ios): restore legacy row rendering in renderer registry Keep the renderer architecture but restore the unstyled rendering and behavior of the deleted NativeListCell. - Include resolved vertical padding in WalletGroup height and use legacy member heights; allow drag unless a member is draggable: false. - Resolve start/end text alignment from the layout direction (RTL). - Measure Message text with a shared sizing label and bounded cache, and compare theme/list style directly instead of hashing JSON on every bind. - Restore legacy defaults: 12pt pressed corners (Rail 8, wallet sidebar 20), Rail pressed color, MediaTile truncation/placeholder/network icon, Action icon border, DataRow linear single label (styles applied to text runs), System warning digits and empty-line height, Activity footer, amounts and empty leading slot, MetricCard clipping and placeholder, Identity leading clear on reuse, trailing gap and sidebar overlays. - Pass nil to the section index above the first section and render unknown non-empty row types as a blank row instead of dropping the snapshot. Co-Authored-By: Claude Opus 5.5 --- .../ios/NativeListActionRenderer.swift | 11 +- .../ios/NativeListActivityRenderer.swift | 22 ++- .../ios/NativeListDataRowRenderer.swift | 138 +++++++++++++++++- .../ios/NativeListIdentityRenderer.swift | 12 +- .../ios/NativeListLeadingVisual.swift | 53 ++++++- .../ios/NativeListMarketRenderer.swift | 6 +- .../ios/NativeListMediaTileRenderer.swift | 30 +++- .../ios/NativeListMetricCardRenderer.swift | 21 ++- .../ios/NativeListModels.swift | 15 +- .../ios/NativeListRailRenderer.swift | 6 +- .../ios/NativeListRendererCell.swift | 61 +++++--- .../ios/NativeListRendererRegistry.swift | 11 ++ .../ios/NativeListResolvedText.swift | 59 ++++++-- .../ios/NativeListSectionHeaderRenderer.swift | 17 ++- .../ios/NativeListSystemRenderer.swift | 4 + .../ios/NativeListTextStyles.swift | 28 ++++ .../ios/NativeListWalletGroupRenderer.swift | 25 +++- .../ios/RNCNativeListView.swift | 5 +- 18 files changed, 442 insertions(+), 82 deletions(-) diff --git a/native-views/react-native-native-list/ios/NativeListActionRenderer.swift b/native-views/react-native-native-list/ios/NativeListActionRenderer.swift index ac5159fc0..bb9dfd145 100644 --- a/native-views/react-native-native-list/ios/NativeListActionRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListActionRenderer.swift @@ -47,7 +47,11 @@ final class NativeListActionCell: NativeListRendererCell { let selector = item.data.string("presentation") == "accountSelector" let icon = item.data.dictionary("icon") let descriptors = - item.data.dictionary("checkbox").map { [$0] } ?? item.data.dictionaries("trailing") + item.data.dictionary("checkbox").map { checkbox -> [[String: Any]] in + var checkbox = checkbox + if checkbox["kind"] == nil { checkbox["kind"] = "checkbox" } + return [checkbox] + } ?? item.data.dictionaries("trailing") accessories.bind( item, descriptors: descriptors, theme: theme, style: style, checkboxState: checkboxState) let hp = CGFloat(style.double("horizontalPadding", default: layout == "table" ? 16 : 12)) @@ -75,6 +79,9 @@ final class NativeListActionCell: NativeListRendererCell { ).bind(title) visual.isHidden = icon == nil if var icon { + // Legacy kept the icon hairline border unless the selector or a transparent default + // background (no explicit backgroundColor) removed it. + let keepsBorder = !selector && icon["backgroundColor"] != nil if icon["backgroundColor"] == nil { icon["backgroundColor"] = "#00000000" } var image = style.dictionary("image") ?? [:] if selector && image["cornerRadius"] == nil && image["shape"] == nil { @@ -83,7 +90,7 @@ final class NativeListActionCell: NativeListRendererCell { visualWidth.constant = CGFloat(image.double("width", default: selector ? 32 : 40)) visualHeight.constant = CGFloat(image.double("height", default: selector ? 32 : 40)) visual.bind(icon, style: image, key: item.key, theme: theme, isUnread: false) - visual.layer.borderWidth = 0 + if !keepsBorder { visual.layer.borderWidth = 0 } } else { visual.recycle() } diff --git a/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift b/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift index 18e1ba654..f52752220 100644 --- a/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift @@ -43,7 +43,12 @@ final class NativeListActivityCell: NativeListRendererCell { amounts.alignment = .trailing amounts.setContentHuggingPriority(.required, for: .horizontal) amounts.setContentCompressionResistancePriority(.required, for: .horizontal) - amountLabels.forEach(amounts.addArrangedSubview) + amountLabels.forEach { + amounts.addArrangedSubview($0) + // Legacy amounts keep their full width; the text column yields first. + $0.setContentHuggingPriority(.required, for: .horizontal) + $0.setContentCompressionResistancePriority(.required, for: .horizontal) + } actions.axis = .horizontal actions.alignment = .center actions.spacing = 8 @@ -86,9 +91,14 @@ final class NativeListActivityCell: NativeListRendererCell { visual.heightAnchor.constraint(equalToConstant: CGFloat(image.double("height", default: 40))), ] NSLayoutConstraint.activate(dimensions) - visual.bind( - data.dictionary("leading") ?? [:], style: image, key: item.key, theme: theme, isUnread: false, - secondaryVisual: data.dictionary("secondaryLeading")) + if let leading = data.dictionary("leading") { + visual.bind( + leading, style: image, key: item.key, theme: theme, isUnread: false, + secondaryVisual: data.dictionary("secondaryLeading")) + } else { + // Legacy kept an empty leading slot (no placeholder disc) when no visual was given. + visual.clear() + } func bind( _ view: NativeListTextLabel, _ value: String, _ slot: String, size: CGFloat, weight: NativeListFontWeight = .regular, color: UIColor, line: CGFloat, lines: Int = 1 @@ -146,7 +156,9 @@ final class NativeListActivityCell: NativeListRendererCell { guard index < descriptors.count else { continue } let action = descriptors[index] button.setTitle(action.string("label"), for: .normal) - button.isEnabled = !data.bool("disabled") && !action.bool("disabled") + // Legacy: only the action's own flag disables it; a disabled row is already dimmed + // and non-interactive as a whole. + button.isEnabled = !action.bool("disabled") button.backgroundColor = nativeListColor(theme, "strongBackground", "#F0F0F0") button.setTitleColor( nativeListColor( diff --git a/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift b/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift index adc8d057d..f6092a6e8 100644 --- a/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift @@ -74,7 +74,11 @@ final class NativeListDataRowCell: NativeListRendererCell { if data["index"] != nil { descriptors.append(["kind": "value", "text": String(data.int("index"))]) } - if let checkbox = data.dictionary("checkbox") { descriptors.append(checkbox) } + if var checkbox = data.dictionary("checkbox") { + // Legacy bound the checkbox field directly; it does not need an explicit kind. + if checkbox["kind"] == nil { checkbox["kind"] = "checkbox" } + descriptors.append(checkbox) + } var accessoryStyle = style accessoryStyle["value"] = style.dictionary("index") accessories.bind( @@ -95,8 +99,12 @@ final class NativeListDataRowCell: NativeListRendererCell { cell.reset() continue } - cell.bind( - column: values[index], badges: index == 0 ? data.dictionaries("badges") : [], theme: theme) + let badges = index == 0 ? data.dictionaries("badges") : [] + if layout == "table" { + cell.bind(column: values[index], badges: badges, theme: theme) + } else { + cell.bindLinear(column: values[index], badges: badges, theme: theme, style: style) + } cell.applyStyle(style, text: NativeListTextStyles.applyStyledText) if index > 0 { rowConstraints.append( @@ -126,6 +134,7 @@ private final class NativeListTableColumnView: UIStackView { private let secondaryLine = UIStackView() private let secondaryLeadingLabel = NativeListTextLabel() private let secondaryLabel = NativeListTextLabel() + private var linear = false override init(frame: CGRect) { super.init(frame: frame) @@ -167,6 +176,7 @@ private final class NativeListTableColumnView: UIStackView { } func reset() { + linear = false for label in [primaryLabel, secondaryLeadingLabel, secondaryLabel] { label.rowVerticalAlignment = nil label.rowOffsetY = 0 @@ -263,7 +273,129 @@ private final class NativeListTableColumnView: UIStackView { } } + /// Legacy linear layout: one label per column holding the primary text, up to two inline + /// badge runs and the secondary text on a second line. `secondaryLeadingText` is not shown. + /// Unstyled output is exactly the legacy label. `style.columns` / `style.columnSecondary` + /// map onto their attributed ranges (font, weight, color) and paragraphs (lineHeight, + /// alignment); `lines`/`truncate` and vertical alignment/offset act on the single label. + func bindLinear( + column: [String: Any], badges: [[String: Any]], theme: [String: Any]?, + style: [String: Any] = [:] + ) { + reset() + linear = true + alignment = .fill + badgesStack.isHidden = true + let primaryStyle = style.dictionary("columns") ?? [:] + let secondaryStyle = style.dictionary("columnSecondary") ?? [:] + func font(_ textStyle: [String: Any], size: CGFloat, weight: NativeListFontWeight) -> UIFont { + guard textStyle["fontSize"] != nil || textStyle["fontWeight"] != nil else { + return nativeListFont(ofSize: size, weight: weight) + } + return nativeListFont( + ofSize: CGFloat(textStyle.double("fontSize", default: Double(size))), + weight: NativeListTextStyles.marketFontWeight( + textStyle.string("fontWeight"), fallback: weight)) + } + func color(_ textStyle: [String: Any], _ fallback: UIColor) -> UIColor { + (textStyle["color"] as? String).map { UIColor(nativeListHex: $0, fallback: fallback) } + ?? fallback + } + let direction = primaryLabel.effectiveUserInterfaceLayoutDirection + /// Paragraph attributes only when the style asks for them, so unstyled text stays legacy. + func paragraph(_ textStyle: [String: Any], font: UIFont, spacing: CGFloat?) + -> [NSAttributedString.Key: Any] + { + guard textStyle["lineHeight"] != nil || textStyle["alignment"] != nil || spacing != nil + else { return [:] } + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = NativeListTextStyles.marketTextAlignment( + textStyle.string("alignment"), direction: direction) + if textStyle["alignment"] == nil { paragraph.alignment = primaryLabel.textAlignment } + var attributes: [NSAttributedString.Key: Any] = [.paragraphStyle: paragraph] + if textStyle["lineHeight"] != nil { + let box = CGFloat(textStyle.double("lineHeight")) + paragraph.minimumLineHeight = box + paragraph.maximumLineHeight = box + attributes[.baselineOffset] = max(0, (box - font.lineHeight) / 2) + } + if let spacing { paragraph.paragraphSpacing = spacing } + return attributes + } + // Legacy physical alignment from the column data; set before the attributed text so that + // unstyled paragraphs inherit it. + primaryLabel.textAlignment = + column.string("alignment") == "end" + ? .right : column.string("alignment") == "center" ? .center : .left + let secondary = column.string("secondaryText") + let primaryFont = font(primaryStyle, size: 14, weight: .medium) + let attributed = NSMutableAttributedString( + string: column.string("text"), + attributes: [ + .font: primaryFont, + .foregroundColor: color(primaryStyle, textColor(column.string("tone"), theme: theme)), + ]) + let primaryLength = attributed.length + for (index, badge) in badges.prefix(2).enumerated() { + // `titleBadgeGap` replaces the legacy two-space lead-in with an exact kern gap. + let gap = index == 0 && style["titleBadgeGap"] != nil + attributed.append( + NSAttributedString( + string: gap ? " \(badge.string("text")) " : " \(badge.string("text")) ", + attributes: [ + .font: nativeListFont(ofSize: 12, weight: .medium), + .foregroundColor: nativeListColor(theme, "info", "#0D74CE"), + .backgroundColor: UIColor(nativeListHex: "#008FF519", fallback: .systemBlue), + ])) + if gap, primaryLength > 0 { + attributed.addAttribute( + .kern, value: CGFloat(style.double("titleBadgeGap")), + range: NSRange(location: primaryLength - 1, length: 1)) + } + } + let primaryParagraph = paragraph( + primaryStyle, font: primaryFont, + spacing: !secondary.isEmpty && style["lineGap"] != nil + ? CGFloat(style.double("lineGap")) : nil) + if !primaryParagraph.isEmpty { + attributed.addAttributes( + primaryParagraph, range: NSRange(location: 0, length: attributed.length)) + } + var lines = min(3, max(1, primaryStyle.int("lines", default: 1))) + if !secondary.isEmpty { + let secondaryFont = font(secondaryStyle, size: 12, weight: .regular) + var attributes: [NSAttributedString.Key: Any] = [ + .font: secondaryFont, + .foregroundColor: color( + secondaryStyle, + textColor(column.string("secondaryTone", default: "secondary"), theme: theme)), + ] + attributes.merge(paragraph(secondaryStyle, font: secondaryFont, spacing: nil)) { $1 } + attributed.append(NSAttributedString(string: "\n\(secondary)", attributes: attributes)) + lines = min(3, lines + max(1, secondaryStyle.int("lines", default: 1))) + } + primaryLabel.font = nativeListFont(ofSize: 12) + primaryLabel.numberOfLines = lines + primaryLabel.attributedText = attributed + let truncation = + primaryStyle["truncate"] != nil || primaryStyle["lines"] != nil + ? primaryStyle + : secondaryStyle["truncate"] != nil || secondaryStyle["lines"] != nil ? secondaryStyle : nil + primaryLabel.lineBreakMode = + truncation.map { NativeListTextStyles.styledLineBreakMode($0, lines: lines) } + ?? .byTruncatingTail + if let vertical = (primaryStyle["verticalAlignment"] ?? secondaryStyle["verticalAlignment"]) + as? String + { + primaryLabel.rowVerticalAlignment = vertical + } + primaryLabel.rowOffsetY = CGFloat( + primaryStyle.double("offsetY", default: secondaryStyle.double("offsetY"))) + } + func applyStyle(_ style: [String: Any], text: (UILabel, [String: Any]) -> Void) { + // The linear single label already mapped its styles onto attributed ranges. + guard !linear else { return } if let primary = style.dictionary("columns") { if primary["alignment"] != nil { alignment = .fill } text(primaryLabel, primary) diff --git a/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift b/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift index 7c6f34ba4..a5d144cd4 100644 --- a/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift @@ -23,7 +23,7 @@ final class NativeListIdentityCell: NativeListRendererCell { private let tertiaryLabel = NativeListTextLabel() private let badgeLabel = NativeListInsetLabel() private let trailingStack = NativeListAccessoryStack(frame: .zero) - private let leadingActionButton = UIButton(type: .custom) + private let leadingActionButton = UIButton(type: .system) private var leadingWidth: NSLayoutConstraint! private var leadingHeight: NSLayoutConstraint! private var leftInset: CGFloat = 12 @@ -53,6 +53,10 @@ final class NativeListIdentityCell: NativeListRendererCell { } return 0 } + private var isWalletSidebar: Bool { currentItem?.data.string("presentation") == "walletSidebar" } + override var pressedCornerRadius: CGFloat? { isWalletSidebar ? 20 : 12 } + // Legacy grouped wallet sidebar rows used the sidebar radius. + override var groupCornerRadius: CGFloat { isWalletSidebar ? 20 : 12 } override var defaultCornerCurve: CALayerCornerCurve { currentItem?.data.string("presentation") == "walletSidebar" ? .continuous : .circular } @@ -270,7 +274,8 @@ final class NativeListIdentityCell: NativeListRendererCell { private func addLeading(_ descriptor: [String: Any]?, key: String) { root.addArrangedSubview(visual) guard let descriptor else { - visual.recycle() + // Legacy reset the leading container: no stale avatar, initials, border or overlays. + visual.clear() return } let image = currentItem?.data.dictionary("style")?.dictionary("image") ?? [:] @@ -576,6 +581,9 @@ final class NativeListIdentityCell: NativeListRendererCell { trailingStack.bind( item, descriptors: accessories, theme: theme, style: item.data.dictionary("style") ?? [:], defaultSpacing: trailingStack.spacing, checkboxState: checkboxState) + // Legacy kept the (possibly empty) trailing stack arranged, so the text column always + // ends 12 points before the trailing edge inset. + trailingStack.isHidden = false if let gap = trailingStack.contentGap { root.setCustomSpacing(gap, after: mainStack) } if let inset = trailingStack.endInset { rightInset = -inset } if item.data.string("presentation") == "accountSelector", diff --git a/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift b/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift index bad8e6b6d..5b8b1549a 100644 --- a/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift +++ b/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift @@ -8,6 +8,7 @@ final class NativeListLeadingVisual: UIView { private let fallback = NativeListTextLabel() var fallbackTextView: UILabel { fallback } var singleOuterMask = false + var alwaysClips = false var dashedBorderWidth: CGFloat = 2 var overlayTextFontSize: CGFloat = 10 var overlayTextLineHeight: CGFloat? @@ -174,7 +175,9 @@ final class NativeListLeadingVisual: UIView { let label = frame.subviews.first as? UILabel ?? UILabel() label.text = data.string("text") label.textAlignment = .center - label.font = nativeListFont(ofSize: overlayTextFontSize, weight: .medium) + // A line-height box is the legacy wallet text badge: regular weight, centered metrics. + label.font = nativeListFont( + ofSize: overlayTextFontSize, weight: overlayTextLineHeight == nil ? .medium : .regular) if let line = overlayTextLineHeight { let paragraph = NSMutableParagraphStyle() paragraph.minimumLineHeight = line @@ -182,7 +185,10 @@ final class NativeListLeadingVisual: UIView { paragraph.alignment = .center label.attributedText = NSAttributedString( string: data.string("text"), - attributes: [.font: label.font as Any, .paragraphStyle: paragraph]) + attributes: [ + .font: label.font as Any, .paragraphStyle: paragraph, + .baselineOffset: max(0, (line - label.font.lineHeight) / 2), + ]) } label.textColor = UIColor( nativeListHex: data.string("tintColor", default: "#646464"), fallback: .darkGray) @@ -229,7 +235,9 @@ final class NativeListLeadingVisual: UIView { ? (imageStyle["shape"] != nil ? 10 : min(10, bounds.height / 4)) : min(bounds.width, bounds.height) / 2 layer.cornerRadius = radius - clipsToBounds = sources.count < 2 && overlays.isEmpty && visual.dictionary("cornerIcon") == nil + clipsToBounds = + alwaysClips + || sources.count < 2 && overlays.isEmpty && visual.dictionary("cornerIcon") == nil let ellipse = imageStyle.string("shape") == "circle" && imageStyle["cornerRadius"] == nil if ellipse && clipsToBounds { let mask = layer.mask as? CAShapeLayer ?? CAShapeLayer() @@ -295,8 +303,19 @@ final class NativeListLeadingVisual: UIView { unread.frame = logical(CGRect(x: bounds.width - 8, y: 0, width: 8, height: 8)) for (frame, data) in overlays { let size = CGFloat(data.double("size", default: 20)) - let width = CGFloat(data.double("width", default: Double(size))) - let height = CGFloat(data.double("height", default: Double(size))) + // Legacy wallet text badges size to their text (tabular 12pt + 4) at the line height. + let textBadgeLine = + data.dictionary("image") == nil && !data.string("text").isEmpty + && data.string("name").isEmpty ? overlayTextLineHeight : nil + let naturalWidth = textBadgeLine.map { _ -> CGFloat in + let scale = max(1, window?.screen.scale ?? traitCollection.displayScale) + let textWidth = (data.string("text") as NSString).size(withAttributes: [ + .font: nativeListTabularFont(ofSize: overlayTextFontSize), .kern: 0, + ]).width + return ceil(textWidth * scale) / scale + 4 + } + let width = CGFloat(data.double("width", default: Double(naturalWidth ?? size))) + let height = CGFloat(data.double("height", default: Double(textBadgeLine ?? size))) let offset = data.double("offset", default: 2) let x = CGFloat(data.double("offsetX", default: offset)) let y = CGFloat(data.double("offsetY", default: offset)) @@ -326,4 +345,28 @@ final class NativeListLeadingVisual: UIView { slots.forEach { $0.recycle() } overlaySlots.forEach { $0.recycle() } } + /// Empty slot (legacy leading container without a visual): no image, fallback, fill, + /// border or overlays from a previous binding. + func clear() { + recycle() + visual = [:] + sources = [] + slots.forEach { $0.view.isHidden = true } + overlays.forEach { $0.0.removeFromSuperview() } + overlays.removeAll() + fallback.text = nil + fallback.isHidden = true + icon.image = nil + icon.isHidden = true + networkBackdrop.isHidden = true + corner.isHidden = true + cornerBackdrop.isHidden = true + unread.isHidden = true + backgroundColor = .clear + layer.borderWidth = 0 + layer.mask = nil + dashedBorder.isHidden = true + dashedBorder.path = nil + setNeedsLayout() + } } diff --git a/native-views/react-native-native-list/ios/NativeListMarketRenderer.swift b/native-views/react-native-native-list/ios/NativeListMarketRenderer.swift index 4e09f323d..de6c0df07 100644 --- a/native-views/react-native-native-list/ios/NativeListMarketRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListMarketRenderer.swift @@ -21,11 +21,11 @@ final class NativeListMarketCell: NativeListRendererCell { private let marketSubtitleStack = UIStackView() private let marketSubtitleSpacer = UIView() private let trailingStack = UIStackView() - private let accessoryButtons = (0..<2).map { _ in NativeListAccessoryButton(type: .custom) } - private let marketBadgeButtons = (0..<3).map { _ in NativeListAccessoryButton(type: .custom) } + private let accessoryButtons = (0..<2).map { _ in NativeListAccessoryButton(type: .system) } + private let marketBadgeButtons = (0..<3).map { _ in NativeListAccessoryButton(type: .system) } private let badgeSlots = (0..<3).map { _ in NativeListImageSlot() } private var marketBadgeImages: [UIView] { badgeSlots.map { $0.view } } - private let leadingActionButton = UIButton(type: .custom) + private let leadingActionButton = UIButton(type: .system) private var leadingWidth: NSLayoutConstraint! private var leadingHeight: NSLayoutConstraint! private var selectorConstraints: [NSLayoutConstraint] = [] diff --git a/native-views/react-native-native-list/ios/NativeListMediaTileRenderer.swift b/native-views/react-native-native-list/ios/NativeListMediaTileRenderer.swift index af0b72f5a..ed7da9449 100644 --- a/native-views/react-native-native-list/ios/NativeListMediaTileRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListMediaTileRenderer.swift @@ -20,6 +20,7 @@ final class NativeListMediaTileCell: NativeListRendererCell { private let closeStack = UIStackView() private let titleLine = UIStackView() private var closeKey = "" + private var ownsImageFill = false private var imageStyle: [String: Any] = [:] private var imageWidth: NSLayoutConstraint! private var imageHeight: NSLayoutConstraint! @@ -97,39 +98,52 @@ final class NativeListMediaTileCell: NativeListRendererCell { item.data.string("title"), style: style.dictionary("title"), size: 16, weight: .medium, color: nativeListColor(theme, "primaryText", "#202020"), lineHeight: nativeListFont(ofSize: 16, weight: .medium).lineHeight, - lines: 1 + lines: 1, preservesBreakMode: true ).bind(title) NativeListResolvedText( item.data.string("subtitle"), style: style.dictionary("subtitle"), size: 12, color: nativeListColor(theme, "secondaryText", "#646464"), - lineHeight: nativeListFont(ofSize: 12).lineHeight, lines: 1 + lineHeight: nativeListFont(ofSize: 12).lineHeight, lines: 1, preservesBreakMode: true ).bind(subtitle) let badgeText = item.data.dictionary("badge")?.string("text") ?? "" NativeListResolvedText( badgeText.isEmpty ? "" : " \(badgeText) ", style: style.dictionary("badge"), size: 14, weight: .medium, color: nativeListColor(theme, "inverseText", "#FCFCFC"), lineHeight: nativeListFont(ofSize: 14, weight: .medium).lineHeight, - lines: 1 + lines: 1, preservesBreakMode: true ).bind(badge) if style.dictionary("badge")?["alignment"] == nil { badge.textAlignment = .center } badge.backgroundColor = nativeListColor(theme, "inverseBackground", "#202020") badge.layer.borderColor = nativeListColor(theme, "rowBackground", "#FFFFFF").cgColor let state = item.data.string("imageState") + let placeholder = theme?.string("strongBackground", default: "#0000000F") ?? "#0000000F" errorIcon.isHidden = state != "error" errorIcon.image = nativeListIcon(named: "ImageSquareWavesOutline") errorIcon.tintColor = UIColor(nativeListHex: "#00000044", fallback: .lightGray) if state != "empty", state != "error", let source = item.data.dictionary("image") { - image.bind(source, key: "\(item.key):media", fit: imageStyle["contentFit"] as? String) + // Legacy source-backed media had a transparent container. Clear only a fill this + // renderer applied, so OneKeyImage keeps ownership of its own placeholder color. + if ownsImageFill { + image.view.backgroundColor = .clear + ownsImageFill = false + } + image.bind( + source, key: "\(item.key):media", fit: imageStyle["contentFit"] as? String, + placeholder: placeholder) } else { image.recycle() image.view.backgroundColor = state == "error" ? nativeListColor(theme, "strongBackground", "#0000000F") : .clear + ownsImageFill = true } if let source = item.data.dictionary("networkImage") { - network.view.isHidden = false - network.view.alpha = 0 - network.bind(source, key: "\(item.key):network", variant: "network") { [weak self] loaded in - self?.network.view.alpha = loaded ? 1 : 0 + // Legacy hid the network badge until it loaded, so it takes no layout space before. + network.view.isHidden = true + network.view.alpha = 1 + network.bind( + source, key: "\(item.key):network", variant: "network", placeholder: placeholder + ) { [weak self] loaded in + self?.network.view.isHidden = !loaded } } else { network.recycle() diff --git a/native-views/react-native-native-list/ios/NativeListMetricCardRenderer.swift b/native-views/react-native-native-list/ios/NativeListMetricCardRenderer.swift index 9fb013dc0..1a021aff4 100644 --- a/native-views/react-native-native-list/ios/NativeListMetricCardRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListMetricCardRenderer.swift @@ -21,11 +21,18 @@ final class NativeListMetricCardCell: NativeListRendererCell { private var metricVisuals: [String: NativeListMetricVisual] = [:] private var usedVisuals = Set() private var rowKey = "" + private var boundTheme: [String: Any]? override var assetFields: [String] { ["visual", "metrics"] } override var defaultCornerRadius: CGFloat { 12 } override func unselectedBackground( _ item: NativeListItem, theme: [String: Any]?, layout: String, itemIndex: Int? - ) -> UIColor { nativeListColor(theme, "subduedBackground", "#F9F9F9") } + ) -> UIColor { + // Legacy: a selected card in a sectioned list without its own `selected` flag falls back + // to rowBackground (the checkbox carries selection), not the subdued card fill. + selectedState && layout == "sectioned" + ? nativeListColor(theme, "rowBackground", "#FFFFFF") + : nativeListColor(theme, "subduedBackground", "#F9F9F9") + } override init(frame: CGRect) { super.init(frame: frame) mainStack.axis = .vertical @@ -40,6 +47,8 @@ final class NativeListMetricCardCell: NativeListRendererCell { [titleLine, subtitleLabel, statusLabel, detailLabel].forEach(mainStack.addArrangedSubview) metricCompositeStack.axis = .vertical metricCompositeStack.alignment = .fill + // Legacy clipped the standard visual container to its shape even with overlays. + visual.alwaysClips = true } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func bindContent( @@ -47,6 +56,7 @@ final class NativeListMetricCardCell: NativeListRendererCell { checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String ) { rowKey = item.key + boundTheme = theme usedVisuals = [] contentView.clipsToBounds = true root.arrangedSubviews.forEach { @@ -179,7 +189,10 @@ final class NativeListMetricCardCell: NativeListRendererCell { usedVisuals.insert(identity) let view = metricVisuals[identity] ?? NativeListMetricVisual() metricVisuals[identity] = view - view.bind(visual, key: identity) + // Legacy metric images used the themed strongBackground placeholder. + view.bind( + visual, key: identity, + placeholder: boundTheme?.string("strongBackground", default: "#0000000F") ?? "#0000000F") return view } private func bindCompositeMetricCard( @@ -371,7 +384,7 @@ private final class NativeListMetricVisual: UIView { clipsToBounds = true } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func bind(_ visual: [String: Any], key: String) { + func bind(_ visual: [String: Any], key: String, placeholder: String) { backgroundColor = UIColor( nativeListHex: visual.string("backgroundColor", default: "#0000000F"), fallback: .lightGray) layer.cornerRadius = visual.string("shape") == "square" ? 0 : 8 @@ -393,7 +406,7 @@ private final class NativeListMetricVisual: UIView { ? "token" : kind == "network" ? "network" : ["wallet", "account"].contains(kind) ? "avatar" : "generic", fit: nil, - placeholder: "#00000000") + placeholder: placeholder) } else { image.recycle() } diff --git a/native-views/react-native-native-list/ios/NativeListModels.swift b/native-views/react-native-native-list/ios/NativeListModels.swift index a9194495f..2ebd7d2ab 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -4,6 +4,9 @@ import UIKit // Template type alone partitions reuse; data keys and styles never select a renderer. enum NativeListRendererKey: String, CaseIterable { case message, rail, mediaTile, action, system, activity, dataRow, metricCard, market, identity, sectionHeader, walletGroup + /// Not a template: legacy accepted any non-empty type and drew an empty row with the + /// shared chrome. Unknown types keep that placeholder instead of failing the snapshot. + case unsupported = "__nativeListUnsupported" } struct NativeListItem { @@ -41,10 +44,12 @@ struct NativeListItem { init(data: [String: Any]) throws { guard let key = data["key"] as? String, !key.isEmpty, - let type = data["type"] as? String, - let rendererKey = NativeListRendererKey(rawValue: type) else { + let type = data["type"] as? String, !type.isEmpty else { throw NativeListModelError.invalidRow } + let rendererKey = NativeListRendererKey(rawValue: type) + .flatMap { $0 == .unsupported ? nil : $0 } ?? .unsupported + if rendererKey == .unsupported { Self.logUnsupported(type) } self.key = key self.type = type self.rendererKey = rendererKey @@ -55,6 +60,12 @@ struct NativeListItem { self.content = String(data: jsonData, encoding: .utf8) ?? "" } + private static var loggedUnsupportedTypes = Set() + private static func logUnsupported(_ type: String) { + guard loggedUnsupportedTypes.insert(type).inserted else { return } + NSLog("[NativeList] Unsupported row type '%@' rendered as an empty placeholder row", type) + } + private static let selectableTypes: Set = [ "identity", "rail", "activity", "message", "dataRow", "mediaTile", "metricCard", ] diff --git a/native-views/react-native-native-list/ios/NativeListRailRenderer.swift b/native-views/react-native-native-list/ios/NativeListRailRenderer.swift index 1422d2085..3883b05f0 100644 --- a/native-views/react-native-native-list/ios/NativeListRailRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListRailRenderer.swift @@ -88,9 +88,13 @@ final class NativeListRailCell: NativeListRendererCell { private var visual: NativeListLeadingVisual? private var dimensions: [NSLayoutConstraint] = [] override var defaultCornerRadius: CGFloat { 8 } + override var pressedCornerRadius: CGFloat? { 8 } override var assetFields: [String] { ["visual"] } override var showsSelection: Bool { false } - override var defaultSeparatorInset: CGFloat { 0 } + // Legacy Rail used a fixed pressed tint, independent of theme.rowPressedBackground. + override func pressedBackground(_ theme: [String: Any]?) -> UIColor { + UIColor(nativeListHex: "#F0F0F0", fallback: .lightGray) + } override init(frame: CGRect) { super.init(frame: frame) diff --git a/native-views/react-native-native-list/ios/NativeListRendererCell.swift b/native-views/react-native-native-list/ios/NativeListRendererCell.swift index 9b3647a7d..55fccf6c7 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererCell.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererCell.swift @@ -10,8 +10,15 @@ class NativeListRendererCell: NativeListRowHost { var defaultBorderWidth: CGFloat { 0 } func defaultBorderColor(_ theme: [String: Any]?) -> UIColor { .clear } var defaultCornerRadius: CGFloat { 0 } + /// Legacy pressed rows round all corners (12pt) while highlighted; nil opts out. + var pressedCornerRadius: CGFloat? { pressChangesBackground ? 12 : nil } + /// Radius for `groupPosition` rows when listStyle does not set one (legacy 12). + var groupCornerRadius: CGFloat { 12 } var defaultCornerCurve: CALayerCornerCurve { .circular } var pressChangesBackground: Bool { true } + func pressedBackground(_ theme: [String: Any]?) -> UIColor { + nativeListColor(theme, "rowPressedBackground", "#E8E8E8") + } var showsSelection: Bool { true } var defaultVerticalAlignment: String? { nil } var defaultSeparatorInset: CGFloat { 12 } @@ -29,12 +36,8 @@ class NativeListRendererCell: NativeListRowHost { { guard let old, old.key == new.key, old.rendererKey == new.rendererKey else { return .replace } if old.content == new.content { return .unchanged } - for field in assetFields { - if NativeListImageSlot.signature([field: old.data[field] ?? NSNull()]) - != NativeListImageSlot.signature([field: new.data[field] ?? NSNull()]) - { - return .assets - } + for field in assetFields where !nativeListValuesEqual(old.data[field], new.data[field]) { + return .assets } return .content } @@ -50,7 +53,8 @@ class NativeListRendererCell: NativeListRowHost { _ item: NativeListItem, theme: [String: Any]?, layout: String, itemIndex: Int? ) -> UIColor { nativeListColor(theme, "rowBackground", "#FFFFFF") } private(set) var selectedState = false - private var inputSignature = "" + // Last applied list-level inputs; compared structurally (no serialization/hashing per bind). + private var boundInputs: (theme: NSDictionary?, layout: String, listStyle: NSDictionary?)? override init(frame: CGRect) { super.init(frame: frame) @@ -81,10 +85,16 @@ class NativeListRendererCell: NativeListRowHost { selected: Bool, checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String ) { let update = update(from: self.item, to: item) - let signature = NativeListImageSlot.signature([ - "theme": theme ?? [:], "layout": layout, "listStyle": listStyle ?? [:], - ]) - if update != .unchanged || signature != inputSignature { + let inputs = ( + theme: theme.map { $0 as NSDictionary }, layout: layout, + listStyle: listStyle.map { $0 as NSDictionary } + ) + let inputsChanged = + boundInputs.map { + $0.layout != inputs.layout || !nativeListValuesEqual($0.theme, inputs.theme) + || !nativeListValuesEqual($0.listStyle, inputs.listStyle) + } ?? true + if update != .unchanged || inputsChanged { if self.item != nil { onBindingInvalidated?(self, bindingEpoch) } bindingEpoch &+= 1 if update == .replace { @@ -117,7 +127,7 @@ class NativeListRendererCell: NativeListRowHost { : root.topAnchor.constraint(equalTo: contentView.topAnchor, constant: contentInsets.top) contentPosition?.isActive = true } - inputSignature = signature + boundInputs = inputs } self.item = item self.theme = theme @@ -171,22 +181,28 @@ class NativeListRendererCell: NativeListRowHost { } contentView.backgroundColor = isHighlighted && pressChangesBackground - ? nativeListColor(theme, "rowPressedBackground", "#E8E8E8") : background + ? pressedBackground(theme) : background contentView.alpha = CGFloat(container.double("opacity", default: item.data.double("opacity", default: 1))) * (item.data.bool("disabled") ? 0.5 : 1) let position = item.data.string("groupPosition") let grouped = ["first", "last", "single"].contains(position) + let pressedRadius = + isHighlighted && isUserInteractionEnabled ? pressedCornerRadius.map(Double.init) : nil + let fallbackRadius = + defaultCornerRadius > 0 + ? Double(defaultCornerRadius) + : grouped + ? listStyle?.double("groupCornerRadius", default: Double(groupCornerRadius)) + ?? Double(groupCornerRadius) : 0 let radius = CGFloat( container.double( - "cornerRadius", - default: defaultCornerRadius > 0 - ? Double(defaultCornerRadius) - : grouped ? listStyle?.double("groupCornerRadius", default: 12) ?? 12 : 0)) + "cornerRadius", default: pressedRadius ?? fallbackRadius)) let top: CACornerMask = [.layerMinXMinYCorner, .layerMaxXMinYCorner] let bottom: CACornerMask = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] layer.maskedCorners = container["cornerRadius"] != nil || position == "single" || defaultCornerRadius > 0 + || pressedRadius != nil ? top.union(bottom) : position == "first" ? top : position == "last" ? bottom : [] layer.cornerCurve = defaultCornerCurve contentView.layer.cornerCurve = defaultCornerCurve @@ -232,9 +248,18 @@ class NativeListRendererCell: NativeListRowHost { if item != nil { onBindingInvalidated?(self, bindingEpoch) } bindingEpoch &+= 1 item = nil - inputSignature = "" + boundInputs = nil recycleContent() fullWidthBackground.removeFromSuperlayer() isHighlighted = false } } + +/// Structural equality for bridged JSON values (identity short-circuits in `isEqual`). +func nativeListValuesEqual(_ lhs: Any?, _ rhs: Any?) -> Bool { + switch (lhs, rhs) { + case (nil, nil): return true + case let (lhs?, rhs?): return (lhs as AnyObject).isEqual(rhs as AnyObject) + default: return false + } +} diff --git a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift index f891a2261..d0aad7e47 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererRegistry.swift @@ -18,6 +18,9 @@ enum NativeListRendererRegistry { .activity: (NativeListActivityCell.self, { NativeListActivityCell(frame: .zero) }), .system: (NativeListSystemCell.self, { NativeListSystemCell(frame: .zero) }), .action: (NativeListActionCell.self, { NativeListActionCell(frame: .zero) }), + .unsupported: ( + NativeListUnsupportedCell.self, { NativeListUnsupportedCell(frame: .zero) } + ), ] static func create(_ key: NativeListRendererKey) -> NativeListRowHost { hosts[key]!.create() } static func reuseIdentifier(for key: NativeListRendererKey) -> String { @@ -37,3 +40,11 @@ enum NativeListRendererRegistry { hosts[item.rendererKey]!.type.measure(item, width: width, theme: theme, layout: layout) } } + +/// Legacy fallback for unknown row types: shared chrome only, 56-point default height +/// (plus the size preset), no template content. +final class NativeListUnsupportedCell: NativeListRendererCell { + override class func measure( + _ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String + ) -> CGFloat? { 56 } +} diff --git a/native-views/react-native-native-list/ios/NativeListResolvedText.swift b/native-views/react-native-native-list/ios/NativeListResolvedText.swift index e163ea248..7becc7ce9 100644 --- a/native-views/react-native-native-list/ios/NativeListResolvedText.swift +++ b/native-views/react-native-native-list/ios/NativeListResolvedText.swift @@ -6,19 +6,26 @@ struct NativeListResolvedText { let color: UIColor let lineHeight: CGFloat let lines: Int - let alignment: NSTextAlignment + /// Semantic `start | center | end`; nil keeps the legacy `.natural` default. + let alignmentName: String? let breakMode: NSLineBreakMode let verticalAlignment: String? let offsetY: CGFloat let explicitLineHeight: Bool let explicitTruncation: Bool + let tabular: Bool + /// Keep the label's break mode in the attributed paragraph (legacy plain-text labels kept + /// their tail ellipsis; attributed paragraphs otherwise default to word wrapping). + let preservesBreakMode: Bool init( _ value: String, style: [String: Any]?, size: CGFloat, weight: NativeListFontWeight = .regular, color: UIColor, lineHeight: CGFloat, lines: Int, breakMode: NSLineBreakMode = .byTruncatingTail, - tabular: Bool = false + tabular: Bool = false, preservesBreakMode: Bool = false ) { let style = style ?? [:] + self.tabular = tabular + self.preservesBreakMode = preservesBreakMode self.lines = min(3, max(1, style.int("lines", default: lines))) explicitTruncation = style["lines"] != nil || style["truncate"] != nil text = @@ -42,9 +49,7 @@ struct NativeListResolvedText { (style["color"] as? String).map { UIColor(nativeListHex: $0, fallback: color) } ?? color self.lineHeight = CGFloat(style.double("lineHeight", default: Double(lineHeight))) explicitLineHeight = style["lineHeight"] != nil - alignment = - style.string("alignment") == "center" - ? .center : style.string("alignment") == "end" ? .right : .natural + alignmentName = style["alignment"] as? String self.breakMode = explicitTruncation ? (style.string("truncate", default: "tail") == "clip" @@ -53,12 +58,22 @@ struct NativeListResolvedText { offsetY = CGFloat(style.double("offsetY")) } + /// Start/end follow the host view's layout direction, as legacy `marketTextAlignment` did. + func alignment(for direction: UIUserInterfaceLayoutDirection) -> NSTextAlignment { + guard let alignmentName else { return .natural } + return NativeListTextStyles.marketTextAlignment(alignmentName, direction: direction) + } + var attributed: NSAttributedString { + attributed(direction: UIView.userInterfaceLayoutDirection(for: .unspecified)) + } + + func attributed(direction: UIUserInterfaceLayoutDirection) -> NSAttributedString { let paragraph = NSMutableParagraphStyle() paragraph.minimumLineHeight = lineHeight paragraph.maximumLineHeight = lineHeight - paragraph.alignment = alignment - if explicitTruncation { paragraph.lineBreakMode = breakMode } + paragraph.alignment = alignment(for: direction) + if explicitTruncation || preservesBreakMode { paragraph.lineBreakMode = breakMode } var attributes: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: color, .paragraphStyle: paragraph, ] @@ -69,23 +84,41 @@ struct NativeListResolvedText { } func bind(_ label: NativeListTextLabel) { + let direction = label.effectiveUserInterfaceLayoutDirection label.font = font label.textColor = color label.numberOfLines = lines - label.textAlignment = alignment + label.textAlignment = alignment(for: direction) label.lineBreakMode = breakMode label.rowVerticalAlignment = verticalAlignment label.rowOffsetY = offsetY - label.attributedText = attributed + label.attributedText = attributed(direction: direction) label.isHidden = text.isEmpty } + // Measurement runs for every row on every snapshot. It reuses one sizing label (the same + // UILabel metrics, line cap and truncation as the bound view) and memoizes by the inputs + // that affect height; alignment and color do not. + private static let sizingLabel = NativeListTextLabel() + private static let heightCache: NSCache = { + let cache = NSCache() + cache.countLimit = 4096 + return cache + }() + func measure(width: CGFloat) -> CGFloat { guard !text.isEmpty else { return 0 } - // UILabel uses the same attributed metrics and line cap as the bound view. - let label = NativeListTextLabel() + let width = max(1, width) + let key = + "\(width)|\(lines)|\(lineHeight)|\(explicitLineHeight)|\(explicitTruncation)|\(breakMode.rawValue)|\(preservesBreakMode)|\(font.fontName)|\(font.pointSize)|\(tabular)|\(text)" + as NSString + if let cached = Self.heightCache.object(forKey: key) { return CGFloat(cached.doubleValue) } + let label = Thread.isMainThread ? Self.sizingLabel : NativeListTextLabel() bind(label) - return ceil( - label.sizeThatFits(CGSize(width: max(1, width), height: .greatestFiniteMagnitude)).height) + let height = ceil( + label.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)).height) + if label === Self.sizingLabel { label.attributedText = nil } + Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: key) + return height } } diff --git a/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift b/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift index c16d184b8..a4845b7f2 100644 --- a/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift @@ -244,14 +244,6 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { if style["lineGap"] != nil { mainStack.spacing = CGFloat(style.double("lineGap")) } if style["trailingGap"] != nil { trailingStack.spacing = CGFloat(style.double("trailingGap")) } applyTextStyle(item) - if item.data.string("presentation") == "networkSelector", item.data["height"] != nil, - item.data.dictionary("checkbox") != nil, !item.data.string("value").isEmpty - { - let width = trailingStack.widthAnchor.constraint( - equalToConstant: accessoryButtons[0].intrinsicContentSize.width + 12 + 20) - width.isActive = true - selectorConstraints.append(width) - } contentInsets = UIEdgeInsets( top: topInset, left: leftInset, bottom: -bottomInset, right: -rightInset) root.alignment = @@ -260,6 +252,15 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { : style.dictionary("container")?.string("contentVerticalAlignment") == "bottom" ? .bottom : .center applySelectorTypography(item) + // Legacy measured the value after selector typography (tabular digits, kern 0). + if item.data.string("presentation") == "networkSelector", item.data["height"] != nil, + item.data.dictionary("checkbox") != nil, !item.data.string("value").isEmpty + { + let width = trailingStack.widthAnchor.constraint( + equalToConstant: accessoryButtons[0].intrinsicContentSize.width + 12 + 20) + width.isActive = true + selectorConstraints.append(width) + } } private func applyTextStyle(_ item: NativeListItem) { let style = item.data.dictionary("style") ?? [:] diff --git a/native-views/react-native-native-list/ios/NativeListSystemRenderer.swift b/native-views/react-native-native-list/ios/NativeListSystemRenderer.swift index c7565954f..ebab5d58a 100644 --- a/native-views/react-native-native-list/ios/NativeListSystemRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListSystemRenderer.swift @@ -332,6 +332,8 @@ final class NativeListSystemCell: NativeListRendererCell { show(subtitleLabel, item.data.string("message"), lines: 0) setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20) setLineHeight(subtitleLabel, text: item.data.string("message"), lineHeight: 20) + // Legacy applied selector typography (tabular digits, kern 0) to warnings. + [titleLabel, subtitleLabel].forEach(NativeListTextStyles.applyTabularDigits) let borderColor = UIColor( nativeListHex: item.data.string("borderColor", default: "#E0E0E0"), fallback: .lightGray) for top in [true, false] { @@ -538,6 +540,8 @@ enum NativeListSystemRenderer { .paragraphStyle: paragraph, ], context: nil ).height / lineHeight) + // Legacy: an empty title/message contributes no line. + guard !item.data.string(key).isEmpty else { return 0 } return min(CGFloat(textStyle?.int("lines", default: Int.max) ?? Int.max), max(1, measured)) * lineHeight } diff --git a/native-views/react-native-native-list/ios/NativeListTextStyles.swift b/native-views/react-native-native-list/ios/NativeListTextStyles.swift index 4c8688ad6..9db68a267 100644 --- a/native-views/react-native-native-list/ios/NativeListTextStyles.swift +++ b/native-views/react-native-native-list/ios/NativeListTextStyles.swift @@ -1,3 +1,4 @@ +import CoreText import UIKit // Apply semantic overrides after a renderer has restored its resolved defaults. @@ -195,6 +196,33 @@ enum NativeListTextStyles { } } + /// Legacy selector/warning typography: monospaced digits and zero kerning on the label. + static func applyTabularDigits(_ label: UILabel) { + guard let font = label.font else { return } + label.font = tabularDigitsFont(font) + guard let original = label.attributedText else { return } + let result = NSMutableAttributedString(attributedString: original) + let range = NSRange(location: 0, length: result.length) + result.addAttribute(.kern, value: 0, range: range) + original.enumerateAttribute(.font, in: range) { value, range, _ in + if let font = value as? UIFont { + result.addAttribute(.font, value: tabularDigitsFont(font), range: range) + } + } + label.attributedText = result + } + + static func tabularDigitsFont(_ font: UIFont) -> UIFont { + var settings = + font.fontDescriptor.fontAttributes[.featureSettings] as? [[UIFontDescriptor.FeatureKey: Int]] + ?? [] + settings.removeAll { $0[.type] == kNumberSpacingType } + settings.append([.type: kNumberSpacingType, .selector: kMonospacedNumbersSelector]) + return UIFont( + descriptor: font.fontDescriptor.addingAttributes([.featureSettings: settings]), + size: font.pointSize) + } + static func marketTextAlignment(_ value: String, direction: UIUserInterfaceLayoutDirection) -> NSTextAlignment { diff --git a/native-views/react-native-native-list/ios/NativeListWalletGroupRenderer.swift b/native-views/react-native-native-list/ios/NativeListWalletGroupRenderer.swift index 616fe2738..8bf81d353 100644 --- a/native-views/react-native-native-list/ios/NativeListWalletGroupRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListWalletGroupRenderer.swift @@ -36,7 +36,20 @@ final class NativeListWalletGroupCell: NativeListRendererCell, UIGestureRecogniz item.styledHeight ?? (item.data["height"] != nil ? CGFloat(item.data.double("height")) - : NativeListIdentityCell.measure(item, width: 0, theme: nil, layout: "linear") ?? 68) + // Legacy member default is independent of the member's presentation. + : item.data.dictionaries("badges").isEmpty ? 68 : 92) + } + /// Legacy default: a one-point inset (source group border) when the parent carries an + /// explicit height, otherwise none. `style.*Padding` overrides it; bind and measure share it. + private static func padding(_ item: NativeListItem, members: [NativeListItem]) -> ( + horizontal: CGFloat, vertical: CGFloat + ) { + let style = item.data.dictionary("style") ?? [:] + let inset: Double = members.first?.data["height"] != nil ? 1 : 0 + return ( + CGFloat(style.double("horizontalPadding", default: inset)), + CGFloat(style.double("verticalPadding", default: inset)) + ) } override class func appliesSizePreset(_ item: NativeListItem) -> Bool { false } override class func measure( @@ -44,7 +57,7 @@ final class NativeListWalletGroupCell: NativeListRendererCell, UIGestureRecogniz ) -> CGFloat? { let members = memberItems(item) return members.reduce( - CGFloat(max(0, members.count - 1) * 12 + (members.first?.data["height"] != nil ? 2 : 0)) + CGFloat(max(0, members.count - 1) * 12) + padding(item, members: members).vertical * 2 ) { $0 + memberHeight($1) } } override init(frame: CGRect) { @@ -94,10 +107,7 @@ final class NativeListWalletGroupCell: NativeListRendererCell, UIGestureRecogniz root.removeArrangedSubview(view.cell) view.cell.removeFromSuperview() } - let style = item.data.dictionary("style") ?? [:] - let inset: Double = members.first?.data["height"] != nil ? 1 : 0 - let horizontal = CGFloat(style.double("horizontalPadding", default: inset)) - let vertical = CGFloat(style.double("verticalPadding", default: inset)) + let (horizontal, vertical) = Self.padding(item, members: members) contentInsets = UIEdgeInsets( top: vertical, left: horizontal, bottom: vertical, right: horizontal) for (index, member) in members.enumerated() { @@ -187,7 +197,8 @@ final class NativeListWalletGroupCell: NativeListRendererCell, UIGestureRecogniz override func canStartWalletGroupReorder(at point: CGPoint) -> Bool { let point = root.convert(point, from: self) for member in members where memberViews[member.key]?.cell.frame.contains(point) == true { - return (member.data["draggable"] as? Bool) != false && !member.data.bool("disabled") + // Legacy: only `draggable: false` excludes a member; disabled members may start a drag. + return (member.data["draggable"] as? Bool) != false } return true } diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index 046fc6984..c8dadab95 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -1297,11 +1297,14 @@ final class NativeListView: UIView { private func syncSectionIndexToVisibleRows() { guard !sectionIndexScrubbing, !sectionIndexEntries.isEmpty else { return } let offset = collectionView.contentOffset.y + collectionView.adjustedContentInset.top + // Legacy semantics: a header at the first position is active even while overscrolled; + // no entry is active while rows above the first header are still visible. let index = sectionIndexEntries.lastIndex { + if $0.position == 0 { return true } guard let attributes = flowLayout.layoutAttributesForItem(at: IndexPath(item: $0.position, section: 0)) else { return false } return attributes.frame.minY <= offset + 1 } - sectionIndexView.setActiveIndex(index ?? 0) + sectionIndexView.setActiveIndex(index) } private func configureRefresh(_ config: NativeListConfig) { From cfb34b7960b87e4d531a3381ba7426a936bb4819 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 10:46:23 +0800 Subject: [PATCH 64/71] fix(native-list/web): restore legacy rendering and sync spec - Keep loaded MetricCard/System images visible when inline defaults are restored on rebind. - Include WalletGroup vertical padding in measurement; restore legacy DataRow heights (table 48) and linear structure. - Reset container alignment styles on reuse; enable selector geometry for style.container.height; drop per-update getComputedStyle. - Restore legacy Web disabled wallet-member actions and non-market retry (no button, row press retries). - Validate listStyle.separator.color as #RRGGBB/#RRGGBBAA. - CHANGELOG: add Breaking Changes (implicit key styling removed, automatic Message heights, strict style validation) and drop stale entries. - SPEC/STYLE_SPEC/ROW_TEMPLATES/DESIGN/README: document platform differences, limits and the legacy-default audit. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 +- .../react-native-native-list/README.md | 18 +- .../react-native-native-list/docs/DESIGN.md | 47 +- .../docs/ROW_TEMPLATES.md | 38 +- .../react-native-native-list/docs/SPEC.md | 95 +++- .../docs/STYLE_SPEC.md | 74 ++- .../src/__tests__/NativeList.web.test.ts | 458 +++++++++++++++++- .../src/__tests__/validation.test.ts | 100 ++++ .../react-native-native-list/src/models.ts | 21 +- .../src/validation.ts | 8 + .../src/web/NativeListWebEngine.ts | 14 +- .../src/web/templates/ActionRowRenderer.ts | 4 +- .../src/web/templates/DataRowRenderer.ts | 24 +- .../src/web/templates/IdentityRowRenderer.ts | 16 +- .../web/templates/MetricCardRowRenderer.ts | 24 +- .../src/web/templates/RowContainerStyle.ts | 92 +++- .../src/web/templates/RowElements.ts | 52 ++ .../src/web/templates/RowText.ts | 21 +- .../web/templates/SectionHeaderRowRenderer.ts | 8 +- .../src/web/templates/SystemRowRenderer.ts | 22 +- .../web/templates/WalletGroupRowRenderer.ts | 9 +- 21 files changed, 1017 insertions(+), 140 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2295b9663..fd5a3f625 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,17 @@ All notable changes to this project will be documented in this file. ### Features - **native-list**: Add `snapshot.listStyle` for list chrome: `separator.inset`, `separator.color`, and `groupCornerRadius`, applied on all three platforms. The surface carries only what every platform can honour — pull to refresh is a system control on both native platforms, the section index belongs to `capabilities.sectionIndex`, and the reorder preview is drawn with platform-specific primitives — and `validateSnapshot` rejects any other key rather than accepting one that some platform would ignore. Every value is absent by default and each platform keeps its own number as the fallback, so an untouched list renders exactly as before; on Web the inset separator keeps a transparent border for layout and paints the line with a logical-inset overlay, so row heights do not move. -- **native-list (iOS and Android)**: Apply the row style natively. A style key is mapped to the view that renders that model field by `nativeListStyleSlot` (Kotlin, unit-tested) and `styleSlot` (Swift), because the view pool is shared and the mapping is not one to one. Both platforms gained a `resetRowStyle` that runs before the binder: Android's `resetViews` restores neither `textSize`, `typeface` nor `lineHeight`, and iOS's `reset` misses the data, metric and media label fonts, so without it a style would leak into the next row reusing the view. On iOS the pass rebuilds the attributed string, because the binders install one through `setLineHeight` and a plain `font` assignment would not take effect. -- **native-list**: Add a bounded per-template row style. `style` keys name the model field they modify rather than the view that carries it, so `metricCard.style.value` reaches the large number even though it shares a view with `identity.style.title`. Typography can be given as a named step (`{ token: '$bodyLg' }`) which is resolved to numbers in JavaScript before serialization, so no native renderer learns the vocabulary; explicit values still override. Keys a template does not declare are rejected by `validateSnapshot` and `validatePatches`, and styles carried by a patch are resolved the same way. Text slots and template-applicable image/padding/gap parameters are implemented on Web, iOS and Android. Row heights are unchanged — a styled row that grows still needs an explicit `height`. +- **native-list**: Render every row through a closed renderer registry on iOS, Android and Web. Each of the 12 templates (Message, Rail, MediaTile, Action, System, Activity, DataRow, MetricCard, Market, Identity, SectionHeader and WalletGroup) owns its view tree, defaults, style mapping, binding and measurement; a shared host owns container appearance, events and binding invalidation. Renderer reuse is keyed by template family, so a same-key template change replaces an incompatible host, and every bind restores the current template's defaults without a preceding recycle. WalletGroup members are independent Identity renderers. The legacy native row monoliths, the global style-slot maps and the Web legacy pool are removed. See `native-views/react-native-native-list/docs/SPEC.md`. +- **native-list**: Add a bounded per-template row style. `style` keys name the model field they modify rather than the view that carries it, and each template renderer maps the key to the view that renders that field. Typography can be given as a named step (`{ token: '$bodyLg' }`) which is resolved to numbers in JavaScript before serialization, so no native renderer learns the vocabulary; explicit values still override. Keys a template does not declare are rejected by `validateSnapshot` and `validatePatches` (see Breaking Changes), and styles carried by a patch are resolved the same way. Text slots, template-applicable image/padding/gap parameters and a shared `style.container` (explicit `height`, background, opacity, corner radius, border and content alignment) are implemented on Web, iOS and Android. Height precedence is `style.container.height` → `row.height` → template measurement, and selector presentations use their explicit-height geometry whichever field supplies the height; without an explicit height, measured templates may change height with their styled metrics. Automatic overflow fitting remains the integrator's responsibility. - **image**: New `resizeHeight` view prop, the pair of `resizeWidth`, so a non-square image can hand the pre-layout probe the same hints its preload used. +### Breaking Changes +- **native-list**: Remove implicit row-key styling; callers now choose `variant`, `style` and `separator` explicitly. A `history-` prefix on a section header's row key or section key no longer selects history typography or the 16-unit header height (use `variant: 'history'`). Android no longer gives the `linear-tokens` and `action-tokens` section headers token-manager typography, padding and 30-unit height (declare them with `style`). Android no longer suppresses `separator: true` for `token-*`, `balance-token-*` and `linear-custom-token` rows. The app-monorepo callers audited in the package `docs/MIGRATION_BASELINE.md` already use the explicit fields. +- **native-list**: Automatic (intrinsic) Message row heights change: measurement now uses the resolved text, spacing and image metrics of the rendered views instead of the old estimator-only defaults. Rows with `row.height` or `style.container.height` are unaffected. +- **native-list**: Row style validation is strict. `validateSnapshot` and `validatePatches` throw for any `style` key a template does not declare, for unknown nested text/image/container keys, and for unknown Market badge style keys, where extra keys used to be ignored. Colors must be `#RRGGBB` or `#RRGGBBAA`, including `listStyle.separator.color`. Every key sent by the app-monorepo Market list builder (`marketNativeListRows.ts`) still validates. + ### Bug Fixes +- **native-list**: Review fixes for the renderer migration. Restore the legacy unstyled rendering of every template inside the new renderers (for example native linear DataRow single-label columns, pressed-row corners, Activity/Identity leading slots, and WalletGroup appearance and drag rules), without restoring key-based styling. Fix an Android sticky-header overlay crash and a leaked pre-draw listener on detached lists. On Web, keep loaded MetricCard images visible after rebinding, restore the legacy message-only non-Market retry row and disabled WalletGroup member actions, include WalletGroup vertical padding in its height, and stop container alignment leaking into reused rows. On iOS, make `end` text alignment follow RTL layout and cache Message text measurement to cut its per-bind cost. - **native-list**: Align the configurable row-style contract across Web, iOS and Android. Apply non-Market image/spacing overrides; isolate primary and secondary data-column text in linear/table layouts; preserve styles through selection and summary updates; reuse the ordinary Android row renderer for pinned headers including values and checkboxes. Reject template-inapplicable box keys and nonportable text colors. Keep template structure, local styling and shared list capabilities distinct; overflow fitting remains caller-owned. - **native-list**: Apply Web selector styles after presentation defaults, isolate wallet-member styles, and target composite metric-card headings by their semantic role. Restore native text state on reuse while retaining unspecified iOS attributed-string properties. Propagate Android list chrome on initial snapshots and restrict custom group radius to actual grouped rows. Reject malformed nested text/image styles and unknown nested keys; developers integrating the list own overflow prevention. - **image (iOS)**: Show a memory-cached image on the mount frame instead of an icon skeleton (OK-63873). The memory probe skipped every image whose JS side left `cachePolicy` unset (Fabric hands Nitro a nil, which the request path already treats as memory-disk), and before layout it keyed the decode thumbnail from the empty bounds, so a freshly mounted image never matched what the prewarm or a previous mount had stored. The probe now coalesces a nil policy to memory-disk and, before layout, tries the decode-thumbnail keys the laid-out request (`resizeWidth`/`resizeHeight` hint + the view's `contentFit`) and a preload (hint + cover) would use. An image already on screen for the same identity is kept as the placeholder while a load for another key runs, instead of being blanked to a skeleton. @@ -22,7 +28,7 @@ All notable changes to this project will be documented in this file. - **native-list**: Add `docs/STYLE_SPEC.md`, the shared style vocabulary for rows, section headers, fixed footers, and empty states. It records the design tokens (aliased to the application's own token names), the per-template style surface keyed by model field, list chrome, the template isolation rules, and a review checklist. Legacy row-height, typography and Android baseline scaling differences are documented separately from the unified configurable style contract. ### Chores -- Add the "Native List Row Style" example page with plain/styled pairs for all 12 row types, plus list chrome and explicit-height examples. Variant coverage and native visual acceptance remain pending; see `docs/STYLE_SPEC.md`. +- Add the "Native List Row Style" example page with plain/styled pairs for all 12 row types, plus list chrome and explicit-height examples. Dated per-stage runtime acceptance of the renderer migration on iOS, Android and Web is recorded in the package `docs/DESIGN.md`; it is not a pixel-parity or scrolling-performance signoff. - **native-list (Web)**: Remove dead duplicated rules from `WEB_LIST_CSS`. Four blocks (`.ok-native-list-footer`/`-sticky`/`-index-rail`/`-index-button`, `-refresh`, `-warning`, `-subtitle-segments`) were emitted twice and a `prefers-reduced-motion` block three times; every property of the earlier copies was redeclared by the later ones, so removing them changes no rendering. - Stop tracking `nitrogen/generated` for `react-native-image` and `react-native-native-list`. Every other Nitro package already ignores `nitrogen/` and regenerates it from the spec at release time; these two were missing a package `.gitignore`, so 117 generated files rode along in every diff that touched a prop. `react-native-native-list` now runs `yarn nitrogen` in its `release` script like the other view packages, and local builds need `yarn nitrogen` in the package before the first native build. - No version bump here; these changes ship with the next release. Note that 3.0.157 was published from this branch before the review fixes landed and should not be used. diff --git a/native-views/react-native-native-list/README.md b/native-views/react-native-native-list/README.md index 8a7425265..d9a3dd476 100644 --- a/native-views/react-native-native-list/README.md +++ b/native-views/react-native-native-list/README.md @@ -209,10 +209,20 @@ scrolls. The optional diagnostic action fires when a row binds an image; quote- only patches (`price`, `priceSegments`, `change`, `accessibilityLabel`, and `revision`) update visible labels without rebinding that image. -`style` is deliberately finite: row padding/gaps, image width/height/shape/ -corner radius/content fit, title/subtitle/price/change font size, weight, -color, line height, one-or-two-line truncation and alignment, plus change-block -width/height/radius. Omitting it preserves the defaults. Loading, empty, retry, +`style` is deliberately finite and uses the shared row-style vocabulary in +[docs/STYLE_SPEC.md](docs/STYLE_SPEC.md): `horizontalPadding`, +`verticalPadding`, `leadingGap`, `lineGap`, `titleBadgeGap`, `trailingGap`, +the Market-only `titleBadgeLayout: 'inline'`, `contentTrailingGap` and +`subtitleTrailingPadding`, the primary `image` (width/height/shape/corner +radius/content fit), `title`/`subtitle`/`price`/`change` text styles (`token`, +font size, weight, `#RRGGBB`/`#RRGGBBAA` color, line height, 1–3 `lines`, +`truncate`, alignment, vertical alignment and `offsetY`), the change block's +`changeWidth`/`changeHeight`/`changeCornerRadius`, and the common +`style.container` (explicit height, background, opacity, radius, border and +content alignment). Badge `style` accepts only `fontSize`, `fontWeight`, +`lineHeight`, `height` and `horizontalPadding`. Unknown keys at any level are +rejected by `validateSnapshot`/`validatePatches` rather than ignored. Omitting +`style` preserves the defaults. Loading, empty, retry, and pagination states use the existing `system` rows with `presentation: 'market'`. Loading rows accept `loadingStyle: 'skeleton'` for the 56-point token skeleton or `'spinner'` for the centered 52-point pagination diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index 967e2ff43..9b1884b72 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -383,7 +383,13 @@ the legacy table allocations, dispatch and style paths. The dedicated iOS/Android simulators and headed Chrome exercised selection (callback counter increment), linear/table switching, style set/clear, end/top scrolling and empty/repopulate with family replacement. Native default -captures preserve column geometry and existing truncation. The Web regression +captures preserve column geometry and existing truncation for the **table** +layout only; the 2026-09-24 audit found that native linear DataRows had been +moved to the table column structure (pill badges, separate secondary lines) instead of +the legacy single label with inline badges. The same audit restored the legacy +Web linear structure (secondary leading text inline before the primary text, +secondary text directly below) and the 48-unit Web table height for rows +without secondary text. The Web regression changes three columns to two and back, clearing secondary labels and styles while retaining the unchanged image. Native builds and Android unit tests pass; all 158 package tests and typecheck pass. Evidence: external runtime @@ -504,8 +510,12 @@ member defaults/badges keep the previous measurement. The list continues to own reorder activation, destination selection, scroll and final order events. Native compact rendering uses a separate Identity parent and `+N` badge; Web uses the existing leased preview. Only children whose draggable is not false count toward -`+N`; non-draggable and disabled members cannot initiate a drag. Drop restoration +`+N`; non-draggable members cannot initiate a drag. (Correction, 2026-09-24: +this stage also blocked disabled members; the audit restored the legacy rule on +all platforms, so only `draggable: false` excludes a member.) Drop restoration uses the current configured height/appearance rather than default chrome. +The compact allocation is 68 on iOS and Web; Android keeps its legacy +`max(68, parent member height + 2 × group vertical inset)`. Member event origins retain the nested Identity host and its binding epoch. Removing/rebinding a member invalidates its previous action anchor. The iOS @@ -513,8 +523,12 @@ member tap recognizer ignores UIControls so menu actions do not also emit a row press. Native WalletSidebar now binds optional trailing controls, matching Web; callers allocate sufficient height for these controls. Member selection visuals continue to follow each Identity's selected field. This migration does not add -nested members to the list's existing top-level selection domain. Web now gates -disabled member actions as well as group-level disabled state. +nested members to the list's existing top-level selection domain. Group-level +disabled state gates member actions on Web. (Correction, 2026-09-24: this stage +also gated disabled members on Web; the audit removed that non-legacy gate, so a +disabled member in an enabled group keeps its actions.) Android keeps its +legacy rule that a disabled group does not block member actions; the resulting +per-platform differences are listed in SPEC.md. Acceptance ran on the task's independent external-drive iOS 26.5 simulator, Android API 36 emulator and headed Chrome. Member press/menu and updated action @@ -582,3 +596,28 @@ results and iOS drag recordings are saved under the validation runtime's `stage6 directory. This completes all six architecture stages, with the two explicit compatibility boundaries above. It is functional/visual simulator acceptance, not a scrolling-performance benchmark or a full app-monorepo release signoff. + +### Legacy-default audit (2026-09-24) + +A review of the completed migration compared every renderer with the +pre-migration engines and restored legacy unstyled rendering inside the +renderer structure; implicit key-based styling stays removed. Notable outcomes: + +- Native DataRow linear rows use the legacy single-label column again (see + ROW_TEMPLATES.md); Web restored its own legacy linear structure. +- Pressed feedback, WalletGroup appearance, separator insets, disabled-row + action rules and the System retry label are documented as retained platform + differences in SPEC.md. +- Android's pinned-header pre-draw listener is registered only while the list + is attached, on the captured view-tree observer, and removed on detach, so + detached lists no longer leak it. +- iOS caches measured Message text heights per resolved text/width input in a + process-wide `NSCache` bounded to 4,096 entries (system eviction), which + avoids repeating text layout for unchanged rows during scrolling. This is a + cost reduction, not a measured scrolling benchmark. +- Web: MetricCard keeps loaded images visible across rebinding, WalletGroup + height includes group vertical padding, container alignment no longer leaks + into reused rows, and selector geometry follows either explicit height field. + +Web validation is jsdom/unit level (package typecheck and tests); native +validation for this round is reported with the native changes. diff --git a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md index 577475c3f..1497a6d1e 100644 --- a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md +++ b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md @@ -57,6 +57,12 @@ separately from source implementation in STYLE_SPEC §9. - **Variants:** default horizontal row; `accountSelector` and `networkSelector` preserve selector control geometry; `walletSidebar` uses a compact, centered wallet presentation. A variant is explicit model data, never inferred from font size. + Selector control geometry applies when the row has an explicit height from + either `style.container.height` or the legacy `row.height`. +- **Separator:** the default native separator inset is 60 for identity rows + and 12 for other templates (Web 0). +- **Platform default:** without `subtitleLines` or `style.subtitle.lines`, the + subtitle shows one line on iOS and Web but two on Android (STYLE_SPEC §6.2). - **Text style roles:** `title`, `subtitle`, `tertiary`, `badge`, `value`, `valueSecondary`. The last two refer to value accessories in `trailing`; `badge` refers to badge text. `value`/`valueSecondary` select the first/second value @@ -76,9 +82,17 @@ separately from source implementation in STYLE_SPEC §9. - **Required data:** `parent: IdentityRow`, nonempty `children: IdentityRow[]`. Every member must use `presentation: 'walletSidebar'`; `parent.key` must equal the group row's `key`, and member keys must be unique within the group. +- **Default appearance:** the group border is drawn beneath member content. + Native groups ignore the row-level `backgroundColor`, while legacy Web still + applies it. `style.container` background, border and radius override the + group fill, border and radius on every platform. +- **Drag:** only `draggable: false` excludes a member from starting the group + drag; disabled members can start it. The compact allocation is 68 on iOS/Web + and `max(68, parent height + 2 × vertical inset)` on Android. - **Style surface:** group `style` exposes the shared container appearance and horizontal/vertical padding. Text styles belong to `parent.style` or the individual `children[i].style`; there is no inherited - group `title`/`value` text style. + group `title`/`value` text style. Measured group height is the sum of member + heights, 12-unit member gaps and twice the group `verticalPadding`. - **Member lifecycle:** each member uses an independent Identity renderer, reused by member key. Removed members release their images/actions immediately. Press and accessory events use the member key; group dragging remains atomic. @@ -149,7 +163,23 @@ separately from source implementation in STYLE_SPEC §9. semantic roles, not arbitrary per-native-label selectors. - **Layout boundary:** column order/count/weights are data, shared table column alignment is layout. Text styles cannot change those or move favorite/checkbox - controls into a data column. + controls into a data column. Default heights are 56, or 60 with secondary + text; Web table rows without secondary text are 48. +- **Linear layout per platform (legacy defaults):** + - *iOS and Android, unstyled:* one label per column holding the primary text, + up to two inline badge runs and the secondary text on a second line. + `secondaryLeadingText` is not rendered. On Android, `disabled`/`caution` + tones render as primary text (legacy tone mapping). + - *iOS, styled:* the same single label. `columns`/`columnSecondary` apply + font, weight, color, line height and alignment to their text runs/lines; + `lines`, `truncate`, `verticalAlignment` and `offsetY` act on the whole + label; `titleBadgeGap` replaces the legacy two-space badge lead-in. + - *Android, styled (any non-empty `style`):* the separate column view is used + (primary line with pill badges, secondary line with secondary leading text), + so each role styles its own view. + - *Web:* `secondaryLeadingText` precedes the primary text on the same line, + badges follow it, and secondary text is directly below. + - Table layout uses the column view on every platform. - **Style isolation:** primary and secondary text use independent style targets in linear and table layouts on all three platforms. Column badges keep their own typography. @@ -250,7 +280,9 @@ separately from source implementation in STYLE_SPEC §9. action text; `warning` requires title/message; `noMatch` requires message; `end` has optional message; `spacer` requires explicit height. - **Text style roles:** `title` for a visible title, `message` for status copy, - `actionText` for retry text. A spacer has no text slot; skeleton/spinner styling + `actionText` for the retry button text. On Web only the Market retry has a + button; a non-Market Web retry is message-only (the row press retries), so + `actionText` has no target there. A spacer has no text slot; skeleton/spinner styling does not become arbitrary drawing through these text roles. - **Layout boundary:** keep each variant's indicator/text/action structure and bounded height. Styling cannot convert loading into retry, turn a spacer into diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index 932fcaf30..056d1e7c5 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -30,14 +30,26 @@ imperative API; [STYLE_SPEC.md](STYLE_SPEC.md) defines normalization, omitted style defaults and platform differences. Height precedence is `style.container.height` → `row.height` → template measurement/default. Removing a style restores the current template's defaults, not the previous -row's appearance. This migration does not change the TypeScript/Nitro protocol. +row's appearance. Selector presentations (`accountSelector`, `networkSelector`, +`walletSidebar`) use their explicit-height geometry whenever either +`style.container.height` or `row.height` is set. + +The row style surface (`row.style`, including `style.container`) and +`snapshot.listStyle` are **new public TypeScript/JSON protocol in this change**; +they travel inside the existing `snapshotJson` string, so the Nitro schema is +unchanged. Validation of that surface is strict: undeclared or unknown nested +keys are rejected (see Failures below and STYLE_SPEC.md §2). The renderer +migration itself does not change any other protocol field. ## Lifecycle and concurrency Full binding must work without a preceding recycle callback, including style set/change/clear and content A → B. A binding epoch invalidates stale image callbacks and action anchors. Recycling/disposal releases pending work -through the existing image/host lifecycle. UI mutation remains on the platform +through the existing image/host lifecycle. Image loading visibility belongs to +the image lifecycle: restoring template defaults on rebind must never hide an +image that already loaded. Common container styling clears what it wrote on the +next bind, including properties it set on template children. UI mutation remains on the platform UI thread; [DESIGN.md](DESIGN.md) describes update and event ownership. ## Data, cache and identity @@ -61,8 +73,12 @@ engine. See [DESIGN.md](DESIGN.md) for their current ownership boundaries. Snapshot validation rejects malformed/duplicate-key input without partial application. Template field/array bounds and image fallback behavior are defined in [ROW_TEMPLATES.md](ROW_TEMPLATES.md), [README.md](../README.md) and -[DESIGN.md](DESIGN.md). Existing normalization remains authoritative; this -migration adds no new input limits, retry policy or automatic layout correction. +[DESIGN.md](DESIGN.md). This change adds the style surface's limits, defined in +[STYLE_SPEC.md](STYLE_SPEC.md) §3.3/§4/§5: numeric bounds, `#RRGGBB`/`#RRGGBBAA` +colors (including `listStyle.separator.color`), `groupCornerRadius` `0…40`, and +rejection of any undeclared or unknown nested style key by `validateSnapshot` +and `validatePatches`, where extra keys used to be ignored. It adds no retry +policy or automatic layout correction. ## Performance and resources @@ -166,7 +182,8 @@ No migrated simple template dispatches through a legacy binder. Market, Identity and SectionHeader move to dedicated registered renderers on all three platforms. This stage preserves existing defaults and implicit-key -compatibility recorded in MIGRATION_BASELINE.md. It adds no public style fields. +compatibility recorded in MIGRATION_BASELINE.md. It adds no style fields beyond +the style surface this change introduces. Market owns quote-only binding and its platform row interactions; Identity owns selection presentation; SectionHeader owns stable summary updates and measurement. Sticky placement and header/footer routing stay in the list container. @@ -178,8 +195,8 @@ Each complex renderer now owns its view tree, defaults, semantic style slots and measurement. Native hosts share bounded leading/image/accessory primitives. Web retains the compatible body and unchanged image elements; loaded image state is not reset when geometry is restored. Quote and summary updates keep the latest -row model for subsequent action/selection dispatch. No new public API or Nitro -schema is introduced. Stage 5 below also replaces the native WalletGroup composite tree. Stage 6 below removes the +row model for subsequent action/selection dispatch. Stage 4 introduces no public +API or Nitro schema beyond the style surface described above. Stage 5 below also replaces the native WalletGroup composite tree. Stage 6 below removes the obsolete native classes and maps. Stage 4 validation: package typecheck and all 161 tests in five suites pass; @@ -200,9 +217,12 @@ compact parent on native). Recycling releases every member and compact parent. Acceptance covers member press/accessory routing, disabled and pressDisabled members, selection-only changes, style set/change/clear, member reorder/removal, -family replacement and reuse. Native and Web drag previews keep the existing -68-point compact allocation; `+N` counts children whose draggable is not false. -A non-draggable or disabled member cannot initiate group drag. Drop/cancel restore the +family replacement and reuse. The compact drag allocation is 68 on iOS and Web; +Android keeps its legacy `max(68, parent member height + 2 × group vertical +inset)` (for example 94 for a badged parent with an explicit height). +`+N` counts children whose draggable is not false. Only `draggable: false` +prevents a member from initiating group drag; a disabled member can still start +it (legacy behavior on all three platforms). Drop/cancel restore the resolved group height and all configured member/container styles. Reorder orchestration stays in the list container. Existing platform defaults stay unchanged. Verify on the dedicated external-drive simulators and headed Web. @@ -227,8 +247,10 @@ NativeList checks, not a consumer-app release or scrolling-performance signoff. The native monoliths and global style-slot maps are deleted. Their still-used fonts, icons, layout helpers, action origins and controls remain shared primitives. -All twelve registry families are explicit; unsupported native model types are -rejected during parsing. Footer initialization uses a registered Action host. +All twelve registry families are explicit. JavaScript validation rejects an +unknown row type before serialization. If one still reaches native code, iOS +keeps its legacy behavior: an empty 56-point placeholder row with shared chrome +and a single log message. Android rejects the snapshot while parsing it. Footer initialization uses a registered Action host. Web has no legacy pool or warning-specific container measurement path; template renderers own selector typography and WalletGroup compact-preview lookup. @@ -244,3 +266,52 @@ Action/System footer replacement and callbacks, scrolling/reuse and WalletGroup member actions and compact dragging. Source/type checks and runtime evidence are recorded separately in DESIGN.md. This completes the architecture migration; it does not add automatic overflow fitting or claim a performance benchmark. + +### Web legacy-default audit (2026-09-24) + +The renderer structure is retained; these legacy Web defaults are restored +inside it. MetricCard/System default restoration keeps image loading visibility +(a loaded image stays visible after content or style rebinding). WalletGroup +measurement adds the group's `style.verticalPadding` to the legacy member sum; +walletSidebar members keep their legacy 68/92 presets, which never applied the +`size` adjustment. The shared container pass records and clears its own inline +writes, so alignment set on template children does not leak into a reused, +unstyled row. DataRow measurement is layout-aware again (table rows without +secondary text are 48) and linear columns keep the legacy structure: secondary +leading text inline before the primary text, secondary text directly below. +Selector geometry follows either explicit height field. A disabled WalletGroup +member can start the group drag and emit its own actions again, and a +non-Market System retry is message-only with a row-press action, as in the +legacy engine. Text styling no longer +reads computed style, so attached and detached rows style identically. +Validation: package typecheck and 173 tests pass (jsdom/unit level); this is +not new rendered browser or native device evidence. + +### Platform interaction and appearance differences (legacy, retained) + +These are pre-existing platform behaviors recorded after the 2026-09-24 native +audit; they are not new in this change. + +- **Disabled rows and actions.** iOS blocks every action on a disabled row + (cell interaction off plus an action-emission guard). Android blocks text + accessories (radio, switch, `⋮` menu), checkboxes, Activity footer buttons and + System Retry, but still emits trailing/leading icon actions, Market badges, + media close, title help and WalletGroup member actions; a disabled group does + not block its members' actions on Android. Web (legacy) blocks row press and + actions on a disabled row or disabled WalletGroup, but not on a disabled + member inside an enabled group. +- **System retry.** Android and iOS show a literal "Retry" button on non-Market + retry rows and read `actionText` only for the Market retry. Web keeps its + legacy rendering: a non-Market retry shows only its message and the whole row + press emits `actionKey`; only the Market retry draws a button + (`actionText ?? 'Retry'`). `style.actionText` therefore has no target on a + non-Market Web retry row; it is accepted by validation and ignored there. +- **Pressed corners.** Native pressed feedback uses 12 on all corners (Rail 8, + MediaTile 16, wallet sidebar 20) regardless of `groupPosition`. Web changes + only the pressed background and keeps the resting (group or template) radius. +- **Text direction.** `start`/`end` text alignment follows the layout direction + on all platforms. +- **Separator inset.** Identity rows default to a 60-unit inset and other + templates to 12 on native; Web defaults to 0 (STYLE_SPEC §6.2). +- **DataRow linear layout.** See ROW_TEMPLATES.md: native unstyled linear rows + keep the legacy single-label column. diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index 8eec8b849..83d90dcee 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -1,7 +1,11 @@ # NativeList Style Spec -Status: **shared style contract implemented in source; native rendered acceptance pending**. Consolidated on 2026-09-22 -from PRs #107, #109, #110, #111 and #112, on main `feac35eb6`. +Status: **shared style contract implemented on Web, iOS and Android through the +registered template renderers**. Consolidated on 2026-09-22 from PRs #107, #109, +#110, #111 and #112, on main `feac35eb6`; the renderer migration and its dated +per-stage runtime acceptance are recorded in [SPEC.md](SPEC.md) and +[DESIGN.md](DESIGN.md). The full §9 matrix (pixel parity, localized/RTL text and +font scaling on every template) has not been executed as one pass. Use symbol names as source anchors. Tables of legacy defaults below are an inventory, not evidence of three-platform visual acceptance. @@ -114,7 +118,7 @@ object identity. | `listStyle.separator` / `groupCornerRadius` | Initial/update propagation and actual-group scope aligned | | Row heights and overflow | `style.container.height` overrides `row.height`; omission retains template measurement, including existing style-dependent sizing; caller owns content fitting | | Legacy baseline dimensions | Inventory in §4/§6; explicit style values use logical units independently of Android's legacy list scale | -| Rendered acceptance | The source contract and build checks do not establish full native interaction/pixel acceptance (§9) | +| Rendered acceptance | Per-stage renderer runtime cases are recorded in SPEC.md/DESIGN.md; source and unit checks alone do not establish the full §9 pixel/interaction matrix | Each renderer maps its own model fields to owned views. There is no global cross-template style-slot map or shared legacy text-view pool. §4 remains the @@ -200,11 +204,11 @@ Radius steps in use: `4` (chip), `8` (rail, small visual), `10` (visual `rounded ## 4. T2 — Template style surface `style.X` modifies the rendering of the **model field named `X`**, regardless of -which physical view carries it. This matters because legacy view slots are shared: on -native legacy `metricCard` renders its *value* through the title label and its -*title* through the subtitle label, and the status label carries `rail.status`, -`activity.status` and `metricCard.trend`. Naming style keys after -views would therefore mis-target. `market` already follows this rule with +which physical view carries it. Each registered template renderer owns its views +and maps the key to the view rendering that field. The removed legacy native +views shared slots (for example legacy `metricCard` rendered its *value* through +the title label and its *title* through the subtitle label), which is why style +keys were never named after views. `market` follows the same rule with `style.price` / `style.change`. On Web the element rendering a field carries `data-nl-slot=""`, so the @@ -221,7 +225,8 @@ Every text role listed below accepts the same `NativeListTextStyle`: `token`, `fontSize`, `fontWeight`, `color`, `lineHeight`, `lines`, `truncate`, `alignment`, `verticalAlignment`, `offsetY`. Weights are `regular | medium | semibold | bold`; line counts are `1 | 2 | 3`; -`start | center | end` alignment follows layout direction. Colors must be +`start | center | end` alignment follows layout direction on all three +platforms (`end` is the left edge in RTL). Colors must be `#RRGGBB` or `#RRGGBBAA`, the common native/Web color format. Omitted properties preserve the template's values. Explicit properties override token values, presentation defaults and rich-text runs only for that property. @@ -268,7 +273,9 @@ does not change the typography or inner label/value gap of each metric. Variant applicability is also shared: composite metric-card `title` styles its heading, while standard-card `value`/`subtitle`/`trend` do not style nested metrics; -`system.title` applies to warning, `actionText` to retry. A missing leading visual, +`system.title` applies to warning, `actionText` to the retry button. Web draws +that button only for the Market retry (legacy), so on a non-Market Web retry row +`actionText` has no target and is ignored; iOS/Android style their button. A missing leading visual, badge, subtitle or trailing value remains absent. New nested-metric/thumbnail style roles require a separately declared cross-platform contract. @@ -400,6 +407,7 @@ Legend: **=** inventoried defaults agree; **≠** legacy divergence, see §6. | `title` | `$headingSm` | 16 medium / 24 | sp(16) medium / dp(24) | 16px / 20px / 600 **≠** | | `subtitle` | `$bodyMd` | 14 regular / 20 | sp(14) regular / dp(20) | 14px / 20px **=** | | `tertiary` | `$bodyMd` | 14 regular / 20 | sp(14) / dp(20) | 14px / 20px **=** | +| `subtitle` lines | model `subtitleLines` | default 1 | default **2** | default 1 **≠** | | `badge` | `$bodySm` | 12 medium / 16, pad 8/2, r4 | sp(12) / dp(16), pad 8/2, r4 | 11px / 18px, pad 0 5, r5 **≠** | | `value` | `$headingSm` | 16 medium | sp(16) medium / dp(24) | 14px / 20px / 500 **≠** | | `valueSecondary` | `$bodyMd` | 14 regular | sp(14) regular / dp(20) | 14px **=** | @@ -439,7 +447,8 @@ Legend: **=** inventoried defaults agree; **≠** legacy divergence, see §6. | --- | --- | --- | --- | --- | | member gap | 12 | stack spacing 12 | topMargin dp(12) | `gap:12px` **=** | | container radius | 12 | 20 | dp(12) | 12px **≠** | -| container border | 1, `borderSubdued` | 1 | dp(1) | 1px **=** | +| container border | 1, `borderSubdued`, drawn beneath members | 1 | dp(1) | 1px **=** | +| compact drag height | 68 | 68 | max(68, parent + 2 × inset) | 68 **≠** | ### rail @@ -511,8 +520,8 @@ defaults applied when `style` is absent. ### metricCard -`title` is the small label; `value` is the large number. They are rendered through -the subtitle and title views respectively — do not follow the view names. +`title` is the small label; `value` is the large number. Style keys follow these +model fields, whatever the renderer's internal view names are. | Key | Default | iOS | Android | Web | | --- | --- | --- | --- | --- | @@ -625,6 +634,11 @@ is deliberately excluded rather than declared and half-implemented: - **Content padding and item spacing** are already `layout.contentPadding*` and `layout.itemSpacing`; duplicating them here would give one value two homes. +Validated values: `separator.inset` is `0…64` logical units; `separator.color` +must be `#RRGGBB` or `#RRGGBBAA`, the same format as every row style color; +`groupCornerRadius` is `0…40` logical units. Any other `listStyle` or separator +key is rejected. + Every `listStyle` value is absent by default, and each platform keeps its own number as the fallback, so an untouched list renders exactly as before. On Web that required the inset separator to keep the transparent `border-bottom` for layout and paint the @@ -646,6 +660,7 @@ Web registered estimate/DOM measurement. Preserved defaults still differ: | `rail` | 40 | 28 | 40 | | `activity` + footer actions | 100 | 104 | 100 | | `dataRow` + secondary text | 60 | 64 | 60 | +| `dataRow` table, no secondary text | — | — | 48 (56 − 8) | | `sectionHeader` `summary` | 68 | 80 | 68 | | `sectionHeader` value + checkbox | 56 | 40 | 56 | | `message`, `mediaTile`, composite `metricCard` | computed / 244 / 160+ | 0 (wrap content) | computed / 244 / 161 | @@ -674,6 +689,10 @@ here and left alone. | `metricCard.value` | 18 semibold | sp(18) semibold | **22 / 28 / 700** | | visual `rounded` radius | `min(10, h/4)` | `min(10, size/4)`; 8 for accountSelector | **10px**; 8 `!important` for account action; 8 for market | | `walletSidebar` row radius | **20** | 12 | 12 | +| `identity` subtitle lines (no `subtitleLines`) | 1 | **2** | 1 (CSS single line) | +| pressed row corners | 12 all corners (rail 8, mediaTile 16, wallet sidebar 20), ignoring `groupPosition` | same as iOS | background only; resting group/template radius kept **≠** | +| `walletGroup` row `backgroundColor` | ignored | ignored | applied **≠** | +| non-Market System retry | literal "Retry" button | literal "Retry" button | no button; message only, row press retries **≠** | | separator default inset | 60 identity / 12 | dp(60) identity / dp(12) | **0** | | separator thickness | `1 / scale` (hairline) | `1f` raw px | `1px` | @@ -750,17 +769,23 @@ this change is not a wholesale redesign of those defaults. ## 7. Isolation rules -Message has its own reuse family; unmigrated templates share the legacy pool. -A styled row must not affect another row in either family. Four rules: +Every template has its own renderer reuse family; there is no shared legacy +pool. A styled row must not affect another row, whether it reuses the same host +or not. Four rules: 1. **Style types are per template.** `IdentityRowStyle` carries only identity's fields; `MetricCardRowStyle` only metricCard's. A key that the row type does not - declare fails `validateSnapshot` instead of silently hitting a shared view. + declare fails `validateSnapshot`/`validatePatches` instead of being ignored. + This strictness is new with the style surface: extra keys used to be ignored. 2. **Every property gets an explicit default.** Restore font, line height, line count, alignment/gravity, padding, color and all other changed state before binding the next row. Native style passes save the actual bound text defaults for restoration (§6.5). An omitted - value must use that template's default, never a previous row's value. + value must use that template's default, never a previous row's value. The + shared container pass clears every property it wrote on the next bind, + including `align-self`/`flex-shrink` on template children. Image loading + visibility is image lifecycle state, not a template default: restoring + defaults must not hide an image that already loaded. 3. **No list-wide side effects.** Any style-dependent decision is made per row. `usesSelectorSourceScale` (§6.3) is the counter-example to avoid. 4. **Bounded and fail-soft.** Validate input in JavaScript before serialization; @@ -887,7 +912,20 @@ Next acceptance work should execute the device cases in §6.5 and record platform-specific build and interaction evidence. The shared document is the contract; no shared cross-language renderer or code generator is required. -### Verification — 2026-09-22 +### Verification — 2026-09-24 (Web legacy-default audit) + +- Package typecheck passes; 173 package tests in five suites pass, including + regressions for loaded MetricCard images across content/style rebinding, + WalletGroup vertical padding in measured height, container alignment cleared + from reused Activity/DataRow children, legacy table DataRow height (48) and + linear column structure, + selector geometry from `style.container.height`, attached/detached text + styling parity, `listStyle.separator.color` validation and the app Market + builder's style keys. +- These are jsdom/unit checks of the Web renderers and validation; they are not + rendered browser or native device acceptance. + +### Verification — 2026-09-22 (historical, before the renderer migration) - Package TypeScript and JavaScript: 109 tests in five suites pass. Added cases cover per-template keys/colors, rich text and badge isolation, independent data diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index 92f71cdf2..63aeadb59 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -26,6 +26,7 @@ import { webRowRenderSignature, webWalletGroupReorderBadge, } from '../web/NativeListWebEngine'; +import { applyTextStyleToSlot } from '../web/templates/RowText'; jest.mock('../web/NativeListWebAvatarCache', () => ({ acquireNativeListAvatar: jest.fn(), @@ -1364,9 +1365,12 @@ describe('web row style', () => { ]) ); expect(body.querySelector('[role="progressbar"]')).toBeNull(); + // Legacy Web: a non-Market retry shows only its message; the row press + // carries the retry action. expect( body.querySelector('[data-native-list-action="retry"]') - ).not.toBeNull(); + ).toBeNull(); + expect(body.textContent).toBe('Retry connection'); } finally { engine.destroy(); } @@ -2052,10 +2056,12 @@ describe('web row style', () => { identity.querySelector('[data-nl-slot="subtitle"]')!.style .fontSize ).toBe('18px'); + // Only the Market retry has a Web retry button (legacy Web). const retry = render({ type: 'system', key: 's', variant: 'retry', + presentation: 'market', message: 'Failed', actionText: 'Try again', actionKey: 'retry', @@ -2715,7 +2721,7 @@ describe('complex renderer lifecycle', () => { engine.destroy(); } }); - it('gates disabled group member controls after rebinding', () => { + it('keeps legacy disabled group member actions and drag after rebinding', () => { const { document } = new JSDOM('').window; const host = document.createElement('div'); document.body.appendChild(host); @@ -2759,13 +2765,26 @@ describe('complex renderer lifecycle', () => { engine.applySnapshot( snap({ ...row, children: [{ ...member, disabled: true }] }) ); + // Legacy Web: a disabled member keeps its own actions. buttons()[0]!.click(); - expect(onRowAction).toHaveBeenCalledTimes(1); + expect(onRowAction).toHaveBeenCalledTimes(2); + expect(onRowAction.mock.calls[1]![0]).toMatchObject({ + rowKey: 'child', + actionKey: 'menu', + }); + // Legacy: a disabled member can still start the atomic group drag; + // only `draggable: false` excludes it. expect( canStartWebWalletGroupReorder( { ...row, children: [{ ...member, disabled: true }] }, 'child' ) + ).toBe(true); + expect( + canStartWebWalletGroupReorder( + { ...row, children: [{ ...member, draggable: false }] }, + 'child' + ) ).toBe(false); } finally { engine.destroy(); @@ -2773,3 +2792,436 @@ describe('complex renderer lifecycle', () => { } }); }); + +describe('legacy default regressions', () => { + const { JSDOM } = require('jsdom') as { + JSDOM: new (html: string) => { + window: { document: Document; Event: typeof Event }; + }; + }; + const snap = ( + items: readonly RowModel[], + layout: NativeListSnapshot['layout'] = { kind: 'linear' } + ): NativeListSnapshot => ({ + schemaVersion: 1, + generation: 1, + layout, + rows: items, + }); + const mount = (initial: NativeListSnapshot) => { + const { window } = new JSDOM(''); + const host = window.document.createElement('div'); + window.document.body.appendChild(host); + const engine = new NativeListWebEngine(host, initial, {}, false); + return { window, host, engine }; + }; + + it('keeps loaded MetricCard images visible across content and style rebinding', () => { + const row: Extract = { + type: 'metricCard', + key: 'metric', + title: 'Portfolio', + value: '$42,000', + visual: { kind: 'image', image }, + }; + const composite: typeof row = { + ...row, + key: 'composite', + variant: 'activity', + metrics: [ + { key: 'a', label: 'A', value: '1', visual: { kind: 'image', image } }, + { key: 'b', label: 'B', value: '2' }, + ], + }; + const { window, host, engine } = mount(snap([row, composite])); + try { + const images = Array.from(host.querySelectorAll('img')); + expect(images).toHaveLength(2); + images.forEach((img) => { + expect(img.style.opacity).toBe('0'); + img.dispatchEvent(new window.Event('load')); + expect(img.style.opacity).toBe('1'); + }); + const updates: readonly (readonly RowModel[])[] = [ + // Content-only change rebuilds the template while reusing the image. + [ + { ...row, value: '$43,000' }, + { ...composite, title: 'Changed' }, + ], + // Style-only change restores defaults before applying the style. + [ + { ...row, value: '$43,000', style: { value: { fontSize: 22 } } }, + { + ...composite, + title: 'Changed', + style: { title: { fontSize: 20 } }, + }, + ], + // Clearing the style restores defaults again. + [ + { ...row, value: '$43,000' }, + { ...composite, title: 'Changed' }, + ], + ]; + updates.forEach((items) => { + engine.applySnapshot(snap(items)); + const current = Array.from(host.querySelectorAll('img')); + expect(current).toEqual(images); + current.forEach((img) => expect(img.style.opacity).toBe('1')); + }); + } finally { + engine.destroy(); + } + }); + + it('includes group vertical padding in WalletGroup height and keeps legacy member presets', () => { + const member = ( + key: string, + extra: Partial = {} + ): IdentityRow => ({ + type: 'identity', + key, + presentation: 'walletSidebar', + leading: { kind: 'wallet', fallbackText: key }, + title: key, + ...extra, + }); + const group: Extract = { + type: 'walletGroup', + key: 'parent', + // Legacy walletSidebar presets ignore `size`; badges add 24. + parent: member('parent', { size: 'large' }), + children: [ + member('badged', { badges: [{ key: 'hidden', text: 'Hidden' }] }), + member('plain', { size: 'small' }), + ], + }; + const unstyled = 68 + 92 + 68 + 2 * 12; + expect(estimateWebRowHeight(group, snap([group]), 320)).toBe(unstyled); + const padded = { ...group, style: { verticalPadding: 10 } }; + expect(estimateWebRowHeight(padded, snap([padded]), 320)).toBe( + unstyled + 20 + ); + // Explicit-height selector members keep the legacy 2-unit border budget. + const explicit = { + ...padded, + parent: { ...group.parent, style: { container: { height: 60 } } }, + }; + expect(estimateWebRowHeight(explicit, snap([explicit]), 320)).toBe( + 60 + 92 + 68 + 2 * 12 + 2 + 20 + ); + + const { host, engine } = mount(snap([padded])); + try { + const wrappers = Array.from( + host.querySelectorAll('.ok-native-list-wallet-member') + ); + expect(wrappers.map((wrapper) => wrapper.style.height)).toEqual([ + '68px', + '92px', + '68px', + ]); + const body = host.querySelector( + '.ok-native-list-wallet-group' + )!; + expect(body.style.paddingBlock).toBe('10px'); + expect( + host.querySelector('.ok-native-list-item')?.style.height + ).toBe(`${unstyled + 20}px`); + } finally { + engine.destroy(); + } + }); + + it('clears container alignment from reused Activity and DataRow children', () => { + const activity: Extract = { + type: 'activity', + key: 'activity', + leading: { kind: 'icon', name: 'coin' }, + title: 'Sent', + primaryAmount: '-1 BTC', + }; + const data: Extract = { + type: 'dataRow', + key: 'data', + favorite: true, + columns: [ + { key: 'asset', text: 'BTC' }, + { key: 'price', text: '$1' }, + ], + }; + const aligned = (alignment: 'top' | 'bottom') => ({ + container: { contentVerticalAlignment: alignment }, + }); + const { host, engine } = mount( + snap([ + { ...activity, style: aligned('bottom') }, + { ...data, style: aligned('bottom') }, + ]) + ); + try { + const activityBody = host.querySelector( + '[data-nl-renderer="activity"]' + )!; + const dataBody = host.querySelector( + '[data-nl-renderer="dataRow"]' + )!; + const favorite = dataBody.querySelector( + '.ok-native-list-favorite' + )!; + const column = activityBody.querySelector( + ':scope > .ok-native-list-flex' + )!; + const amounts = activityBody.querySelector( + '.ok-native-list-amounts' + )!; + [column, amounts, favorite].forEach((node) => + expect(node.style.alignSelf).toBe('flex-end') + ); + engine.applySnapshot( + snap([ + { ...activity, style: aligned('top') }, + { ...data, style: aligned('top') }, + ]) + ); + [column, amounts, favorite].forEach((node) => + expect(node.style.alignSelf).toBe('flex-start') + ); + engine.applySnapshot(snap([activity, data])); + expect(host.querySelector('[data-nl-renderer="activity"]')).toBe( + activityBody + ); + expect(host.querySelector('[data-nl-renderer="dataRow"]')).toBe(dataBody); + [column, amounts, favorite].forEach((node) => + expect(node.style.alignSelf).toBe('') + ); + expect(activityBody.style.alignItems).toBe(''); + expect(dataBody.style.alignItems).toBe(''); + } finally { + engine.destroy(); + } + }); + + it('keeps legacy DataRow table/linear measurement', () => { + const data = (secondary: boolean, size?: 'small'): RowModel => ({ + type: 'dataRow', + key: `data-${secondary}-${size}`, + size, + columns: [ + { + key: 'asset', + text: 'BTC', + secondaryText: secondary ? 'Bitcoin' : undefined, + }, + { key: 'price', text: '$1' }, + ], + }); + const items = [data(false), data(true), data(false, 'small')]; + const heights = (kind: 'table' | 'linear') => + computeWebListLayout(snap(items, { kind }), 320, 640).items.map( + (item) => item.height + ); + expect(heights('table')).toEqual([48, 60, 40]); + expect(heights('linear')).toEqual([56, 60, 48]); + }); + + it('enables selector layout for row.height and style.container.height alike', () => { + const selectorRows = ( + height: Partial<{ + height: number; + style: { container: { height: number } }; + }> + ): readonly RowModel[] => [ + { + type: 'identity', + key: 'account', + presentation: 'accountSelector', + leading: { kind: 'icon', name: 'coin' }, + title: 'Account', + trailing: [ + { kind: 'icon', name: 'PlusSmallOutline', actionKey: 'create' }, + ], + ...height, + } as RowModel, + { + type: 'action', + key: 'action', + presentation: 'accountSelector', + title: 'Add', + actionKey: 'add', + trailing: [ + { kind: 'icon', name: 'PlusSmallOutline', actionKey: 'create' }, + ], + ...height, + } as RowModel, + ]; + const selectorState = (items: readonly RowModel[]) => { + const { host, engine } = mount(snap(items)); + try { + const identity = host.querySelector( + '[data-nl-renderer="identity"]' + )!; + const action = host.querySelector( + '[data-nl-renderer="action"]' + )!; + return { + selector: identity.dataset.nativeListSelector, + identityControl: identity.querySelector( + '[data-native-list-account-control="createAddress"]' + ) + ? 'createAddress' + : undefined, + actionControl: action.querySelector( + '[data-native-list-account-control="createAddress"]' + ) + ? 'createAddress' + : undefined, + }; + } finally { + engine.destroy(); + } + }; + const legacy = selectorState(selectorRows({ height: 58 })); + expect(legacy).toEqual({ + selector: 'accountSelector', + identityControl: 'createAddress', + actionControl: 'createAddress', + }); + expect( + selectorState(selectorRows({ style: { container: { height: 58 } } })) + ).toEqual(legacy); + expect(selectorState(selectorRows({}))).toEqual({ + selector: undefined, + identityControl: undefined, + actionControl: undefined, + }); + }); + + it('styles text identically whether or not the row is attached', () => { + const { window } = new JSDOM(''); + const style = window.document.createElement('style'); + style.textContent = WEB_LIST_CSS; + window.document.head.appendChild(style); + const output = ( + attached: boolean, + text: Parameters[1] + ) => { + const element = window.document.createElement('span'); + element.className = 'ok-native-list-title'; + element.textContent = 'Title'; + if (attached) window.document.body.appendChild(element); + applyTextStyleToSlot(element, text); + const html = element.outerHTML; + element.remove(); + return html; + }; + for (const text of [ + { offsetY: 2 }, + { verticalAlignment: 'center' as const }, + { lines: 2 as const }, + { lines: 1 as const, truncate: 'clip' as const }, + ]) + expect(output(true, text)).toBe(output(false, text)); + expect(output(false, { offsetY: 2 })).toContain( + 'white-space: inherit; text-overflow: inherit' + ); + }); + + it('keeps the legacy Web DataRow column structure', () => { + const data: Extract = { + type: 'dataRow', + key: 'data', + badges: [{ key: 'b', text: 'NEW' }], + columns: [ + { + key: 'asset', + text: 'BTC', + secondaryLeadingText: '1', + secondaryText: 'Bitcoin', + secondaryTone: 'positive', + }, + { key: 'price', text: '$1' }, + ], + }; + const { host, engine } = mount(snap([data])); + try { + const cell = host.querySelector( + '.ok-native-list-data-cell' + )!; + const primaryLine = cell.querySelector( + '.ok-native-list-data-primary' + )!; + // Leading text, primary text and badges share the primary line. + expect(primaryLine.textContent).toBe('1BTCNEW'); + const [leading, secondary] = Array.from( + cell.querySelectorAll('[data-nl-slot="columnSecondary"]') + ); + expect(leading?.parentElement).toBe(primaryLine); + expect(leading?.style.color).toBe(''); + expect(secondary?.textContent).toBe('Bitcoin'); + expect(secondary?.style.color).toBe('var(--nl-positive)'); + expect(secondary?.parentElement?.style.display).toBe('contents'); + // Removing the secondary text removes the second line again. + engine.applySnapshot( + snap([ + { + ...data, + columns: [{ key: 'asset', text: 'BTC' }, data.columns[1]!], + }, + ]) + ); + expect( + cell.querySelectorAll('[data-nl-slot="columnSecondary"]') + ).toHaveLength(0); + expect( + cell.querySelector('.ok-native-list-data-secondary-line') + ?.style.display + ).toBe('none'); + } finally { + engine.destroy(); + } + }); + + it('renders non-Market retry as legacy message-only row that fires on press', () => { + const retry: RowModel = { + type: 'system', + key: 'retry', + variant: 'retry', + message: 'Failed to load', + actionText: 'Try again', + actionKey: 'reload', + }; + const { window } = new JSDOM(''); + const host = window.document.createElement('div'); + window.document.body.appendChild(host); + const onRowAction = jest.fn(); + const engine = new NativeListWebEngine( + host, + snap([retry, { ...retry, key: 'market', presentation: 'market' }]), + { onRowAction }, + false + ); + const previousElement = global.Element; + global.Element = (window as unknown as { Element: typeof Element }).Element; + try { + const [plain, market] = Array.from( + host.querySelectorAll('[data-nl-renderer="system"]') + ); + expect(plain!.querySelector('button')).toBeNull(); + expect(plain!.querySelector('[data-nl-slot="actionText"]')).toBeNull(); + expect(plain!.textContent).toBe('Failed to load'); + expect( + market!.querySelector('[data-nl-slot="actionText"]')?.textContent + ).toBe('Try again'); + plain!.click(); + expect(onRowAction).toHaveBeenCalledTimes(1); + expect(onRowAction.mock.calls[0]![0]).toMatchObject({ + rowKey: 'retry', + actionKey: 'reload', + }); + } finally { + engine.destroy(); + global.Element = previousElement; + } + }); +}); diff --git a/native-views/react-native-native-list/src/__tests__/validation.test.ts b/native-views/react-native-native-list/src/__tests__/validation.test.ts index 065a1ebef..591a42e68 100644 --- a/native-views/react-native-native-list/src/__tests__/validation.test.ts +++ b/native-views/react-native-native-list/src/__tests__/validation.test.ts @@ -1410,6 +1410,106 @@ describe('NativeList style contract', () => { ).toThrow('is not a separator style key'); }); + it('validates the separator color with the shared row color format', () => { + const withChrome = (listStyle: unknown): NativeListSnapshot => + ({ ...snapshot(), listStyle } as NativeListSnapshot); + expect( + validateSnapshot(withChrome({ separator: { color: '#11223344' } })) + .listStyle + ).toEqual({ separator: { color: '#11223344' } }); + for (const color of ['red', '#fff', 'rgba(0,0,0,0.1)', '#1122334', 42]) { + expect(() => + validateSnapshot(withChrome({ separator: { color } })) + ).toThrow('listStyle.separator.color: must be #RRGGBB or #RRGGBBAA'); + } + }); + + it('accepts every style key sent by the app Market list builder', () => { + // Mirrors TOKEN_ROW_STYLE, rowStyleForChange, subtitle prefixes and the + // token/perps badge styles in app-monorepo marketNativeListRows.ts. + const style = { + horizontalPadding: 20, + verticalPadding: 12, + leadingGap: 14, + lineGap: 4, + titleBadgeGap: 4, + titleBadgeLayout: 'inline', + trailingGap: 8, + image: { + width: 32, + height: 32, + shape: 'circle', + cornerRadius: 16, + contentFit: 'cover', + }, + title: { + fontSize: 16, + fontWeight: 'medium', + lineHeight: 24, + lines: 1, + alignment: 'start', + }, + subtitle: { + fontSize: 12, + fontWeight: 'regular', + lineHeight: 16, + lines: 1, + alignment: 'start', + }, + price: { + fontSize: 16, + fontWeight: 'medium', + lineHeight: 24, + lines: 1, + alignment: 'end', + }, + change: { + fontSize: 13, + fontWeight: 'medium', + lineHeight: 20, + lines: 1, + alignment: 'center', + }, + changeWidth: 80, + changeHeight: 32, + changeCornerRadius: 8, + } as const; + expect(() => + validateSnapshot( + snapshot([ + { + ...marketRow(), + style, + badges: [ + { + key: 'stock-source', + icon: { + uri: 'https://example.com/s.png', + width: 14, + height: 14, + }, + style: { height: 14 }, + tone: 'neutral', + }, + { + key: 'leverage', + text: '10x', + tone: 'info', + style: { + fontSize: 10, + fontWeight: 'regular', + lineHeight: 16, + height: 16, + horizontalPadding: 6, + }, + }, + ], + } as MarketRow, + ]) + ) + ).not.toThrow(); + }); + it('keeps the Market style surface intact', () => { const [first] = validateSnapshot( snapshot([ diff --git a/native-views/react-native-native-list/src/models.ts b/native-views/react-native-native-list/src/models.ts index dd321e52d..9873ef469 100644 --- a/native-views/react-native-native-list/src/models.ts +++ b/native-views/react-native-native-list/src/models.ts @@ -192,10 +192,9 @@ type TemplateBoxStyle = Pick< /** * A style key names the model field it modifies, never the view that carries - * it. The view pool is shared and the mapping is not one to one: metricCard - * renders `value` through the same label identity uses for `title`, and the - * status view carries rail.status, activity.status, message.time and - * metricCard.trend. See docs/STYLE_SPEC.md §4. + * it. Each template renderer owns its views and maps the key to whichever view + * renders that field (for example metricCard `value` is its large number), so + * the same key name keeps one meaning across platforms. See docs/STYLE_SPEC.md §4. */ export type IdentityRowStyle = TemplateBoxStyle<'identity'> & Readonly<{ @@ -289,6 +288,10 @@ export type SystemRowStyle = TemplateBoxStyle<'system'> & Readonly<{ title?: NativeListTextStyle; message?: NativeListTextStyle; + /** + * Retry button text. Web draws a retry button only for the Market retry; + * a non-Market Web retry is message-only, so this has no target there. + */ actionText?: NativeListTextStyle; }>; @@ -764,7 +767,10 @@ export type NativeListSeparatorStyle = Readonly<{ * which is not the same everywhere — see docs/STYLE_SPEC.md §6.2. */ inset?: number; - /** Overrides the `separator` theme token for this list only. */ + /** + * Overrides the `separator` theme token for this list only. Must be + * `#RRGGBB` or `#RRGGBBAA`, like every row style color. + */ color?: string; }>; @@ -776,7 +782,10 @@ export type NativeListSeparatorStyle = Readonly<{ */ export type NativeListListStyle = Readonly<{ separator?: NativeListSeparatorStyle; - /** Corner radius of a `groupId` card. Does not affect rail or media tiles. */ + /** + * Corner radius of a `groupId` card, `0…40` logical units. Does not affect + * rail or media tiles. + */ groupCornerRadius?: number; }>; diff --git a/native-views/react-native-native-list/src/validation.ts b/native-views/react-native-native-list/src/validation.ts index 6c4aa6141..4fb4a3f0a 100644 --- a/native-views/react-native-native-list/src/validation.ts +++ b/native-views/react-native-native-list/src/validation.ts @@ -517,6 +517,14 @@ function assertListStyle( 'separator style key' ); assertBoundedStyleNumber(separator.inset, `${path}.separator.inset`, 0, 64); + // Same format as every row style color; renderers parse it directly. + if ( + separator.color !== undefined && + (typeof separator.color !== 'string' || + !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(separator.color)) + ) { + fail(`${path}.separator.color`, 'must be #RRGGBB or #RRGGBBAA'); + } } /** Token resolution is idempotent: a resolved style carries no token. */ diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index fac978a2d..925e5b4d2 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -1,4 +1,5 @@ import { applyRowContainerStyle } from './templates/RowContainerStyle'; +import { hasExplicitRowHeight, rowSizeModifier } from './templates/RowElements'; import { applyTextStyleToSlot, applyValueSegments } from './templates/RowText'; import { setMarketQuoteContent } from './templates/MarketRowRenderer'; export { @@ -430,9 +431,7 @@ function sizeModifier(row: RowModel): number { (row.variant === 'summary' || row.variant === 'gallery') ) return 0; - if (row.size === 'small') return -8; - if (row.size === 'large') return 12; - return 0; + return rowSizeModifier(row.size); } export function estimateWebRowHeight( @@ -640,7 +639,9 @@ export function canStartWebWalletGroupReorder( const member = [row.parent, ...row.children].find( (candidate) => candidate.key === memberKey ); - return member?.draggable !== false && !member?.disabled; + // Legacy: only `draggable: false` excludes a member; disabled members may + // still start the atomic group drag. + return member?.draggable !== false; } function itemStart(item: WebLayoutItem, horizontal: boolean): number { @@ -1824,7 +1825,7 @@ function createAccessory( ) { setData(element, 'nativeListAnchorInset', 7); // OneKey patch: the create-address button omits IconButton's one-point border. - if (row.height !== undefined && accessory.name === 'PlusSmallOutline') { + if (hasExplicitRowHeight(row) && accessory.name === 'PlusSmallOutline') { setData(element, 'nativeListAccountControl', 'createAddress'); if (!accessory.tintColor) element.style.color = 'var(--nl-icon-subdued)'; @@ -3712,7 +3713,8 @@ export class NativeListWebEngine { (member) => member.key === memberKey ) ?? row : row; - if (sourceRow.disabled) return; + // Legacy Web: a disabled WalletGroup member does not block its own + // actions or press; only a disabled row (or group) does. const action = target.closest('[data-native-list-action]'); if (action) { const scope = action.dataset.selectionScope; diff --git a/native-views/react-native-native-list/src/web/templates/ActionRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/ActionRowRenderer.ts index bdb9663dd..72ca4a582 100644 --- a/native-views/react-native-native-list/src/web/templates/ActionRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/ActionRowRenderer.ts @@ -1,4 +1,4 @@ -import { applyTabularNumbers } from './RowElements'; +import { applyTabularNumbers, hasExplicitRowHeight } from './RowElements'; import type { ActionRow } from '../../models'; import { RowVisualStyle, type RowPrimitives } from './RowVisual'; @@ -65,7 +65,7 @@ function bind(body: HTMLElement, row: ActionRow, primitives: RowPrimitives) { if (style?.trailingGap !== undefined) trailing.style.gap = `${style.trailingGap}px`; if ( - row.height !== undefined && + hasExplicitRowHeight(row) && row.presentation === 'accountSelector' && descriptors.length === 1 && descriptors[0]?.kind === 'icon' && diff --git a/native-views/react-native-native-list/src/web/templates/DataRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/DataRowRenderer.ts index 5539b7c48..4831d9a07 100644 --- a/native-views/react-native-native-list/src/web/templates/DataRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/DataRowRenderer.ts @@ -105,17 +105,22 @@ function bind(body: HTMLElement, row: DataRow, primitives: RowPrimitives) { label.dataset.tone = badge.tone ?? ''; cell.badges.appendChild(label); }); - cell.secondaryLine.style.display = - !column.secondaryLeadingText && !column.secondaryText ? 'none' : 'flex'; + // Legacy Web layout: secondary leading text sits inline before the primary + // text; secondary text is a direct line below it, toned by secondaryTone. + cell.secondaryLine.style.display = column.secondaryText + ? 'contents' + : 'none'; [column.secondaryLeadingText, column.secondaryText].forEach( (text, slot) => { const label = cell.secondary[slot]!; label.hidden = !text; - if (text) cell.secondaryLine.appendChild(label); - else label.remove(); + if (!text) label.remove(); + else if (slot === 0) cell.primaryLine.insertBefore(label, cell.primary); + else cell.secondaryLine.appendChild(label); label.style.cssText = ''; label.textContent = text ?? ''; - label.style.color = tone(column.secondaryTone, 'secondary'); + if (slot === 1) + label.style.color = tone(column.secondaryTone, 'secondary'); if (style?.columnSecondary) primitives.textStyle(label, style.columnSecondary); } @@ -151,8 +156,6 @@ function create(document: Document, row: DataRow, primitives: RowPrimitives) { 'span', 'ok-native-list-data-secondary-line' ); - secondaryLine.style.display = 'flex'; - secondaryLine.style.gap = '4px'; const secondary = Array.from({ length: 2 }, () => tagSlot( createElement(document, 'span', 'ok-native-list-secondary'), @@ -199,8 +202,11 @@ export const dataRowRenderer = { create, bind, recycle, - measure: (row: DataRow, _width: number) => - row.columns.some((column) => column.secondaryText) ? 60 : 56, + // Legacy presets: table rows without secondary text are 8 units shorter. + measure: (row: DataRow, _width: number, layout?: string) => { + const secondary = row.columns.some((column) => column.secondaryText); + return (secondary ? 60 : 56) + (layout === 'table' && !secondary ? -8 : 0); + }, measureRendered: (_body: HTMLElement, _row: DataRow): number | undefined => undefined, }; diff --git a/native-views/react-native-native-list/src/web/templates/IdentityRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/IdentityRowRenderer.ts index 18606264f..197fb63ec 100644 --- a/native-views/react-native-native-list/src/web/templates/IdentityRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/IdentityRowRenderer.ts @@ -1,4 +1,4 @@ -import { applyTabularNumbers } from './RowElements'; +import { applyTabularNumbers, hasExplicitRowHeight } from './RowElements'; import type { IdentityRow, NativeListTextStyle, @@ -45,7 +45,7 @@ function appendAccessories( if ( row && - row.height !== undefined && + hasExplicitRowHeight(row) && 'presentation' in row && row.presentation === 'networkSelector' ) { @@ -57,7 +57,7 @@ function appendAccessories( } if ( row && - row.height !== undefined && + hasExplicitRowHeight(row) && 'presentation' in row && row.presentation === 'accountSelector' && accessories.length === 1 && @@ -99,7 +99,7 @@ function createIdentityRow( setData( body, 'nativeListSelector', - row.height !== undefined ? presentation : undefined + hasExplicitRowHeight(row) ? presentation : undefined ); if (row.titleActionKey && row.titleActionOnHover) { setData(body, 'nativeListHoverAction', row.titleActionKey); @@ -117,12 +117,12 @@ function createIdentityRow( } const visual = primitives.visual( row.leading, - row.height !== undefined ? presentation : undefined + hasExplicitRowHeight(row) ? presentation : undefined ); if ( visual && true && - row.height !== undefined && + hasExplicitRowHeight(row) && row.presentation === 'walletSidebar' && 'fallbackIcon' in row.leading && row.leading.fallbackIcon?.name === 'LockSolid' @@ -146,7 +146,7 @@ function createIdentityRow( if ( visual && true && - row.height !== undefined && + hasExplicitRowHeight(row) && row.presentation === 'walletSidebar' && 'borderStyle' in row.leading && row.leading.borderStyle === 'dashed' @@ -425,7 +425,7 @@ function bind(body: HTMLElement, row: IdentityRow, primitives: RowPrimitives) { for (const name of next.getAttributeNames()) if (name.startsWith('data-')) body.setAttribute(name, next.getAttribute(name)!); - if (row.type === 'identity' && row.height !== undefined) { + if (row.type === 'identity' && hasExplicitRowHeight(row)) { const title = body.querySelector('.ok-native-list-title'); if (row.presentation === 'accountSelector') { body.style.gap = '12px'; diff --git a/native-views/react-native-native-list/src/web/templates/MetricCardRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/MetricCardRowRenderer.ts index e07933ab2..ceaf2dbc9 100644 --- a/native-views/react-native-native-list/src/web/templates/MetricCardRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/MetricCardRowRenderer.ts @@ -3,7 +3,13 @@ import type { NativeListTextStyle, TextTone, } from '../../models'; -import { createElement, setData, tagSlot } from './RowElements'; +import { + captureInlineStyles, + createElement, + restoreInlineStyles, + setData, + tagSlot, +} from './RowElements'; import { RowVisualStyle, type RowPrimitives } from './RowVisual'; const toneColor = (tone: TextTone | undefined, fallback: string) => tone === 'positive' @@ -206,9 +212,7 @@ function bind( const contentKey = JSON.stringify({ ...row, style: undefined }); if (contentKey !== v.contentKey) { let index = 0; - v.defaults.forEach((css, node) => { - node.style.cssText = css; - }); + restoreInlineStyles(v.defaults); const next = createMetricRow( body.ownerDocument, { @@ -235,21 +239,15 @@ function bind( body.replaceChildren(...Array.from(next.childNodes)); body.className = next.className; body.style.cssText = next.style.cssText; - v.defaults = new Map( - [body, ...body.querySelectorAll('*')].map((node) => [ - node, - node.style.cssText, - ]) - ); + // Image opacity is loading state; restoring it would hide loaded images. + v.defaults = captureInlineStyles(body); const leading = body.querySelector( ':scope > .ok-native-list-visual, :scope > .ok-native-list-stacked' ); v.visualStyle = leading ? new RowVisualStyle(leading) : undefined; v.contentKey = contentKey; } - v.defaults.forEach((css, node) => { - node.style.cssText = css; - }); + restoreInlineStyles(v.defaults); body.removeAttribute('data-nl-container-background'); body.removeAttribute('data-nl-container-border'); const style = row.style; diff --git a/native-views/react-native-native-list/src/web/templates/RowContainerStyle.ts b/native-views/react-native-native-list/src/web/templates/RowContainerStyle.ts index 332f188ec..b8a98fb88 100644 --- a/native-views/react-native-native-list/src/web/templates/RowContainerStyle.ts +++ b/native-views/react-native-native-list/src/web/templates/RowContainerStyle.ts @@ -1,40 +1,92 @@ import type { RowModel } from '../../models'; +type Write = { + element: HTMLElement; + property: string; + previous: string; + previousPriority: string; + written: string; +}; + +// Inline properties written by the container pass, per row body. Renderers only +// reset the nodes they own, so the container clears its own writes on rebind. +const writesByBody = new WeakMap(); + +function restoreContainerWrites(body: HTMLElement) { + body.removeAttribute('data-nl-container-background'); + body.removeAttribute('data-nl-container-border'); + const writes = writesByBody.get(body); + if (!writes) return; + writesByBody.delete(body); + // Undo in reverse so repeated writes restore the oldest value. + for (let index = writes.length - 1; index >= 0; index -= 1) { + const write = writes[index]!; + const { style } = write.element; + // A renderer that already reset this property owns its current value. + if (style.getPropertyValue(write.property) !== write.written) continue; + style.removeProperty(write.property); + if (write.previous) + style.setProperty(write.property, write.previous, write.previousPriority); + } +} + +function writeProperty( + body: HTMLElement, + element: HTMLElement, + property: string, + value: string, + priority = '' +) { + const { style } = element; + const write: Write = { + element, + property, + previous: style.getPropertyValue(property), + previousPriority: style.getPropertyPriority(property), + written: '', + }; + style.setProperty(property, value, priority); + write.written = style.getPropertyValue(property); + let writes = writesByBody.get(body); + if (!writes) writesByBody.set(body, (writes = [])); + writes.push(write); +} + export function applyRowContainerStyle(body: HTMLElement, row: RowModel): void { + restoreContainerWrites(body); + const set = (element: HTMLElement, property: string, value: string) => + writeProperty(body, element, property, value); if ( row.type === 'system' && row.variant === 'warning' && (row.style?.container?.height ?? row.height) !== undefined ) - body.style.height = '100%'; + set(body, 'height', '100%'); const container = row.style?.container; if (!container) return; if (container.backgroundColor !== undefined) { - body.style.setProperty( - '--nl-container-background', - container.backgroundColor - ); + set(body, '--nl-container-background', container.backgroundColor); body.setAttribute('data-nl-container-background', 'true'); // A CSS rule allows existing hover/press feedback to override the resting fill. + // The template/engine re-applies its own fill on every bind, so this + // removal is not tracked for restoration. body.style.removeProperty('background'); body.style.removeProperty('background-color'); } if (container.cornerRadius !== undefined) - body.style.borderRadius = `${container.cornerRadius}px`; + set(body, 'border-radius', `${container.cornerRadius}px`); if (container.borderWidth !== undefined) { // Paint above member backgrounds without changing the content box or hit targets. body.setAttribute('data-nl-container-border', 'true'); - body.style.setProperty( - '--nl-container-border-width', - `${container.borderWidth}px` - ); - body.style.setProperty( + set(body, '--nl-container-border-width', `${container.borderWidth}px`); + set( + body, '--nl-container-border-color', container.borderColor ?? 'transparent' ); - body.style.borderColor = 'transparent'; + set(body, 'border-color', 'transparent'); } else if (container.borderColor !== undefined) - body.style.borderColor = container.borderColor; + set(body, 'border-color', container.borderColor); if (container.contentVerticalAlignment !== undefined) { const alignment = { top: 'flex-start', @@ -48,18 +100,18 @@ export function applyRowContainerStyle(body: HTMLElement, row: RowModel): void { (row.type === 'identity' && row.presentation === 'walletSidebar') || (row.type === 'system' && row.variant === 'warning'); if (vertical) { - body.style.display = 'flex'; - body.style.flexDirection = 'column'; - if (row.type === 'mediaTile') body.style.gap = '0px'; - body.style.justifyContent = alignment; + set(body, 'display', 'flex'); + set(body, 'flex-direction', 'column'); + if (row.type === 'mediaTile') set(body, 'gap', '0px'); + set(body, 'justify-content', alignment); Array.from(body.children).forEach((child) => { - (child as HTMLElement).style.flexShrink = '0'; + set(child as HTMLElement, 'flex-shrink', '0'); }); } else { - body.style.alignItems = alignment; + set(body, 'align-items', alignment); // Timestamp/accessory defaults may have their own align-self. Array.from(body.children).forEach((child) => { - (child as HTMLElement).style.alignSelf = alignment; + set(child as HTMLElement, 'align-self', alignment); }); } } diff --git a/native-views/react-native-native-list/src/web/templates/RowElements.ts b/native-views/react-native-native-list/src/web/templates/RowElements.ts index 95b91c209..5e3dd3445 100644 --- a/native-views/react-native-native-list/src/web/templates/RowElements.ts +++ b/native-views/react-native-native-list/src/web/templates/RowElements.ts @@ -106,3 +106,55 @@ export function applyTabularNumbers(body: HTMLElement): void { text.style.fontVariantNumeric = 'tabular-nums'; }); } + +/** + * Selector presentations use their explicit-height geometry whenever the row + * has a resolved explicit height: the legacy `row.height` or the preferred + * `style.container.height`. + */ +export function hasExplicitRowHeight( + row: Readonly<{ + height?: number; + style?: Readonly<{ container?: Readonly<{ height?: number }> }>; + }> +): boolean { + return (row.style?.container?.height ?? row.height) !== undefined; +} + +/** Legacy `size` preset adjustment, applied by templates that opt in. */ +export function rowSizeModifier(size: string | undefined): number { + if (size === 'small') return -8; + if (size === 'large') return 12; + return 0; +} + +// Async image reveal state is owned by the image lifecycle, not by template +// defaults: a reused image that already fired `load` never fires it again. +const isImageLoadingNode = (node: HTMLElement) => + node.tagName === 'IMG' || + node.classList.contains('ok-native-list-selector-image-background'); + +/** Captures factory inline styles of a template subtree. */ +export function captureInlineStyles( + root: HTMLElement +): Map { + return new Map( + [root, ...root.querySelectorAll('*')].map((node) => [ + node, + node.style.cssText, + ]) + ); +} + +/** Restores captured factory styles while keeping image loading visibility. */ +export function restoreInlineStyles(defaults: Map) { + defaults.forEach((css, node) => { + if (!isImageLoadingNode(node)) { + node.style.cssText = css; + return; + } + const opacity = node.style.opacity; + node.style.cssText = css; + node.style.opacity = opacity; + }); +} diff --git a/native-views/react-native-native-list/src/web/templates/RowText.ts b/native-views/react-native-native-list/src/web/templates/RowText.ts index 0ead9942b..21f71c6c8 100644 --- a/native-views/react-native-native-list/src/web/templates/RowText.ts +++ b/native-views/react-native-native-list/src/web/templates/RowText.ts @@ -44,19 +44,13 @@ export function applyTextLayout( let content = element.querySelector( ':scope > .ok-native-list-text-content' ); - const computed = element.ownerDocument.defaultView?.getComputedStyle(element); + // Line clamps are only ever written inline by templates (no stylesheet rule + // sets them), so no computed-style read is needed; this also keeps the result + // identical for attached and detached rows. const inheritedClamp = Number( - element.style.getPropertyValue('-webkit-line-clamp') || - computed?.getPropertyValue('-webkit-line-clamp') + element.style.getPropertyValue('-webkit-line-clamp') ); - const lines = - style.lines ?? - defaultLines ?? - (Number( - element.style.getPropertyValue('-webkit-line-clamp') || - computed?.getPropertyValue('-webkit-line-clamp') - ) || - 1); + const lines = style.lines ?? defaultLines ?? (inheritedClamp || 1); if ( !content && (style.verticalAlignment !== undefined || @@ -92,8 +86,9 @@ export function applyTextLayout( !inheritedClamp ) { if (content) { - content.style.whiteSpace = computed?.whiteSpace || 'inherit'; - content.style.textOverflow = computed?.textOverflow || 'inherit'; + // Inherit the slot's resolved wrapping/truncation from CSS or inline style. + content.style.whiteSpace = 'inherit'; + content.style.textOverflow = 'inherit'; content.style.overflow = 'hidden'; } return; diff --git a/native-views/react-native-native-list/src/web/templates/SectionHeaderRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/SectionHeaderRowRenderer.ts index dcc5cba1f..5162f9695 100644 --- a/native-views/react-native-native-list/src/web/templates/SectionHeaderRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/SectionHeaderRowRenderer.ts @@ -1,4 +1,4 @@ -import { applyTabularNumbers } from './RowElements'; +import { applyTabularNumbers, hasExplicitRowHeight } from './RowElements'; import type { SectionHeaderRow } from '../../models'; import { createElement, @@ -43,13 +43,13 @@ function createSectionHeader( title.style.alignSelf = 'flex-start'; title.style.maxWidth = '100%'; // OneKey patch: explicit network headers reserve a separate 3-point underline area. - if (row.presentation !== 'networkSelector' || row.height === undefined) { + if (row.presentation !== 'networkSelector' || !hasExplicitRowHeight(row)) { title.style.textDecoration = 'underline dotted'; title.style.textUnderlineOffset = '6px'; } if (row.titleActionOnHover) setData(title, 'nativeListHoverAction', true); } - if (row.presentation === 'networkSelector' && row.height !== undefined) { + if (row.presentation === 'networkSelector' && hasExplicitRowHeight(row)) { body.style.padding = row.variant === 'summary' ? '24px 12px 20px' : '0 12px'; body.style.backgroundColor = 'var(--nl-bg)'; @@ -125,7 +125,7 @@ function createSectionHeader( 'value' ); applyValueSegments(value, row.valueSegments); - if (row.presentation === 'networkSelector' && row.height !== undefined) { + if (row.presentation === 'networkSelector' && hasExplicitRowHeight(row)) { value.style.fontFamily = 'inherit'; value.style.fontSize = '16px'; value.style.lineHeight = '24px'; diff --git a/native-views/react-native-native-list/src/web/templates/SystemRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/SystemRowRenderer.ts index d45609081..1594b8c7c 100644 --- a/native-views/react-native-native-list/src/web/templates/SystemRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/SystemRowRenderer.ts @@ -1,7 +1,9 @@ import type { NativeListTextStyle, SystemRow } from '../../models'; import type { RowPrimitives } from './RowVisual'; import { + captureInlineStyles, createElement, + restoreInlineStyles, setData, tagSlot, markActionAnchorSource, @@ -144,7 +146,14 @@ function createSystemRow( 'message' ) ); - if (row.variant === 'retry') { + // Legacy Web: only the Market retry draws a button. A non-Market retry shows + // its message and the whole row triggers the retry action, so + // `style.actionText` has no target there. + if ( + row.variant === 'retry' && + 'presentation' in row && + row.presentation === 'market' + ) { const action = tagSlot( createElement( document, @@ -178,18 +187,11 @@ function bind(body: HTMLElement, row: SystemRow, primitives: RowPrimitives) { 'presentation' in row ? row.presentation ?? '' : ''; binding = { key, - defaults: new Map( - [body, ...body.querySelectorAll('*')].map((node) => [ - node, - node.style.cssText, - ]) - ), + defaults: captureInlineStyles(body), }; bindings.set(body, binding); } - binding.defaults.forEach((css, node) => { - node.style.cssText = css; - }); + restoreInlineStyles(binding.defaults); body.removeAttribute('data-nl-container-background'); body.removeAttribute('data-nl-container-border'); const style = row.style; diff --git a/native-views/react-native-native-list/src/web/templates/WalletGroupRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/WalletGroupRowRenderer.ts index c8a63566f..0916a284e 100644 --- a/native-views/react-native-native-list/src/web/templates/WalletGroupRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/WalletGroupRowRenderer.ts @@ -1,11 +1,13 @@ import type { IdentityRow, WalletGroupRow } from '../../models'; import { identityRowRenderer } from './IdentityRowRenderer'; import { applyRowContainerStyle } from './RowContainerStyle'; -import { setData } from './RowElements'; +import { hasExplicitRowHeight, setData } from './RowElements'; import type { RowPrimitives } from './RowVisual'; type Member = { wrapper: HTMLElement; body: HTMLElement }; const membersByBody = new WeakMap>(); +// Members are walletSidebar identities; like the legacy engine, their preset +// height (68, +24 with badges) ignores the `size` preset adjustment. const memberHeight = (member: IdentityRow, width: number) => member.style?.container?.height ?? member.height ?? @@ -98,13 +100,16 @@ export const walletGroupRowRenderer = { bind, recycle, appliesSizePreset: () => false, + // Legacy group height (the 1px border pair is budgeted for explicit-height + // selector members) plus the group's own resolved vertical padding. measure: (row: WalletGroupRow, width: number) => [row.parent, ...row.children].reduce( (total, member) => total + memberHeight(member, width), 0 ) + row.children.length * 12 + - (row.parent.height !== undefined ? 2 : 0), + (hasExplicitRowHeight(row.parent) ? 2 : 0) + + (row.style?.verticalPadding ?? 0) * 2, measureRendered: ( _body: HTMLElement, _row: WalletGroupRow From 0fb97d29ace0ac6062b7a2964b961294d6337e5a Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 11:51:22 +0800 Subject: [PATCH 65/71] fix(native-list/android): keep user styles over restored legacy defaults - Switch DataRow off the legacy single-label / fixed-column layout only for text style keys (columns, columnSecondary, lineGap, titleBadgeGap); container, padding, image and index styles keep legacy text layout. - Share selector source-scale typography and apply it to every row text like legacy, including trailing accessory and fallback text, while honoring explicit style fontSize/lineHeight. - Honor truncate/lines on market retry and noMatch message and action text. - Key wallet-sidebar overlay metrics and dashed border on source scaling, matching legacy. Co-Authored-By: Claude Opus 5.5 --- .../nativelist/NativeListAccessoryStack.kt | 21 +++++++- .../nativelist/NativeListActionRenderer.kt | 45 ++++------------ .../nativelist/NativeListDataRowRenderer.kt | 11 +++- .../nativelist/NativeListIdentityRenderer.kt | 51 ++++++------------- .../nativelist/NativeListLeadingVisual.kt | 22 ++++---- .../nativelist/NativeListResolvedText.kt | 27 ++++++++++ .../nativelist/NativeListSystemRenderer.kt | 14 +++-- 7 files changed, 104 insertions(+), 87 deletions(-) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt index b4e2426e9..86132ca08 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt @@ -67,6 +67,24 @@ internal class NativeListAccessoryStack(context: android.content.Context) : Line } } + private val trailingPaintFlags = trailingViews.map { it.paintFlags } + + /** The caller's `value` / `valueSecondary` text style bound to [view], if any. */ + fun explicitTextStyle(view: TextView, style: JSONObject?): JSONObject? = + when (semanticValueViews.indexOf(view)) { + 0 -> style?.optJSONObject("value") + 1 -> style?.optJSONObject("valueSecondary") + else -> null + } + + /** Legacy selector source-scale typography for every visible trailing text. */ + fun applySourceTypography(style: JSONObject?) { + trailingViews.forEach { view -> + if (view.visibility == VISIBLE) + applyNativeListSourceTypography(view, explicitTextStyle(view, style)) + } + } + init { orientation = VERTICAL gravity = Gravity.END or Gravity.CENTER_VERTICAL @@ -164,7 +182,8 @@ internal class NativeListAccessoryStack(context: android.content.Context) : Line clipToPadding = true semanticValueViews.clear() boundCheckboxData = null - trailingViews.forEach { v -> + trailingViews.forEachIndexed { index, v -> + v.paintFlags = trailingPaintFlags[index] v.visibility = GONE v.text = "" v.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActionRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActionRenderer.kt index 08ef43962..d318d41ff 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActionRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActionRenderer.kt @@ -26,39 +26,7 @@ internal class NativeListActionRowView(context: ThemedReactContext) : } private val titlePaintFlags = title.paintFlags - - // Legacy selector typography: whole-pixel source font size, unhinted - // fractional advances and the original line box. - private fun applySourceTypography(view: NativeListTextView) { - val line = view.lineHeight - view.paintFlags = - view.paintFlags or android.graphics.Paint.SUBPIXEL_TEXT_FLAG or - android.graphics.Paint.LINEAR_TEXT_FLAG - view.setTextSize( - android.util.TypedValue.COMPLEX_UNIT_PX, - kotlin.math - .ceil( - (view.textSize * resources.displayMetrics.density / resources.displayMetrics.scaledDensity) - .toDouble() - ) - .toFloat(), - ) - view.letterSpacing = 0f - if (view.text.isNotEmpty()) { - val text = android.text.SpannableStringBuilder(view.text) - text - .getSpans(0, text.length, NativeListLineHeightSpan::class.java) - .forEach(text::removeSpan) - text.setSpan( - NativeListLineHeightSpan(line), - 0, - text.length, - android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - view.setLineSpacing(0f, 1f) - view.text = text - } - } + private val fallbackPaintFlags = visual.fallbackTextView.paintFlags override fun defaultHeight(item: NativeListItem, layout: String) = when { @@ -121,7 +89,12 @@ internal class NativeListActionRowView(context: ThemedReactContext) : ) .bind(title) title.paintFlags = titlePaintFlags - if (selector && sourceScale) applySourceTypography(title) + // Legacy selector typography covered every text in the row, including the + // trailing accessory text; explicit style fontSize/lineHeight stay as set. + if (selector && sourceScale) { + applyNativeListSourceTypography(title, style.optJSONObject("title")) + accessories.applySourceTypography(style) + } visual.visibility = if (icon == null) GONE else VISIBLE // Legacy: the 1px icon outline is kept only for a non-selector icon with an // explicit background; selector icons use a plain 8dp rounded fill. @@ -153,6 +126,10 @@ internal class NativeListActionRowView(context: ThemedReactContext) : } visual.bind(source, image, item.key, theme, false, sourceScale) } else visual.recycle() + visual.fallbackTextView.paintFlags = fallbackPaintFlags + // Legacy selector typography also covered the leading fallback text. + if (selector && sourceScale && icon != null) + applyNativeListSourceTypography(visual.fallbackTextView, null) } override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt index 8f33f477c..2980ef2c7 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt @@ -130,11 +130,14 @@ internal class NativeListDataRowView(context: ThemedReactContext) : accessories.layoutParams = LayoutParams(dp(32), LayoutParams.WRAP_CONTENT).apply { marginEnd = dp(10) } val values = data.getJSONArray("columns") + // Only the keys NativeListTableColumnView consumes switch a row off the legacy + // single-label / fixed-geometry text layout; container, padding, image and + // index styles keep legacy text rendering. + val unstyled = TEXT_STYLE_KEYS.none(style::has) cells.forEachIndexed { index, cell -> cell.visibility = if (index < values.length()) VISIBLE else GONE if (index < values.length()) { val value = values.getJSONObject(index) - val unstyled = style.length() == 0 val infoColor = parseNativeListColor(theme?.optString("info", "#006DCBF2") ?: "#006DCBF2") if (unstyled && layout != "table") // Legacy linear default: one label per column with appended spans. @@ -203,6 +206,10 @@ internal class NativeListDataRowView(context: ThemedReactContext) : visual.dispose() accessories.reset() } + + private companion object { + val TEXT_STYLE_KEYS = listOf("columns", "columnSecondary", "lineGap", "titleBadgeGap") + } } private class NativeListTableColumnView(context: android.content.Context) : LinearLayout(context) { @@ -375,7 +382,7 @@ private class NativeListTableColumnView(context: android.content.Context) : Line secondary.visibility = VISIBLE secondaryLine.visibility = VISIBLE } - if (style.length() > 0) applyStyle(style) + if (!fixedTableGeometry) applyStyle(style) if (fixedTableGeometry) { // Legacy table lines: fixed 20dp primary and 16dp secondary/badge boxes. primaryLine.layoutParams.height = dp(20) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt index 59e6b1d49..c9f31d0f8 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt @@ -1,6 +1,5 @@ package com.margelo.nitro.nativelist -import android.graphics.Paint import android.graphics.drawable.GradientDrawable import android.text.Spannable import android.text.SpannableStringBuilder @@ -228,6 +227,7 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : if (walletBadgeLine != null) (walletBadgeLine!!.layoutParams as LayoutParams).topMargin = gap else (badgeLine.layoutParams as LayoutParams).marginStart = gap } + val explicitTextStyles = mutableMapOf() for ((slot, targets) in listOf( "title" to listOf(title), @@ -235,7 +235,12 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : "tertiary" to listOf(tertiary), "badge" to semanticBadgeLabels.ifEmpty { listOf(badgeLine) }, )) { - style.optJSONObject(slot)?.let { text -> targets.forEach { applyStyledText(it, text) } } + style.optJSONObject(slot)?.let { text -> + targets.forEach { + applyStyledText(it, text) + explicitTextStyles[it] = text + } + } } val alignment = style.optJSONObject("container")?.optString("contentVerticalAlignment") if (!alignment.isNullOrEmpty()) @@ -246,7 +251,9 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : "bottom" -> Gravity.BOTTOM else -> Gravity.CENTER_VERTICAL } - applySelectorTypography(item) + applySelectorTypography(item) { view -> + explicitTextStyles[view] ?: trailingColumn.explicitTextStyle(view, style) + } // Legacy: overhanging selector icons/checkboxes must not be clipped by row padding. if (trailingColumn.parent === this && trailingColumn.requestsUnclippedHost) clipToPadding = false } @@ -261,7 +268,10 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : } } - private fun applySelectorTypography(item: NativeListItem) { + private fun applySelectorTypography( + item: NativeListItem, + explicitStyle: (TextView) -> JSONObject?, + ) { if ( item.json.optString("presentation") !in setOf("accountSelector", "networkSelector", "walletSidebar") @@ -272,33 +282,8 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : view.fontFeatureSettings = "tnum" if (sourceScale) { paintDefaults.putIfAbsent(view, view.paintFlags) - view.paintFlags = view.paintFlags or Paint.SUBPIXEL_TEXT_FLAG or Paint.LINEAR_TEXT_FLAG - val line = view.lineHeight - view.setTextSize( - TypedValue.COMPLEX_UNIT_PX, - kotlin.math - .ceil( - (view.textSize * resources.displayMetrics.density / - resources.displayMetrics.scaledDensity) - .toDouble() - ) - .toFloat(), - ) - view.letterSpacing = 0f - if (view.text.isNotEmpty()) { - val text = SpannableStringBuilder(view.text) - text - .getSpans(0, text.length, NativeListLineHeightSpan::class.java) - .forEach(text::removeSpan) - text.setSpan( - NativeListLineHeightSpan(line), - 0, - text.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - view.setLineSpacing(0f, 1f) - view.text = text - } + // Explicit style fontSize/lineHeight are kept as the caller set them. + applyNativeListSourceTypography(view, explicitStyle(view)) } } if (view is ViewGroup) for (i in 0 until view.childCount) visit(view.getChildAt(i)) @@ -312,7 +297,6 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : if (visual == null) leadingFrame.recycle() val item = currentItem!! val style = item.json.optJSONObject("style")?.optJSONObject("image") ?: JSONObject() - val wallet = item.json.optString("presentation") == "walletSidebar" && item.json.has("height") // Legacy wallet-sidebar glyph/overlay sizing applies with source scaling. val walletSource = item.json.optString("presentation") == "walletSidebar" && sourceScale val fallbackIconName = @@ -325,9 +309,6 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : leadingFrame.roundedImageRadius = if (item.json.optString("presentation") == "accountSelector" && item.json.has("height")) 8 else 10 - leadingFrame.dashedBorderWidth = if (wallet) 1 else 2 - leadingFrame.overlayTextFontSize = if (wallet) 12f else 10f - leadingFrame.overlayTextLineHeight = if (wallet) 16 else null leadingFrame.bind(descriptor, style, item.key, currentTheme, false, sourceScale) leadingFallback.typeface = NativeListFonts.bold(context) leadingFallback.textSize = sp(13f) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt index 47b53433a..626184a4f 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt @@ -34,12 +34,12 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont val fallbackTextView: TextView get() = fallback - var dashedBorderWidth = 2 - var overlayTextFontSize = 10f - var overlayTextLineHeight: Int? = null var bitmapBorderWidth = 0 - /** Legacy wallet-sidebar text overlays: 16dp tall pills that wrap their text. */ + /** + * Legacy wallet-sidebar source-scale leading visual: text-only overlays are 16dp + * tall pills with 12sp text on a 16dp line box, and a dashed frame border is 1dp. + */ var walletTextOverlays = false private fun isWalletText(data: JSONObject) = @@ -189,7 +189,7 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont if (kind == "icon" && iconBorder) setStroke(1, color("#0000001F")) if (visual.optString("borderStyle") == "dashed") setStroke( - dp(dashedBorderWidth), + dp(if (walletTextOverlays) 1 else 2), color(visual.optString("borderColor", "#00000072")), dp(4).toFloat(), dp(4).toFloat(), @@ -282,12 +282,12 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont text = data.optString("text") gravity = Gravity.CENTER includeFontPadding = false - textSize = - if (sourceScale) overlayTextFontSize - else NativeListScale.font(resources, overlayTextFontSize) - overlayTextLineHeight?.let { - androidx.core.widget.TextViewCompat.setLineHeight(this, dp(it)) - } + val walletText = isWalletText(data) + val size = if (walletText) 12f else 10f + textSize = if (sourceScale) size else NativeListScale.font(resources, size) + // The overlay TextView is reused: reset the line box for non-wallet text. + if (walletText) androidx.core.widget.TextViewCompat.setLineHeight(this, dp(16)) + else setLineSpacing(0f, 1f) typeface = if (isWalletText(data)) NativeListFonts.regular(context) else NativeListFonts.medium(context) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt index bc633ccec..a93e48d37 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt @@ -9,6 +9,7 @@ import android.text.TextUtils import android.text.style.LineHeightSpan import android.util.TypedValue import android.view.Gravity +import android.widget.TextView import kotlin.math.roundToInt import org.json.JSONObject @@ -134,3 +135,29 @@ internal data class NativeListResolvedText( } } } + +/** + * Legacy selector source-scale typography (accountSelector / networkSelector / + * walletSidebar with source scaling): whole-pixel unscaled font size, unhinted + * fractional advances and the original line box. A property explicitly set by the + * caller's text style (`fontSize`, `lineHeight`) is already resolved in source + * units and is left untouched so user style still takes effect. + */ +internal fun applyNativeListSourceTypography(view: TextView, style: JSONObject?) { + val metrics = view.resources.displayMetrics + val line = view.lineHeight + view.paintFlags = view.paintFlags or Paint.SUBPIXEL_TEXT_FLAG or Paint.LINEAR_TEXT_FLAG + if (style?.has("fontSize") != true) + view.setTextSize( + TypedValue.COMPLEX_UNIT_PX, + kotlin.math.ceil((view.textSize * metrics.density / metrics.scaledDensity).toDouble()).toFloat(), + ) + view.letterSpacing = 0f + if (style?.has("lineHeight") != true && view.text.isNotEmpty()) { + val text = SpannableStringBuilder(view.text) + text.getSpans(0, text.length, NativeListLineHeightSpan::class.java).forEach(text::removeSpan) + text.setSpan(NativeListLineHeightSpan(line), 0, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + view.setLineSpacing(0f, 1f) + view.text = text + } +} diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSystemRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSystemRenderer.kt index 974c7a61b..2c27c10d9 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSystemRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSystemRenderer.kt @@ -192,9 +192,9 @@ internal class NativeListSystemRowView(context: ThemedReactContext) : dp(if (value.isEmpty()) 11 else 27), ) if (value.isNotEmpty()) { - // Legacy market text: one line, ellipsized unless a style overrides it. + // Legacy market text: one line, ellipsized unless style sets lines/truncate. text(title, value, "message", 16f, line = 24, lines = 1, alignment = "center", physical = true) - if (style.optJSONObject("message")?.has("lines") != true) + if (!hasExplicitTruncation(style, "message")) title.ellipsize = android.text.TextUtils.TruncateAt.END column.addView(title) addView( @@ -213,7 +213,9 @@ internal class NativeListSystemRowView(context: ThemedReactContext) : "center", physical = true, ) - action.ellipsize = android.text.TextUtils.TruncateAt.END + // Legacy retry button ellipsizes; an explicit style truncate/lines wins. + if (!hasExplicitTruncation(style, "actionText")) + action.ellipsize = android.text.TextUtils.TruncateAt.END val density = resources.displayMetrics.density val width = (ceil(action.paint.measureText(action.text.toString()).toDouble()) + 18 * density) @@ -235,7 +237,7 @@ internal class NativeListSystemRowView(context: ThemedReactContext) : alignment = "center", physical = true, ) - if (style.optJSONObject("message")?.has("lines") != true) + if (!hasExplicitTruncation(style, "message")) title.ellipsize = android.text.TextUtils.TruncateAt.END column.addView(title) addView(column, wrap()) @@ -456,3 +458,7 @@ private class NativeListMarketSkeleton(context: android.content.Context, backgro } else animator.cancel() } } + +/** True when the caller's text style for [slot] sets its own truncation (`lines` / `truncate`). */ +private fun hasExplicitTruncation(style: JSONObject, slot: String): Boolean = + style.optJSONObject(slot)?.let { it.has("lines") || it.has("truncate") } == true From a25876a63cd0da8ac78433f5e94c64da6c277d83 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 11:51:22 +0800 Subject: [PATCH 66/71] fix(native-list/ios): exact badge gap, typed change detection, direction rebind - Keep legacy DataRow badge pills and add titleBadgeGap as an exact, unfilled gap after the title. - Compare theme/listStyle with type-aware structural equality so true and 1 differ, without serializing. - Resolve activity amount alignment from the view direction and rebind visible rows and the fixed footer when the layout direction changes. - Ignore row.height for walletGroup like legacy; style.container.height still wins. Co-Authored-By: Claude Opus 5.5 --- .../ios/NativeListActivityRenderer.swift | 3 +- .../ios/NativeListDataRowRenderer.swift | 21 ++++---- .../ios/NativeListRendererCell.swift | 54 ++++++++++++++++--- .../ios/NativeListResolvedText.swift | 6 +-- .../ios/RNCNativeListView.swift | 34 +++++++++++- 5 files changed, 96 insertions(+), 22 deletions(-) diff --git a/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift b/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift index f52752220..1f1223bf0 100644 --- a/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift @@ -143,7 +143,8 @@ final class NativeListActivityCell: NativeListRendererCell { let resolved = NativeListResolvedText( value, style: nil, size: index == 0 ? 16 : 14, weight: index == 0 ? .medium : .regular, color: color, lineHeight: index == 0 ? 24 : 20, lines: 1, tabular: true) - button.setAttributedTitle(resolved.attributed, for: .normal) + button.setAttributedTitle( + resolved.attributed(direction: button.effectiveUserInterfaceLayoutDirection), for: .normal) button.titleLabel?.font = resolved.font button.isHidden = value.isEmpty if let text = style.dictionary(slot) { NativeListTextStyles.applyStyledButton(button, text) } diff --git a/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift b/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift index f6092a6e8..815d3d75d 100644 --- a/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift @@ -336,22 +336,25 @@ private final class NativeListTableColumnView: UIStackView { .foregroundColor: color(primaryStyle, textColor(column.string("tone"), theme: theme)), ]) let primaryLength = attributed.length - for (index, badge) in badges.prefix(2).enumerated() { - // `titleBadgeGap` replaces the legacy two-space lead-in with an exact kern gap. - let gap = index == 0 && style["titleBadgeGap"] != nil + // Every badge run is the legacy " text " pill: both lead spaces sit inside the badge + // background and are its left padding. `titleBadgeGap` adds an exact, unfilled gap between + // the title and the first pill as kern on the title's last composed character; unstyled + // rows add no kern and stay byte-identical to legacy. + if style["titleBadgeGap"] != nil, !badges.isEmpty, primaryLength > 0 { + attributed.addAttribute( + .kern, value: CGFloat(style.double("titleBadgeGap")), + range: (attributed.string as NSString).rangeOfComposedCharacterSequence( + at: primaryLength - 1)) + } + for badge in badges.prefix(2) { attributed.append( NSAttributedString( - string: gap ? " \(badge.string("text")) " : " \(badge.string("text")) ", + string: " \(badge.string("text")) ", attributes: [ .font: nativeListFont(ofSize: 12, weight: .medium), .foregroundColor: nativeListColor(theme, "info", "#0D74CE"), .backgroundColor: UIColor(nativeListHex: "#008FF519", fallback: .systemBlue), ])) - if gap, primaryLength > 0 { - attributed.addAttribute( - .kern, value: CGFloat(style.double("titleBadgeGap")), - range: NSRange(location: primaryLength - 1, length: 1)) - } } let primaryParagraph = paragraph( primaryStyle, font: primaryFont, diff --git a/native-views/react-native-native-list/ios/NativeListRendererCell.swift b/native-views/react-native-native-list/ios/NativeListRendererCell.swift index 55fccf6c7..3e9933e2e 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererCell.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererCell.swift @@ -54,7 +54,12 @@ class NativeListRendererCell: NativeListRowHost { ) -> UIColor { nativeListColor(theme, "rowBackground", "#FFFFFF") } private(set) var selectedState = false // Last applied list-level inputs; compared structurally (no serialization/hashing per bind). - private var boundInputs: (theme: NSDictionary?, layout: String, listStyle: NSDictionary?)? + // The layout direction is an input because renderers resolve start/end alignment at bind. + private var boundInputs: + ( + theme: NSDictionary?, layout: String, listStyle: NSDictionary?, + direction: UIUserInterfaceLayoutDirection + )? override init(frame: CGRect) { super.init(frame: frame) @@ -87,12 +92,15 @@ class NativeListRendererCell: NativeListRowHost { let update = update(from: self.item, to: item) let inputs = ( theme: theme.map { $0 as NSDictionary }, layout: layout, - listStyle: listStyle.map { $0 as NSDictionary } + listStyle: listStyle.map { $0 as NSDictionary }, + direction: effectiveUserInterfaceLayoutDirection ) let inputsChanged = boundInputs.map { - $0.layout != inputs.layout || !nativeListValuesEqual($0.theme, inputs.theme) - || !nativeListValuesEqual($0.listStyle, inputs.listStyle) + // A missing theme/listStyle is the empty object, as in the former `?? [:]` JSON digest. + $0.layout != inputs.layout || $0.direction != inputs.direction + || !nativeListValuesEqual($0.theme ?? [:], inputs.theme ?? [:]) + || !nativeListValuesEqual($0.listStyle ?? [:], inputs.listStyle ?? [:]) } ?? true if update != .unchanged || inputsChanged { if self.item != nil { onBindingInvalidated?(self, bindingEpoch) } @@ -255,11 +263,43 @@ class NativeListRendererCell: NativeListRowHost { } } -/// Structural equality for bridged JSON values (identity short-circuits in `isEqual`). +/// Type-aware structural equality for bridged JSON values, matching the former +/// sorted-keys JSON digest without serializing: `true` != `1` (CFBoolean vs NSNumber), +/// numbers compare by value (`1` == `1.0`), strings by UTF-16 code units, and inside objects +/// an explicit `null` differs from a missing key. At the top level a missing value equals +/// `NSNull` (the digest used `value ?? NSNull()`). func nativeListValuesEqual(_ lhs: Any?, _ rhs: Any?) -> Bool { - switch (lhs, rhs) { + switch (lhs.flatMap { $0 is NSNull ? nil : $0 }, rhs.flatMap { $0 is NSNull ? nil : $0 }) { case (nil, nil): return true - case let (lhs?, rhs?): return (lhs as AnyObject).isEqual(rhs as AnyObject) + case let (lhs?, rhs?): return nativeListJSONEqual(lhs as AnyObject, rhs as AnyObject) default: return false } } + +private func nativeListJSONEqual(_ lhs: AnyObject, _ rhs: AnyObject) -> Bool { + if lhs === rhs { return true } + if let lhs = lhs as? NSNumber, let rhs = rhs as? NSNumber { + let boolean = CFBooleanGetTypeID() + return (CFGetTypeID(lhs) == boolean) == (CFGetTypeID(rhs) == boolean) && lhs.isEqual(to: rhs) + } + if let lhs = lhs as? NSString, let rhs = rhs as? NSString { return lhs.isEqual(to: rhs as String) } + if let lhs = lhs as? NSDictionary, let rhs = rhs as? NSDictionary { + guard lhs.count == rhs.count else { return false } + for (key, value) in lhs { + guard let other = rhs.object(forKey: key), + nativeListJSONEqual(value as AnyObject, other as AnyObject) + else { return false } + } + return true + } + if let lhs = lhs as? NSArray, let rhs = rhs as? NSArray { + guard lhs.count == rhs.count else { return false } + for index in 0.. NSAttributedString { let paragraph = NSMutableParagraphStyle() paragraph.minimumLineHeight = lineHeight @@ -83,6 +79,8 @@ struct NativeListResolvedText { return NSAttributedString(string: text, attributes: attributes) } + /// Resolves start/end once from the label's current direction; the list rebinds bound rows + /// when its effective layout direction changes (NativeListRendererCell bound inputs). func bind(_ label: NativeListTextLabel) { let direction = label.effectiveUserInterfaceLayoutDirection label.font = font diff --git a/native-views/react-native-native-list/ios/RNCNativeListView.swift b/native-views/react-native-native-list/ios/RNCNativeListView.swift index c8dadab95..286513007 100644 --- a/native-views/react-native-native-list/ios/RNCNativeListView.swift +++ b/native-views/react-native-native-list/ios/RNCNativeListView.swift @@ -330,10 +330,38 @@ final class NativeListView: UIView { lastLayoutSize = bounds.size if lastLayoutDirection != direction, let config { configureLayout(config) + rebindRowsForLayoutDirection(config) } performPendingScrollIfNeeded() } + // A layout-direction change may arrive without a layout pass (semantic attribute or trait). + override var semanticContentAttribute: UISemanticContentAttribute { + didSet { if semanticContentAttribute != oldValue { setNeedsLayout() } } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if previousTraitCollection?.layoutDirection != traitCollection.layoutDirection { + setNeedsLayout() + } + } + + /// Renderers resolve start/end alignment at bind time, and the layout direction is one of a + /// cell's bound inputs; rebinding visible rows and the footer re-resolves it. A cell whose + /// bound direction already matches only refreshes selection and appearance. Offscreen rows + /// rebind when they are next dequeued. + private func rebindRowsForLayoutDirection(_ config: NativeListConfig) { + for case let cell as NativeListRowHost in collectionView.visibleCells { + guard let indexPath = collectionView.indexPath(for: cell), + let item = item(at: indexPath) else { continue } + bind(cell: cell, item: item, itemIndex: indexPath.item) + } + if let footer = config.fixedFooter, !footerCell.isHidden { + bind(cell: footerCell, item: footer, itemIndex: nil) + } + } + func applySnapshotJson(_ json: String) { guard let next = try? NativeListConfig.parse(json: json) else { return } invalidateActionAnchor(reason: "snapshot") @@ -1708,7 +1736,11 @@ final class NativeListView: UIView { // OneKey patch: honor selector baseline geometry; keep compact drag sizing. if item.type == "walletGroup", usingCompactReorderHeight, item.key == interactiveReorderCompactKey { return 68 } if let height = item.styledHeight { return height } - if item.data["height"] != nil { return CGFloat(item.data.double("height")) } + // Legacy WalletGroup ignored `row.height` (its height comes from its members); + // `style.container.height` above still wins. + if item.type != "walletGroup", item.data["height"] != nil { + return CGFloat(item.data.double("height")) + } let availableWidth = max(0, collectionView.bounds.width - flowLayout.sectionInset.left - flowLayout.sectionInset.right) let columnWidth = config?.layout == "grid" ? floor((availableWidth - CGFloat((config?.gridColumns ?? 2) - 1) * (config?.itemSpacing ?? 0)) / CGFloat(config?.gridColumns ?? 2)) From 1d8f32b3e9cbe4b3ae8850b24bbdadaeeda2949e Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 11:51:22 +0800 Subject: [PATCH 67/71] fix(native-list/web): restore legacy retry and hover, align docs - Remove the Web retry button for every presentation; the row press, Enter or Space fires the retry action as in legacy. - Restore legacy hover actions for members of disabled wallet groups. - Let WalletGroup padding replace the explicit-height inset like native so rendered and measured heights match. - Reset container style writes before each rebind so renderer values are never reverted. - Docs: per-platform DataRow heights, pressed corners, walletGroup height and backgroundColor differences, direction rebinding, test counts and CHANGELOG. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +- .../react-native-native-list/docs/DESIGN.md | 12 +- .../docs/ROW_TEMPLATES.md | 46 +++-- .../react-native-native-list/docs/SPEC.md | 72 ++++--- .../docs/STYLE_SPEC.md | 42 +++- .../src/__tests__/NativeList.web.test.ts | 190 +++++++++++++++--- .../src/web/NativeListWebEngine.ts | 19 +- .../src/web/templates/RowContainerStyle.ts | 23 ++- .../src/web/templates/SystemRowRenderer.ts | 29 +-- .../web/templates/WalletGroupRowRenderer.ts | 34 +++- 10 files changed, 346 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd5a3f625..a9fbb44c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ All notable changes to this project will be documented in this file. - **native-list**: Row style validation is strict. `validateSnapshot` and `validatePatches` throw for any `style` key a template does not declare, for unknown nested text/image/container keys, and for unknown Market badge style keys, where extra keys used to be ignored. Colors must be `#RRGGBB` or `#RRGGBBAA`, including `listStyle.separator.color`. Every key sent by the app-monorepo Market list builder (`marketNativeListRows.ts`) still validates. ### Bug Fixes -- **native-list**: Review fixes for the renderer migration. Restore the legacy unstyled rendering of every template inside the new renderers (for example native linear DataRow single-label columns, pressed-row corners, Activity/Identity leading slots, and WalletGroup appearance and drag rules), without restoring key-based styling. Fix an Android sticky-header overlay crash and a leaked pre-draw listener on detached lists. On Web, keep loaded MetricCard images visible after rebinding, restore the legacy message-only non-Market retry row and disabled WalletGroup member actions, include WalletGroup vertical padding in its height, and stop container alignment leaking into reused rows. On iOS, make `end` text alignment follow RTL layout and cache Message text measurement to cut its per-bind cost. +- **native-list**: Review fixes for the renderer migration. Restore the legacy unstyled rendering of every template inside the new renderers (for example native linear DataRow single-label columns, pressed-row corners, Activity/Identity leading slots, and WalletGroup appearance and drag rules), without restoring key-based styling. Fix an Android sticky-header overlay crash and a leaked pre-draw listener on detached lists. On Web, keep loaded MetricCard images visible after rebinding, restore the legacy message-only retry row (no Web retry button for any presentation) and disabled WalletGroup member actions, include WalletGroup vertical padding in its height, and stop container alignment leaking into reused rows. On iOS, make `end` text alignment follow RTL layout and cache Message text measurement to cut its per-bind cost. +- **native-list**: Second legacy-parity review round. Web: remove the Market retry button, keep hover title actions keyed on the member only, make WalletGroup `verticalPadding` replace the legacy border inset in measurement and rendering, and undo container writes before each renderer rebind. Android: switch DataRow off the legacy layout only for its text-style keys, apply selector source-scale typography to every row text while honouring an explicit `fontSize`/`lineHeight`, let `truncate` override the Market retry/noMatch ellipsis, and restore the legacy wallet leading-overlay metrics. iOS: exact `titleBadgeGap` inside legacy badge pills, type-aware theme/listStyle change detection, start/end alignment re-resolved on layout-direction change (including the footer), and WalletGroup ignores `row.height` as before. - **native-list**: Align the configurable row-style contract across Web, iOS and Android. Apply non-Market image/spacing overrides; isolate primary and secondary data-column text in linear/table layouts; preserve styles through selection and summary updates; reuse the ordinary Android row renderer for pinned headers including values and checkboxes. Reject template-inapplicable box keys and nonportable text colors. Keep template structure, local styling and shared list capabilities distinct; overflow fitting remains caller-owned. - **native-list**: Apply Web selector styles after presentation defaults, isolate wallet-member styles, and target composite metric-card headings by their semantic role. Restore native text state on reuse while retaining unspecified iOS attributed-string properties. Propagate Android list chrome on initial snapshots and restrict custom group radius to actual grouped rows. Reject malformed nested text/image styles and unknown nested keys; developers integrating the list own overflow prevention. - **image (iOS)**: Show a memory-cached image on the mount frame instead of an icon skeleton (OK-63873). The memory probe skipped every image whose JS side left `cachePolicy` unset (Fabric hands Nitro a nil, which the request path already treats as memory-disk), and before layout it keyed the decode thumbnail from the empty bounds, so a freshly mounted image never matched what the prewarm or a previous mount had stored. The probe now coalesces a nil policy to memory-disk and, before layout, tries the decode-thumbnail keys the laid-out request (`resizeWidth`/`resizeHeight` hint + the view's `contentFit`) and a preload (hint + cover) would use. An image already on screen for the same identity is kept as the placeholder while a load for another key runs, instead of being blanked to a skeleton. diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index 9b1884b72..480473d73 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -264,7 +264,11 @@ pool. Snapshot application uses stable keys and platform diffing. A patch updates only changed models and rebinds/reconfigures only the corresponding visible cells. Selection-only changes use native payload updates and never require rebuilding -all JavaScript rows. +all JavaScript rows. On iOS a cell also rebinds when its list-level inputs +change: layout, layout direction, or `theme`/`listStyle` under type-aware +structural equality (`true` differs from `1`, `1` equals `1.0`, an explicit +`null` inside an object differs from a missing key, and a missing +`theme`/`listStyle` equals `{}`). `visibleRangeChanged` is coalesced to at most once per animation frame and only emitted when the first/last visible keys change. `endReached` is edge-triggered @@ -616,8 +620,10 @@ renderer structure; implicit key-based styling stays removed. Notable outcomes: avoids repeating text layout for unchanged rows during scrolling. This is a cost reduction, not a measured scrolling benchmark. - Web: MetricCard keeps loaded images visible across rebinding, WalletGroup - height includes group vertical padding, container alignment no longer leaks - into reused rows, and selector geometry follows either explicit height field. + `verticalPadding` replaces the legacy border inset in both measurement and + rendering, container writes are undone before each renderer rebind (no leaked + alignment, no reverted renderer values), every System retry is message-only + again, and selector geometry follows either explicit height field. Web validation is jsdom/unit level (package typecheck and tests); native validation for this round is reported with the native changes. diff --git a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md index 1497a6d1e..459095235 100644 --- a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md +++ b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md @@ -83,16 +83,23 @@ separately from source implementation in STYLE_SPEC §9. Every member must use `presentation: 'walletSidebar'`; `parent.key` must equal the group row's `key`, and member keys must be unique within the group. - **Default appearance:** the group border is drawn beneath member content. - Native groups ignore the row-level `backgroundColor`, while legacy Web still - applies it. `style.container` background, border and radius override the - group fill, border and radius on every platform. + Native groups ignore the row-level `backgroundColor` (legacy): the fill is + `style.container.backgroundColor` or the theme `subduedBackground`. Legacy Web + still applies `backgroundColor` inline, so use `style.container.backgroundColor` + for a cross-platform fill. `style.container` background, border and radius + override the group fill, border and radius on every platform. - **Drag:** only `draggable: false` excludes a member from starting the group drag; disabled members can start it. The compact allocation is 68 on iOS/Web and `max(68, parent height + 2 × vertical inset)` on Android. - **Style surface:** group `style` exposes the shared container appearance and horizontal/vertical padding. Text styles belong to `parent.style` or the individual `children[i].style`; there is no inherited group `title`/`value` text style. Measured group height is the sum of member - heights, 12-unit member gaps and twice the group `verticalPadding`. + heights, 12-unit member gaps and twice the vertical inset. The default inset + is 1 unit (the group border) when the parent member carries `height`, else 0; + `style.verticalPadding` / `horizontalPadding` replace it rather than adding + to it, and bind uses the same value as measurement on every platform. + iOS/Android ignore the group's own `row.height` (legacy; `style.container.height` + still wins); Web honors it. - **Member lifecycle:** each member uses an independent Identity renderer, reused by member key. Removed members release their images/actions immediately. Press and accessory events use the member key; group dragging remains atomic. @@ -163,8 +170,9 @@ separately from source implementation in STYLE_SPEC §9. semantic roles, not arbitrary per-native-label selectors. - **Layout boundary:** column order/count/weights are data, shared table column alignment is layout. Text styles cannot change those or move favorite/checkbox - controls into a data column. Default heights are 56, or 60 with secondary - text; Web table rows without secondary text are 48. + controls into a data column. Default heights (legacy, per platform): linear + 56, or 60 with secondary text (Android 64); table 60 with secondary text, + otherwise 48 on iOS/Web and 52 on Android (STYLE_SPEC §6.1). - **Linear layout per platform (legacy defaults):** - *iOS and Android, unstyled:* one label per column holding the primary text, up to two inline badge runs and the secondary text on a second line. @@ -173,13 +181,20 @@ separately from source implementation in STYLE_SPEC §9. - *iOS, styled:* the same single label. `columns`/`columnSecondary` apply font, weight, color, line height and alignment to their text runs/lines; `lines`, `truncate`, `verticalAlignment` and `offsetY` act on the whole - label; `titleBadgeGap` replaces the legacy two-space badge lead-in. - - *Android, styled (any non-empty `style`):* the separate column view is used - (primary line with pill badges, secondary line with secondary leading text), - so each role styles its own view. + label. Badge pills keep the legacy two-space padding inside their + background; `titleBadgeGap` adds an exact, unfilled gap between the title + and the first pill. Unstyled rows are unchanged. + - *Android, text-styled:* only `style.columns`, `columnSecondary`, `lineGap` + or `titleBadgeGap` switch a row off the legacy layout (single label per + column in linear; fixed 40dp column blocks with 20/16dp lines in table). + The separate column view is then used (primary line with pill badges, + secondary line with secondary leading text), so each role styles its own + view. Container, padding, image, `leadingGap` and `index` styles keep the + legacy text layout. - *Web:* `secondaryLeadingText` precedes the primary text on the same line, badges follow it, and secondary text is directly below. - - Table layout uses the column view on every platform. + - Table layout uses the column view on every platform (Android unstyled: + the fixed legacy column geometry above). - **Style isolation:** primary and secondary text use independent style targets in linear and table layouts on all three platforms. Column badges keep their own typography. @@ -280,9 +295,12 @@ separately from source implementation in STYLE_SPEC §9. action text; `warning` requires title/message; `noMatch` requires message; `end` has optional message; `spacer` requires explicit height. - **Text style roles:** `title` for a visible title, `message` for status copy, - `actionText` for the retry button text. On Web only the Market retry has a - button; a non-Market Web retry is message-only (the row press retries), so - `actionText` has no target there. A spacer has no text slot; skeleton/spinner styling + `actionText` for the native retry button text. Web (legacy) draws no retry + button for any presentation: a Web retry row is message-only and the row + press (click, Enter or Space) emits `actionKey`, so `actionText` and + `style.actionText` have no Web target (native only). On Android the Market + retry/noMatch message and the retry `actionText` ellipsize at the end by + default; an explicit `lines` or `truncate` (including `clip`) wins. A spacer has no text slot; skeleton/spinner styling does not become arbitrary drawing through these text roles. - **Layout boundary:** keep each variant's indicator/text/action structure and bounded height. Styling cannot convert loading into retry, turn a spacer into diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index 056d1e7c5..74e6c2a22 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -272,19 +272,25 @@ it does not add automatic overflow fitting or claim a performance benchmark. The renderer structure is retained; these legacy Web defaults are restored inside it. MetricCard/System default restoration keeps image loading visibility (a loaded image stays visible after content or style rebinding). WalletGroup -measurement adds the group's `style.verticalPadding` to the legacy member sum; -walletSidebar members keep their legacy 68/92 presets, which never applied the -`size` adjustment. The shared container pass records and clears its own inline -writes, so alignment set on template children does not leak into a reused, -unstyled row. DataRow measurement is layout-aware again (table rows without -secondary text are 48) and linear columns keep the legacy structure: secondary -leading text inline before the primary text, secondary text directly below. -Selector geometry follows either explicit height field. A disabled WalletGroup -member can start the group drag and emit its own actions again, and a -non-Market System retry is message-only with a row-press action, as in the -legacy engine. Text styling no longer -reads computed style, so attached and detached rows style identically. -Validation: package typecheck and 173 tests pass (jsdom/unit level); this is +measurement keeps the legacy member sum, 12-unit gaps and the 1px border pair +(budgeted only when the parent member carries `height`, like native's 1-unit +inset); `style.verticalPadding` replaces that inset instead of adding to it, and +the rendered padding matches the measurement (the border supplies the first +unit). walletSidebar members keep their legacy 68/92 presets, which never +applied the `size` adjustment. The shared container pass records its own inline +writes and the engine (and WalletGroup, per member) undoes them before the +renderer rebinds, so alignment set on template children does not leak into a +reused, unstyled row and a renderer value equal to the previous container value +is never reverted. DataRow measurement is layout-aware again (table rows +without secondary text are 48) and linear columns keep the legacy structure: +secondary leading text inline before the primary text, secondary text directly +below. Selector geometry follows either explicit height field. A disabled +WalletGroup member can start the group drag and emit its own actions again +while its member press stays blocked; title-help hover checks only the resolved +member. Every System retry (Market or not) is message-only with a row-press +action, as in the legacy engine. Text styling no longer reads computed style, so +attached and detached rows style identically. +Validation: package typecheck and 176 tests pass (jsdom/unit level); this is not new rendered browser or native device evidence. ### Platform interaction and appearance differences (legacy, retained) @@ -298,19 +304,39 @@ audit; they are not new in this change. System Retry, but still emits trailing/leading icon actions, Market badges, media close, title help and WalletGroup member actions; a disabled group does not block its members' actions on Android. Web (legacy) blocks row press and - actions on a disabled row or disabled WalletGroup, but not on a disabled - member inside an enabled group. + click actions on a disabled row or disabled WalletGroup. A disabled member + inside an enabled group still emits its own actions, but its member press is + blocked. +- **Web hover actions.** Web only: a title marked `titleActionOnHover` or an icon + accessory with `hoverActionKey` emits that action with an anchor on + `pointerover` (not on moves inside the element) and invalidates the anchor + with reason `pointerLeave` on `pointerout`. The check is the resolved source's + `disabled` only: a WalletGroup member's own flag, never the group's, so a + member of a disabled group still emits its hover action (legacy). Native has + no hover; the same keys fire on tap. - **System retry.** Android and iOS show a literal "Retry" button on non-Market retry rows and read `actionText` only for the Market retry. Web keeps its - legacy rendering: a non-Market retry shows only its message and the whole row - press emits `actionKey`; only the Market retry draws a button - (`actionText ?? 'Retry'`). `style.actionText` therefore has no target on a - non-Market Web retry row; it is accepted by validation and ignored there. -- **Pressed corners.** Native pressed feedback uses 12 on all corners (Rail 8, - MediaTile 16, wallet sidebar 20) regardless of `groupPosition`. Web changes - only the pressed background and keeps the resting (group or template) radius. + legacy rendering for every presentation: a retry row shows only its message + and the row press (click, Enter or Space) emits `actionKey` with a `row` + anchor; no retry button is drawn. `actionText` and `style.actionText` are + accepted by validation and have no Web target (native only). +- **Pressed corners.** Native pressed feedback ignores `groupPosition` and uses + 12 on all corners; templates with their own resting radius keep it (Rail 8, + MediaTile 16). iOS walletSidebar identity rows use 20; Android has no + wallet-sidebar exception (12). Web changes only the pressed background and + keeps the resting (group or template) radius. +- **WalletGroup row appearance.** The group's row-level `backgroundColor` is + ignored on iOS and Android (legacy): the group fill is + `style.container.backgroundColor` or the theme `subduedBackground`. Web + (legacy) applies it inline over the group fill. Use + `style.container.backgroundColor` for a cross-platform group fill. +- **WalletGroup height.** iOS and Android (legacy) ignore the group's + `row.height`: `style.container.height` wins, otherwise the height comes from + the members. Web (legacy) honors `row.height` for a WalletGroup like any other + row. - **Text direction.** `start`/`end` text alignment follows the layout direction - on all platforms. + on all platforms. On iOS a runtime layout-direction change re-resolves it: + visible rows and the fixed footer rebind immediately, offscreen rows on reuse. - **Separator inset.** Identity rows default to a 60-unit inset and other templates to 12 on native; Web defaults to 0 (STYLE_SPEC §6.2). - **DataRow linear layout.** See ROW_TEMPLATES.md: native unstyled linear rows diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index 83d90dcee..d0954f75d 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -273,9 +273,9 @@ does not change the typography or inner label/value gap of each metric. Variant applicability is also shared: composite metric-card `title` styles its heading, while standard-card `value`/`subtitle`/`trend` do not style nested metrics; -`system.title` applies to warning, `actionText` to the retry button. Web draws -that button only for the Market retry (legacy), so on a non-Market Web retry row -`actionText` has no target and is ignored; iOS/Android style their button. A missing leading visual, +`system.title` applies to warning, `actionText` to the native retry button. Web +(legacy) draws no retry button for any presentation, so `style.actionText` has +no Web target and is ignored there; iOS/Android style their button. A missing leading visual, badge, subtitle or trailing value remains absent. New nested-metric/thumbnail style roles require a separately declared cross-platform contract. @@ -659,12 +659,22 @@ Web registered estimate/DOM measurement. Preserved defaults still differ: | --- | --- | --- | --- | | `rail` | 40 | 28 | 40 | | `activity` + footer actions | 100 | 104 | 100 | -| `dataRow` + secondary text | 60 | 64 | 60 | -| `dataRow` table, no secondary text | — | — | 48 (56 − 8) | +| `dataRow` linear, no secondary text | 56 | 56 | 56 | +| `dataRow` linear + secondary text | 60 | **64** | 60 | +| `dataRow` table, no secondary text | 48 (56 − 8) | **52** (60 − 8) | 48 (56 − 8) | +| `dataRow` table + secondary text | 60 | 60 | 60 | | `sectionHeader` `summary` | 68 | 80 | 68 | | `sectionHeader` value + checkbox | 56 | 40 | 56 | | `message`, `mediaTile`, composite `metricCard` | computed / 244 / 160+ | 0 (wrap content) | computed / 244 / 161 | +DataRow provenance (pre-migration engines at `52849b86f`): iOS +`RNCNativeListView.rowHeight()` used base 56, or 60 with secondary text, then +−8 in table layout without secondary text; legacy Web applied the same rule. +Android `NativeListRowView` used base 60 for every table row (−8 without +secondary text) and 64/56 for linear rows with/without secondary text. The +registered renderers return these values directly; `size` and explicit heights +apply as for other templates. + Message now uses its resolved text/spacing/image metrics for intrinsic measurement; iOS measures the actual allocated column width and Web corrects its conservative estimate from the rendered DOM. This explicitly fixes the old @@ -690,9 +700,10 @@ here and left alone. | visual `rounded` radius | `min(10, h/4)` | `min(10, size/4)`; 8 for accountSelector | **10px**; 8 `!important` for account action; 8 for market | | `walletSidebar` row radius | **20** | 12 | 12 | | `identity` subtitle lines (no `subtitleLines`) | 1 | **2** | 1 (CSS single line) | -| pressed row corners | 12 all corners (rail 8, mediaTile 16, wallet sidebar 20), ignoring `groupPosition` | same as iOS | background only; resting group/template radius kept **≠** | -| `walletGroup` row `backgroundColor` | ignored | ignored | applied **≠** | -| non-Market System retry | literal "Retry" button | literal "Retry" button | no button; message only, row press retries **≠** | +| pressed row corners | 12 all corners (rail 8, mediaTile 16 resting, wallet sidebar **20**), ignoring `groupPosition` | 12 all corners (rail 8, mediaTile 16 resting); no wallet-sidebar exception | background only; resting group/template radius kept **≠** | +| `walletGroup` row `backgroundColor` | ignored (`style.container.backgroundColor` or theme `subduedBackground`) | ignored (same) | applied inline **≠** | +| `walletGroup` `row.height` | ignored (members / `style.container.height`) | ignored (same) | honored **≠** | +| System retry | literal "Retry" button (non-Market); `actionText` button (Market) | same as iOS | no button for any presentation; message only, row press retries **≠** | | separator default inset | 60 identity / 12 | dp(60) identity / dp(12) | **0** | | separator thickness | `1 / scale` (hairline) | `1f` raw px | `1px` | @@ -714,6 +725,13 @@ exposed — a hairline is correct on iOS and a whole pixel is correct elsewhere. `NativeListTableColumnView`, the market skeleton, and the section index never follow it. Explicit non-Market row styles now use density directly, so this legacy baseline policy cannot scale an explicitly supplied style value. + Under source scaling, accountSelector/networkSelector/walletSidebar rows apply + the legacy selector typography (whole-pixel unscaled size, fractional advances, + original line box) to every text in the row, including trailing accessory + text and the action leading fallback text; a `fontSize` or `lineHeight` set in + that slot's style is applied as given. A walletSidebar leading visual under + source scaling draws a 1dp dashed border (else 2dp); its text-only overlays + are 16dp pills with 12sp text on a 16dp line, and other text overlays use 10sp. - **`accent` is not an accent.** The consumer maps the application's `iconActive` onto the theme key `accent`. The name should be retired in favour of an alias. @@ -914,9 +932,13 @@ the contract; no shared cross-language renderer or code generator is required. ### Verification — 2026-09-24 (Web legacy-default audit) -- Package typecheck passes; 173 package tests in five suites pass, including +- Package typecheck passes; 176 package tests in five suites pass, including regressions for loaded MetricCard images across content/style rebinding, - WalletGroup vertical padding in measured height, container alignment cleared + WalletGroup vertical padding replacing the legacy inset in measured and + rendered height, message-only retry rows (Market included) firing on click + and Enter, member-only hover and member-press blocking for WalletGroup, + renderer values equal to the previous container write surviving a rebind, + container alignment cleared from reused Activity/DataRow children, legacy table DataRow height (48) and linear column structure, selector geometry from `style.container.height`, attached/detached text diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index 63aeadb59..9f732c000 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -2056,7 +2056,8 @@ describe('web row style', () => { identity.querySelector('[data-nl-slot="subtitle"]')!.style .fontSize ).toBe('18px'); - // Only the Market retry has a Web retry button (legacy Web). + // Legacy Web draws no retry button, so `style.actionText` has no Web + // target (it styles the native retry button only). const retry = render({ type: 'system', key: 's', @@ -2065,14 +2066,14 @@ describe('web row style', () => { message: 'Failed', actionText: 'Try again', actionKey: 'retry', - style: { actionText: { fontSize: 20 } }, + style: { actionText: { fontSize: 20 }, message: { fontSize: 15 } }, }); - const action = retry.querySelector( - '[data-nl-slot="actionText"]' - )!; - expect(action.textContent).toBe('Try again'); - expect(action.style.fontSize).toBe('20px'); - expect(action.dataset.nativeListAction).toBe('retry'); + expect(retry.querySelector('[data-nl-slot="actionText"]')).toBeNull(); + expect(retry.querySelector('button')).toBeNull(); + expect( + retry.querySelector('[data-nl-slot="message"]')!.style + .fontSize + ).toBe('15px'); }); it('applies header accessory spacing and keeps rail badge before status', () => { @@ -2692,7 +2693,7 @@ describe('complex renderer lifecycle', () => { expect(body.children[2]).toBe(member); expect(member.querySelector('img')).toBe(imageElement); expect(member.style.height).toBe('96px'); - expect(body.style.paddingInline).toBe('9px'); + expect(body.style.paddingInline).toBe('8px'); expect( body.querySelector('[data-nl-slot="title"]')!.style .fontSize @@ -2786,9 +2787,73 @@ describe('complex renderer lifecycle', () => { 'child' ) ).toBe(false); + // Legacy: the disabled member's own press stays blocked. + host + .querySelector( + '[data-native-list-group-member-key="child"]' + )! + .click(); + expect(onRowAction).toHaveBeenCalledTimes(2); + } finally { + engine.destroy(); + global.Element = previousElement; + } + }); + it('keeps legacy WalletGroup member hover actions keyed on the member only', () => { + const { document } = new JSDOM('').window; + const window = document.defaultView!; + const host = document.createElement('div'); + document.body.appendChild(host); + const member: IdentityRow = { + type: 'identity', + key: 'child', + presentation: 'walletSidebar', + title: 'Child', + leading: { kind: 'icon', name: 'StarOutline' }, + titleActionKey: 'help', + titleActionOnHover: true, + }; + const row: Extract = { + type: 'walletGroup', + key: 'parent', + disabled: true, + parent: { ...member, key: 'parent', titleActionKey: undefined }, + children: [member], + }; + const onRowAction = jest.fn(); + const engine = new NativeListWebEngine( + host, + snap(row), + { onRowAction }, + false + ); + const previousElement = global.Element; + const previousNode = global.Node; + global.Element = document.defaultView!.Element; + global.Node = document.defaultView!.Node; + const hover = () => + host + .querySelector( + '[data-native-list-group-member-key="child"] [data-native-list-hover-action]' + )! + .dispatchEvent(new window.Event('pointerover', { bubbles: true })); + try { + // Legacy Web checks only the member's `disabled`, not the group's. + hover(); + expect(onRowAction).toHaveBeenCalledTimes(1); + expect(onRowAction.mock.calls[0]![0]).toMatchObject({ + rowKey: 'child', + actionKey: 'help', + }); + engine.applySnapshot( + snap({ ...row, children: [{ ...member, disabled: true }] }) + ); + hover(); + expect(onRowAction).toHaveBeenCalledTimes(1); } finally { engine.destroy(); global.Element = previousElement; + global.Node = previousNode; } }); }); @@ -2902,14 +2967,27 @@ describe('legacy default regressions', () => { expect(estimateWebRowHeight(padded, snap([padded]), 320)).toBe( unstyled + 20 ); - // Explicit-height selector members keep the legacy 2-unit border budget. + // An explicit parent `height` keeps the legacy 2-unit border budget + // (native: a 1-unit inset); a container-only height does not. const explicit = { - ...padded, - parent: { ...group.parent, style: { container: { height: 60 } } }, + ...group, + parent: { ...group.parent, height: 60 }, }; expect(estimateWebRowHeight(explicit, snap([explicit]), 320)).toBe( - 60 + 92 + 68 + 2 * 12 + 2 + 20 + 60 + 92 + 68 + 2 * 12 + 2 ); + const containerOnly = { + ...group, + parent: { ...group.parent, style: { container: { height: 60 } } }, + }; + expect( + estimateWebRowHeight(containerOnly, snap([containerOnly]), 320) + ).toBe(60 + 92 + 68 + 2 * 12); + // `verticalPadding` replaces that inset instead of adding to it (native). + const explicitPadded = { ...explicit, style: { verticalPadding: 10 } }; + expect( + estimateWebRowHeight(explicitPadded, snap([explicitPadded]), 320) + ).toBe(60 + 92 + 68 + 2 * 12 + 20); const { host, engine } = mount(snap([padded])); try { @@ -2924,7 +3002,9 @@ describe('legacy default regressions', () => { const body = host.querySelector( '.ok-native-list-wallet-group' )!; - expect(body.style.paddingBlock).toBe('10px'); + // The 1px group border is the first unit of the 10-unit inset, so the + // rendered box matches the measured height exactly. + expect(body.style.paddingBlock).toBe('9px'); expect( host.querySelector('.ok-native-list-item')?.style.height ).toBe(`${unstyled + 20}px`); @@ -2933,6 +3013,41 @@ describe('legacy default regressions', () => { } }); + it('keeps a renderer value that equals the previous container write', () => { + const plain: IdentityRow = { + type: 'identity', + key: 'selector', + leading: { kind: 'icon', name: 'StarOutline' }, + title: 'Selector', + style: { container: { cornerRadius: 12 } }, + }; + const { host, engine } = mount(snap([plain])); + const body = () => + host.querySelector('[data-nl-renderer="identity"]')!; + try { + const reused = body(); + expect(reused.style.borderRadius).toBe('12px'); + // The accountSelector preset re-writes the same 12px radius itself; the + // stale container write must not revert it on this rebind. + engine.applySnapshot( + snap([ + { + ...plain, + presentation: 'accountSelector', + height: 76, + style: undefined, + }, + ]) + ); + expect(body()).toBe(reused); + expect(reused.style.borderRadius).toBe('12px'); + engine.applySnapshot(snap([{ ...plain, style: undefined }])); + expect(reused.style.borderRadius).toBe(''); + } finally { + engine.destroy(); + } + }); + it('clears container alignment from reused Activity and DataRow children', () => { const activity: Extract = { type: 'activity', @@ -3182,7 +3297,7 @@ describe('legacy default regressions', () => { } }); - it('renders non-Market retry as legacy message-only row that fires on press', () => { + it('renders every retry as a legacy message-only row that fires on press', () => { const retry: RowModel = { type: 'system', key: 'retry', @@ -3201,27 +3316,44 @@ describe('legacy default regressions', () => { { onRowAction }, false ); + const domWindow = window as unknown as { + Element: typeof Element; + HTMLElement: typeof HTMLElement; + KeyboardEvent: typeof KeyboardEvent; + }; const previousElement = global.Element; - global.Element = (window as unknown as { Element: typeof Element }).Element; + const previousHTMLElement = global.HTMLElement; + global.Element = domWindow.Element; + global.HTMLElement = domWindow.HTMLElement; try { - const [plain, market] = Array.from( + const bodies = Array.from( host.querySelectorAll('[data-nl-renderer="system"]') ); - expect(plain!.querySelector('button')).toBeNull(); - expect(plain!.querySelector('[data-nl-slot="actionText"]')).toBeNull(); - expect(plain!.textContent).toBe('Failed to load'); - expect( - market!.querySelector('[data-nl-slot="actionText"]')?.textContent - ).toBe('Try again'); - plain!.click(); - expect(onRowAction).toHaveBeenCalledTimes(1); - expect(onRowAction.mock.calls[0]![0]).toMatchObject({ - rowKey: 'retry', - actionKey: 'reload', - }); + expect(bodies).toHaveLength(2); + for (const body of bodies) { + expect(body.querySelector('button')).toBeNull(); + expect(body.querySelector('[data-nl-slot="actionText"]')).toBeNull(); + expect(body.textContent).toBe('Failed to load'); + } + bodies[0]!.click(); + bodies[1]!.click(); + bodies[1]! + .closest('[data-native-list-row-key]')! + .dispatchEvent( + new domWindow.KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + }) + ); + expect(onRowAction.mock.calls.map(([event]) => event)).toEqual([ + expect.objectContaining({ rowKey: 'retry', actionKey: 'reload' }), + expect.objectContaining({ rowKey: 'market', actionKey: 'reload' }), + expect.objectContaining({ rowKey: 'market', actionKey: 'reload' }), + ]); } finally { engine.destroy(); global.Element = previousElement; + global.HTMLElement = previousHTMLElement; } }); }); diff --git a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts index 925e5b4d2..0f50152d1 100644 --- a/native-views/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/native-views/react-native-native-list/src/web/NativeListWebEngine.ts @@ -1,4 +1,7 @@ -import { applyRowContainerStyle } from './templates/RowContainerStyle'; +import { + applyRowContainerStyle, + resetRowContainerStyle, +} from './templates/RowContainerStyle'; import { hasExplicitRowHeight, rowSizeModifier } from './templates/RowElements'; import { applyTextStyleToSlot, applyValueSegments } from './templates/RowText'; import { setMarketQuoteContent } from './templates/MarketRowRenderer'; @@ -2777,7 +2780,10 @@ export class NativeListWebEngine { itemIndex: index, }; const body = reusableBody ?? createRowBody(context, row); - if (reusableBody) registered.bind(body, rendererPrimitives(context)); + if (reusableBody) { + resetRowContainerStyle(body); + registered.bind(body, rendererPrimitives(context)); + } // OneKey patch: explicit selector fields preserve original page geometry. element.style.contain = row.backgroundFullWidth ? 'layout style' : ''; if (row.backgroundColor) body.style.backgroundColor = row.backgroundColor; @@ -3669,7 +3675,9 @@ export class NativeListWebEngine { action.dataset.nativeListHoverAction === 'true' ? action.dataset.nativeListAction : action.dataset.nativeListHoverAction; - if (sourceRow && !row?.disabled && !sourceRow.disabled && actionKey) + // Legacy Web: hover checks only the resolved source (the member for a + // WalletGroup), not the enclosing row's `disabled`. + if (sourceRow && !sourceRow.disabled && actionKey) this.emitRowAction(sourceRow, actionKey, action, rowElement ?? undefined); }; @@ -3713,8 +3721,9 @@ export class NativeListWebEngine { (member) => member.key === memberKey ) ?? row : row; - // Legacy Web: a disabled WalletGroup member does not block its own - // actions or press; only a disabled row (or group) does. + // Legacy Web: a disabled row (or whole WalletGroup) blocks everything + // above. A disabled WalletGroup member still emits its own actions, but its + // member press is blocked by `isRowPressEnabled` in `handleRowPress`. const action = target.closest('[data-native-list-action]'); if (action) { const scope = action.dataset.selectionScope; diff --git a/native-views/react-native-native-list/src/web/templates/RowContainerStyle.ts b/native-views/react-native-native-list/src/web/templates/RowContainerStyle.ts index b8a98fb88..2aaedf4c5 100644 --- a/native-views/react-native-native-list/src/web/templates/RowContainerStyle.ts +++ b/native-views/react-native-native-list/src/web/templates/RowContainerStyle.ts @@ -9,10 +9,10 @@ type Write = { }; // Inline properties written by the container pass, per row body. Renderers only -// reset the nodes they own, so the container clears its own writes on rebind. +// reset the nodes they own, so the container undoes its own writes on rebind. const writesByBody = new WeakMap(); -function restoreContainerWrites(body: HTMLElement) { +function restoreContainerWrites(body: HTMLElement, force: boolean) { body.removeAttribute('data-nl-container-background'); body.removeAttribute('data-nl-container-border'); const writes = writesByBody.get(body); @@ -22,14 +22,27 @@ function restoreContainerWrites(body: HTMLElement) { for (let index = writes.length - 1; index >= 0; index -= 1) { const write = writes[index]!; const { style } = write.element; - // A renderer that already reset this property owns its current value. - if (style.getPropertyValue(write.property) !== write.written) continue; + // Fallback path only (no reset before the renderer bind): a renderer that + // already rewrote this property owns its current value. It cannot tell a + // renderer rewrite of the same value apart, which is why the engine and + // WalletGroup reset before binding instead. + if (!force && style.getPropertyValue(write.property) !== write.written) + continue; style.removeProperty(write.property); if (write.previous) style.setProperty(write.property, write.previous, write.previousPriority); } } +/** + * Undo the previous container pass before a renderer rebinds `body`, so the + * renderer always writes over its own baseline and the next + * `applyRowContainerStyle` records that fresh value as the value to restore. + */ +export function resetRowContainerStyle(body: HTMLElement): void { + restoreContainerWrites(body, true); +} + function writeProperty( body: HTMLElement, element: HTMLElement, @@ -53,7 +66,7 @@ function writeProperty( } export function applyRowContainerStyle(body: HTMLElement, row: RowModel): void { - restoreContainerWrites(body); + restoreContainerWrites(body, false); const set = (element: HTMLElement, property: string, value: string) => writeProperty(body, element, property, value); if ( diff --git a/native-views/react-native-native-list/src/web/templates/SystemRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/SystemRowRenderer.ts index 1594b8c7c..cd7c88633 100644 --- a/native-views/react-native-native-list/src/web/templates/SystemRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/SystemRowRenderer.ts @@ -6,7 +6,6 @@ import { restoreInlineStyles, setData, tagSlot, - markActionAnchorSource, } from './RowElements'; function createSystemRow( document: Document, @@ -146,28 +145,10 @@ function createSystemRow( 'message' ) ); - // Legacy Web: only the Market retry draws a button. A non-Market retry shows - // its message and the whole row triggers the retry action, so - // `style.actionText` has no target there. - if ( - row.variant === 'retry' && - 'presentation' in row && - row.presentation === 'market' - ) { - const action = tagSlot( - createElement( - document, - 'button', - 'ok-native-list-action-button', - row.actionText ?? 'Retry' - ), - 'actionText' - ); - action.setAttribute('type', 'button'); - setData(action, 'nativeListAction', row.actionKey); - markActionAnchorSource(action, 'trailingAccessory', 0); - body.appendChild(action); - } + // Legacy Web draws no retry button for any presentation: a retry row shows + // only its message and the whole row (click, Enter or Space) emits the + // retry `actionKey`. `actionText` / `style.actionText` therefore have no Web + // target; they style the native retry button only. return body; } @@ -201,7 +182,7 @@ function bind(body: HTMLElement, row: SystemRow, primitives: RowPrimitives) { body.style.paddingBlock = `${style.verticalPadding}px`; if (style?.lineGap !== undefined && row.variant === 'warning') body.style.rowGap = `${style.lineGap}px`; - for (const slot of ['title', 'message', 'actionText'] as const) { + for (const slot of ['title', 'message'] as const) { const text = style?.[slot]; if (!text) continue; body diff --git a/native-views/react-native-native-list/src/web/templates/WalletGroupRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/WalletGroupRowRenderer.ts index 0916a284e..83c15b4b8 100644 --- a/native-views/react-native-native-list/src/web/templates/WalletGroupRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/WalletGroupRowRenderer.ts @@ -1,7 +1,10 @@ import type { IdentityRow, WalletGroupRow } from '../../models'; import { identityRowRenderer } from './IdentityRowRenderer'; -import { applyRowContainerStyle } from './RowContainerStyle'; -import { hasExplicitRowHeight, setData } from './RowElements'; +import { + applyRowContainerStyle, + resetRowContainerStyle, +} from './RowContainerStyle'; +import { setData } from './RowElements'; import type { RowPrimitives } from './RowVisual'; type Member = { wrapper: HTMLElement; body: HTMLElement }; @@ -33,14 +36,24 @@ function bind( for (const name of body.getAttributeNames()) if (name.startsWith('data-') && name !== 'data-nl-renderer') body.removeAttribute(name); + // The group's legacy 1px border is its default one-unit inset (native: a + // 1-unit padding when the parent has `height`). A style padding replaces that + // inset, so the border supplies its first unit and CSS padding the rest; the + // edge-to-content distance is then exactly the style value, as on native and + // as `measure` budgets it. if (row.style?.horizontalPadding !== undefined) - body.style.paddingInline = `${row.style.horizontalPadding}px`; + body.style.paddingInline = `${Math.max( + 0, + row.style.horizontalPadding - 1 + )}px`; if (row.style?.verticalPadding !== undefined) - body.style.paddingBlock = `${row.style.verticalPadding}px`; + body.style.paddingBlock = `${Math.max(0, row.style.verticalPadding - 1)}px`; [row.parent, ...row.children].forEach((memberRow, index) => { let member = members.get(memberRow.key); - if (member) identityRowRenderer.bind(member.body, memberRow, primitives); - else { + if (member) { + resetRowContainerStyle(member.body); + identityRowRenderer.bind(member.body, memberRow, primitives); + } else { const wrapper = body.ownerDocument.createElement('div'); wrapper.className = 'ok-native-list-wallet-member'; const memberBody = identityRowRenderer.create( @@ -100,16 +113,17 @@ export const walletGroupRowRenderer = { bind, recycle, appliesSizePreset: () => false, - // Legacy group height (the 1px border pair is budgeted for explicit-height - // selector members) plus the group's own resolved vertical padding. + // Legacy group height: members, 12-unit gaps and the 1px border pair, which + // (like native's 1-unit inset) is budgeted only when the parent carries + // `height`. `style.verticalPadding` replaces that inset, as on native. measure: (row: WalletGroupRow, width: number) => [row.parent, ...row.children].reduce( (total, member) => total + memberHeight(member, width), 0 ) + row.children.length * 12 + - (hasExplicitRowHeight(row.parent) ? 2 : 0) + - (row.style?.verticalPadding ?? 0) * 2, + (row.style?.verticalPadding ?? (row.parent.height !== undefined ? 1 : 0)) * + 2, measureRendered: ( _body: HTMLElement, _row: WalletGroupRow From 2dcd575e282b2ee78b1f382c4601e6b2db5acca5 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 15:00:11 +0800 Subject: [PATCH 68/71] fix(native-list/android): honor container height for selectors, rebind footer - Gate selector explicit-height geometry on style.container.height or row.height, matching Web hasExplicitRowHeight. The list-wide selector source scale stays triggered by row.height only (no list-wide side effects from style). - Rebind the fixed footer when only listStyle changes on the stable path. - Keep Market subscript runs at the legacy ceil(0.6 x fontSize) when an explicit fontSize is set. Co-Authored-By: Claude Opus 5.5 --- .../nativelist/NativeListAccessoryStack.kt | 6 +++--- .../nativelist/NativeListActionRenderer.kt | 6 +++--- .../nativelist/NativeListIdentityRenderer.kt | 20 +++++++++---------- .../nativelist/NativeListMarketRenderer.kt | 3 +-- .../com/onekey/nativelist/NativeListModels.kt | 4 ++++ .../NativeListSectionHeaderRenderer.kt | 10 +++++----- .../com/onekey/nativelist/NativeListView.kt | 5 ++++- 7 files changed, 30 insertions(+), 24 deletions(-) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt index 86132ca08..5e4eac6ba 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAccessoryStack.kt @@ -336,7 +336,7 @@ internal class NativeListAccessoryStack(context: android.content.Context) : Line } checkbox.visibility = VISIBLE checkbox.setState(state, checkboxIconColor) - val usesSourceCheckboxGeometry = checkboxUsesSelectorStyle && item.json.has("height") + val usesSourceCheckboxGeometry = checkboxUsesSelectorStyle && item.hasExplicitHeight checkbox.usesSelectorGeometry = usesSourceCheckboxGeometry if (usesSourceCheckboxGeometry) { // OneKey patch: source Yoga children may round into padding; retain their complete border. @@ -514,8 +514,8 @@ internal class NativeListAccessoryStack(context: android.content.Context) : Line icon.contentDescription = data.optString("accessibilityLabel").takeIf { it.isNotEmpty() } if (item.json.optString("presentation") == "accountSelector") { icon.glyphSizeDp = 24 - val isSourceMenu = item.json.has("height") && icon.iconName == "DotHorOutline" - val size = if (item.json.has("height") && icon.iconName == "PlusSmallOutline") 36 else 38 + val isSourceMenu = item.hasExplicitHeight && icon.iconName == "DotHorOutline" + val size = if (item.hasExplicitHeight && icon.iconName == "PlusSmallOutline") 36 else 38 icon.layoutParams = LayoutParams(dp(size), dp(size)).apply { gravity = Gravity.CENTER_VERTICAL diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActionRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActionRenderer.kt index d318d41ff..01330da93 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActionRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActionRenderer.kt @@ -51,7 +51,7 @@ internal class NativeListActionRowView(context: ThemedReactContext) : else -> Gravity.CENTER_VERTICAL } val selector = item.json.optString("presentation") == "accountSelector" - visual.roundedGlyphOrigin = selector && item.json.has("height") + visual.roundedGlyphOrigin = selector && item.hasExplicitHeight val descriptors = item.json.optJSONObject("checkbox")?.let { JSONArray().put(it) } ?: item.json.optJSONArray("trailing") @@ -121,7 +121,7 @@ internal class NativeListActionRowView(context: ThemedReactContext) : marginEnd = if (style.has("leadingGap")) stylePx(style.optDouble("leadingGap")) // Legacy: Yoga rounds the cumulative selector edges, not each gap. - else if (selector && item.json.has("height")) dp(56) - dp(12) - dp(32) + else if (selector && item.hasExplicitHeight) dp(56) - dp(12) - dp(32) else dp(12) } visual.bind(source, image, item.key, theme, false, sourceScale) @@ -136,7 +136,7 @@ internal class NativeListActionRowView(context: ThemedReactContext) : super.onLayout(changed, l, t, r, b) val item = tag as? NativeListItem ?: return if ( - item.json.has("height") && + item.hasExplicitHeight && item.json.optString("presentation") == "accountSelector" && item.json .optJSONObject("style") diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt index c9f31d0f8..6d53c4c06 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt @@ -47,7 +47,7 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : ) = if ( item.json.optString("presentation") == "walletSidebar" || - item.json.has("height") && + item.hasExplicitHeight && item.json.optString("presentation") in setOf("accountSelector", "networkSelector") ) "single" @@ -307,7 +307,7 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : else 18 leadingFrame.walletTextOverlays = walletSource leadingFrame.roundedImageRadius = - if (item.json.optString("presentation") == "accountSelector" && item.json.has("height")) 8 + if (item.json.optString("presentation") == "accountSelector" && item.hasExplicitHeight) 8 else 10 leadingFrame.bind(descriptor, style, item.key, currentTheme, false, sourceScale) leadingFallback.typeface = NativeListFonts.bold(context) @@ -323,7 +323,7 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : // Legacy: Yoga rounds cumulative selector edges, not each gap separately. marginEnd = if ( - item.json.has("height") && + item.hasExplicitHeight && item.json.optString("presentation") in setOf("accountSelector", "networkSelector") ) dp(12 + sizeDp + spacingDp) - dp(12) - dp(sizeDp) @@ -359,7 +359,7 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : super.onMeasure(widthMeasureSpec, heightMeasureSpec) val item = currentItem ?: return if ( - item.json.has("height") && + item.hasExplicitHeight && item.json.optString("presentation") == "networkSelector" && trailingColumn.hasCheckbox && trailingColumn.parent === this @@ -389,7 +389,7 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : ?.has("contentVerticalAlignment") == true ) return - if (!item.json.has("height")) return + if (!item.hasExplicitHeight) return val accessory = item.json.optJSONArray("trailing")?.optJSONObject(0) if ( item.json.optString("presentation") == "accountSelector" && @@ -445,7 +445,7 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : title.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) title.gravity = Gravity.CENTER // OneKey patch: this branch returns before the common identity ellipsis setup. - if (item.json.has("height")) title.ellipsize = TextUtils.TruncateAt.END + if (item.hasExplicitHeight) title.ellipsize = TextUtils.TruncateAt.END showText(title, item.json.optString("title"), 1) title.setTextColor( color( @@ -458,7 +458,7 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : mainColumn, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { // OneKey patch: snap the source 4 + 40 + 4 sequence once instead of rounding each gap. - topMargin = if (item.json.has("height")) dp(48) - dp(44) else dp(4) + topMargin = if (item.hasExplicitHeight) dp(48) - dp(44) else dp(4) }, ) // OneKey patch: wallet tags are a centered line below the wallet name. @@ -466,7 +466,7 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : .optJSONArray("badges") ?.takeIf { it.length() > 0 } ?.let { badges -> - val isSelector = item.json.has("height") + val isSelector = item.hasExplicitHeight val badgeLineHeight = if (isSelector) 14 else 16 val badgeHeight = badgeLineHeight + 4 val line = @@ -537,7 +537,7 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : if (item.json.optString("presentation") == "networkSelector") { // OneKey patch: the explicit 48-point row centers its 32-point network icon. // setPadding(dp(12), dp(7), dp(12), dp(8)) - setPadding(dp(12), dp(if (item.json.has("height")) 8 else 7), dp(12), dp(8)) + setPadding(dp(12), dp(if (item.hasExplicitHeight) 8 else 7), dp(12), dp(8)) } val leading = item.json.optJSONObject("leading") item.json.optJSONObject("leadingAction")?.let { action -> @@ -681,7 +681,7 @@ internal class NativeListIdentityRowView(context: ThemedReactContext) : addView(trailingColumn, wrap()) val accessories = item.json.optJSONArray("trailing") ?: JSONArray() if ( - item.json.has("height") && + item.hasExplicitHeight && item.json.optString("presentation") == "networkSelector" && accessories.hasAccessory("checkbox") ) { diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMarketRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMarketRenderer.kt index 7d32d687f..0cab624e0 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMarketRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMarketRenderer.kt @@ -367,9 +367,8 @@ internal class NativeListMarketRowView(context: ThemedReactContext) : kotlin.math.ceil(sourceSize.toDouble()).toFloat(), ) } + // Legacy parity: keep marketText's subscript AbsoluteSizeSpan (ceil(0.6 x fontSize)). val text = SpannableStringBuilder(view.text) - if (style.has("fontSize")) - text.getSpans(0, text.length, AbsoluteSizeSpan::class.java).forEach(text::removeSpan) text.getSpans(0, text.length, NativeListLineHeightSpan::class.java).forEach(text::removeSpan) if (style.has("lineHeight")) { val lineHeight = stylePx(style.optDouble("lineHeight")) diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index dd9ef4f50..52f7f715c 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -65,6 +65,10 @@ internal data class NativeListItem( val styledHeight: Double? get() = json.optJSONObject("style")?.optJSONObject("container") ?.takeIf { it.has("height") }?.optDouble("height") + // Selector presentations use their explicit-height geometry when either explicit height field + // is set (Web `hasExplicitRowHeight`). Row-local only; list-wide source scale stays row.height. + val hasExplicitHeight: Boolean + get() = styledHeight != null || json.has("height") // OneKey patch: only a host-validated stable snapshot can request a lightweight diff payload. var selectionUpdateFromContent: String? = null diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt index 1d6dc7d92..ae48631bb 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt @@ -228,7 +228,7 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : title.typeface = when { network && - item.json.has("height") && + item.hasExplicitHeight && variant != "summary" && (item.json.optJSONObject("checkbox") != null || item.json.optString("titleActionKey").isEmpty()) -> NativeListFonts.semibold(context) @@ -252,7 +252,7 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : if (layout == "table") TextViewCompat.setLineHeight(trailingViews[0], dp(14)) if ( network && - item.json.has("height") && + item.hasExplicitHeight && variant != "summary" && item.json.optString("titleActionKey").isEmpty() && item.json.optJSONObject("checkbox") == null @@ -359,7 +359,7 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : super.onMeasure(widthMeasureSpec, heightMeasureSpec) val item = currentItem ?: return if ( - item.json.has("height") && + item.hasExplicitHeight && item.json.optString("presentation") == "networkSelector" && checkboxControl.visibility == VISIBLE && trailingColumn.parent === this @@ -383,7 +383,7 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : super.onLayout(changed, left, top, right, bottom) val item = currentItem ?: return if ( - item.json.has("height") && + item.hasExplicitHeight && item.json.optString("presentation") == "networkSelector" && item.json.optString("variant") == "summary" && item.json @@ -428,7 +428,7 @@ internal class NativeListSectionHeaderRowView(context: ThemedReactContext) : } val isNetworkSelector = item.json.optString("presentation") == "networkSelector" val isHistory = variant == "history" - val isExplicitNetworkHeader = isNetworkSelector && item.json.has("height") + val isExplicitNetworkHeader = isNetworkSelector && item.hasExplicitHeight val hasDottedTitle = isSummary || (isNetworkSelector && diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt index 50f0eaa76..db3bb0583 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt @@ -569,6 +569,9 @@ class NativeListView( adapter.layout = next.layout adapter.orientation = next.orientation adapter.selectedKeys = next.selectedKeys + // Rows rebind through the adapter's listStyle invalidation; the fixed footer is bound + // outside the adapter, so a listStyle-only snapshot must rebind it here. + if (previous.listStyle?.toString() != next.listStyle?.toString()) bindFooter(next) adapter.submitList(next.items) { // OneKey patch: DiffUtil has dispatched its payloads; release the old serialized rows. next.items.forEach { it.selectionUpdateFromContent = null } @@ -2744,7 +2747,7 @@ private class ItemSpacingDecoration( if (spacing <= 0) return val horizontal = (parent.layoutManager as? LinearLayoutManager)?.orientation == RecyclerView.HORIZONTAL val item = view.tag as? NativeListItem - val sourceWallet = item?.type == "identity" && item.json.optString("presentation") == "walletSidebar" && item.json.has("height") + val sourceWallet = item?.type == "identity" && item.json.optString("presentation") == "walletSidebar" && item.hasExplicitHeight // OneKey patch: V1 measures the wallet and its bottom padding as one sortable row. val itemSpacing = if (!horizontal && sourceWallet) { val sourceHeight = item!!.styledHeight ?: item.json.optDouble("height") From 4b64ee44cf4f1585e6a7773bd636b0ae940abb90 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 15:00:11 +0800 Subject: [PATCH 69/71] fix(native-list/ios): honor container height for selectors, walletGroup fill - Gate selector explicit-height geometry on style.container.height or row.height, matching Web hasExplicitRowHeight. - Ignore row-level backgroundColor for walletGroup in both the resting and full-width fills, as documented; style.container.backgroundColor applies. - Keep Market subscript runs at the legacy ceil(0.6 x fontSize) when an explicit fontSize is set. Co-Authored-By: Claude Opus 5.5 --- .../ios/NativeListAccessoryStack.swift | 6 +++--- .../ios/NativeListIdentityRenderer.swift | 16 ++++++++-------- .../ios/NativeListMarketRenderer.swift | 5 ++--- .../ios/NativeListModels.swift | 6 ++++++ .../ios/NativeListRendererCell.swift | 10 ++++++++-- .../ios/NativeListSectionHeaderRenderer.swift | 14 +++++++------- .../ios/NativeListWalletGroupRenderer.swift | 2 ++ 7 files changed, 36 insertions(+), 23 deletions(-) diff --git a/native-views/react-native-native-list/ios/NativeListAccessoryStack.swift b/native-views/react-native-native-list/ios/NativeListAccessoryStack.swift index 66ab252e2..47b6325e7 100644 --- a/native-views/react-native-native-list/ios/NativeListAccessoryStack.swift +++ b/native-views/react-native-native-list/ios/NativeListAccessoryStack.swift @@ -247,7 +247,7 @@ final class NativeListAccessoryStack: UIStackView { case "value": showAccessory(textIndex, accessory.string("text")) semanticValueButtons.append(accessoryButtons[textIndex]) - if item.data.string("presentation") == "networkSelector" && item.data["height"] != nil { + if item.data.string("presentation") == "networkSelector" && item.hasExplicitHeight { accessoryButtons[textIndex].contentHorizontalAlignment = .trailing accessoryButtons[textIndex].titleLabel?.textAlignment = .right } @@ -469,7 +469,7 @@ final class NativeListAccessoryStack: UIStackView { button.alpha = button.isEnabled ? 1 : 0.4 let isAccountCreate = currentItem?.data.string("presentation") == "accountSelector" - && currentItem?.data["height"] != nil && data.string("name") == "PlusSmallOutline" + && currentItem?.hasExplicitHeight == true && data.string("name") == "PlusSmallOutline" let tintColor = data["tintColor"] == nil && isAccountCreate ? nativeListColor(currentTheme, "iconSubdued", "#8D8D8D") @@ -532,7 +532,7 @@ final class NativeListAccessoryStack: UIStackView { current.flatMap { $0.length > 0 ? $0.attributes(at: 0, effectiveRange: nil) : nil } ?? [:] attributes[.foregroundColor] = color if currentItem?.data.string("presentation") == "networkSelector" - && currentItem?.data["height"] != nil + && currentItem?.hasExplicitHeight == true { let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() diff --git a/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift b/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift index a5d144cd4..41f329726 100644 --- a/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift @@ -44,9 +44,9 @@ final class NativeListIdentityCell: NativeListRendererCell { override var defaultCornerRadius: CGFloat { guard let item = currentItem else { return 0 } let presentation = item.data.string("presentation") - if presentation == "walletSidebar", isHighlighted || item.data["height"] != nil { return 20 } + if presentation == "walletSidebar", isHighlighted || item.hasExplicitHeight { return 20 } if isHighlighted - || (item.data["height"] != nil + || (item.hasExplicitHeight && ["accountSelector", "networkSelector"].contains(presentation)) { return 12 @@ -244,7 +244,7 @@ final class NativeListIdentityCell: NativeListRendererCell { } override func layoutSubviews() { super.layoutSubviews() - if currentItem?.data["height"] != nil, + if currentItem?.hasExplicitHeight == true, currentItem?.data.string("presentation") == "accountSelector", currentItem?.data.dictionary("style")?.dictionary("container")?["contentVerticalAlignment"] == nil, let descriptor = currentItem?.data.dictionaries("trailing").first, @@ -283,10 +283,10 @@ final class NativeListIdentityCell: NativeListRendererCell { if image["height"] != nil { leadingHeight.constant = CGFloat(image.double("height")) } let wallet = currentItem?.data.string("presentation") == "walletSidebar" - && currentItem?.data["height"] != nil + && currentItem?.hasExplicitHeight == true visual.singleOuterMask = currentItem?.data.string("presentation") == "networkSelector" - && currentItem?.data["height"] != nil + && currentItem?.hasExplicitHeight == true visual.dashedBorderWidth = wallet ? 1 : 2 visual.overlayTextFontSize = wallet ? 12 : 10 visual.overlayTextLineHeight = wallet ? 16 : nil @@ -326,7 +326,7 @@ final class NativeListIdentityCell: NativeListRendererCell { addLeading(item.data.dictionary("leading"), key: item.key) root.addArrangedSubview(mainStack) // OneKey patch: activate width constraints only after both stacks share an ancestor. - if item.data["height"] != nil { + if item.hasExplicitHeight { titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) titleRowStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let width = mainStack.widthAnchor.constraint(equalTo: root.widthAnchor) @@ -353,7 +353,7 @@ final class NativeListIdentityCell: NativeListRendererCell { line.alignment = .center for badge in badges { let label = NativeListInsetLabel() - let isSelector = item.data["height"] != nil + let isSelector = item.hasExplicitHeight let isWarning = badge.string("tone") == "warning" label.font = nativeListFont(ofSize: isSelector ? 11 : 12) label.textColor = nativeListColor( @@ -674,7 +674,7 @@ final class NativeListIdentityCell: NativeListRendererCell { .paragraphStyle: paragraphStyle, ] let isNetworkFallback = label === fallbackLabel && presentation == "networkSelector" - if (currentItem?.data["height"] != nil + if (currentItem?.hasExplicitHeight == true && ["accountSelector", "walletSidebar"].contains(presentation)) || isNetworkFallback { attributes[.baselineOffset] = max(0, (lineHeight - label.font.lineHeight) / 2) diff --git a/native-views/react-native-native-list/ios/NativeListMarketRenderer.swift b/native-views/react-native-native-list/ios/NativeListMarketRenderer.swift index de6c0df07..9144e1bd1 100644 --- a/native-views/react-native-native-list/ios/NativeListMarketRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListMarketRenderer.swift @@ -343,9 +343,8 @@ final class NativeListMarketCell: NativeListRendererCell { let result = NSMutableAttributedString(string: "") let source = segments.isEmpty ? [["text": text]] : segments for segment in source { - let segmentSize = - style?["fontSize"] == nil && segment.string("style") == "subscript" - ? ceil(size * 0.6) : size + // Legacy parity: subscript runs always use ceil(0.6 x fontSize), styled or not. + let segmentSize = segment.string("style") == "subscript" ? ceil(size * 0.6) : size result.append( NSAttributedString( string: segment.string("text"), diff --git a/native-views/react-native-native-list/ios/NativeListModels.swift b/native-views/react-native-native-list/ios/NativeListModels.swift index 2ebd7d2ab..1698ec7bc 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -23,6 +23,12 @@ struct NativeListItem { (data.dictionary("style")?.dictionary("container")?["height"] as? Double).map { CGFloat($0) } } + /// Selector presentations use their explicit-height geometry when either + /// `style.container.height` or `row.height` is set (Web `hasExplicitRowHeight`). + var hasExplicitHeight: Bool { + data.dictionary("style")?.dictionary("container")?["height"] != nil || data["height"] != nil + } + var isSelectable: Bool { let disabled = data["disabled"] as? Bool ?? false return !disabled && Self.selectableTypes.contains(type) diff --git a/native-views/react-native-native-list/ios/NativeListRendererCell.swift b/native-views/react-native-native-list/ios/NativeListRendererCell.swift index 3e9933e2e..cba877038 100644 --- a/native-views/react-native-native-list/ios/NativeListRendererCell.swift +++ b/native-views/react-native-native-list/ios/NativeListRendererCell.swift @@ -20,6 +20,9 @@ class NativeListRendererCell: NativeListRowHost { nativeListColor(theme, "rowPressedBackground", "#E8E8E8") } var showsSelection: Bool { true } + /// Whether the legacy row-level `backgroundColor` paints this row (resting and full-width + /// fills). `style.container.backgroundColor` always applies. + var honorsRowBackgroundColor: Bool { true } var defaultVerticalAlignment: String? { nil } var defaultSeparatorInset: CGFloat { 12 } func bindContent( @@ -184,7 +187,10 @@ class NativeListRendererCell: NativeListRowHost { showSelection ? nativeListColor(theme, "rowSelectedBackground", "#F0F0F0") : unselectedBackground(item, theme: theme, layout: layout, itemIndex: itemIndex) - if let color = (container["backgroundColor"] ?? item.data["backgroundColor"]) as? String { + let rowFill = + container["backgroundColor"] + ?? (honorsRowBackgroundColor ? item.data["backgroundColor"] : nil) + if let color = rowFill as? String { background = UIColor(nativeListHex: color, fallback: background) } contentView.backgroundColor = @@ -225,7 +231,7 @@ class NativeListRendererCell: NativeListRowHost { nativeListHex: container.string("borderColor"), fallback: defaultBorderColor(theme) ).cgColor if item.data.bool("backgroundFullWidth"), - let fill = (container["backgroundColor"] ?? item.data["backgroundColor"]) as? String + let fill = rowFill as? String { fullWidthBackground.backgroundColor = UIColor(nativeListHex: fill, fallback: .clear).cgColor contentView.layer.insertSublayer(fullWidthBackground, at: 0) diff --git a/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift b/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift index a4845b7f2..a93464cd7 100644 --- a/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift @@ -253,7 +253,7 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { ? .bottom : .center applySelectorTypography(item) // Legacy measured the value after selector typography (tabular digits, kern 0). - if item.data.string("presentation") == "networkSelector", item.data["height"] != nil, + if item.data.string("presentation") == "networkSelector", item.hasExplicitHeight, item.data.dictionary("checkbox") != nil, !item.data.string("value").isEmpty { let width = trailingStack.widthAnchor.constraint( @@ -336,7 +336,7 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { let isGallery = variant == "gallery" let isTable = layout == "table" let isNetworkSelector = item.data.string("presentation") == "networkSelector" - let isExplicitNetworkHeader = isNetworkSelector && item.data["height"] != nil + let isExplicitNetworkHeader = isNetworkSelector && item.hasExplicitHeight if isExplicitNetworkHeader { // OneKey patch: the flexible title consumes spare space before trailing totals. trailingStack.setContentHuggingPriority(.required, for: .horizontal) @@ -538,7 +538,7 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { .foregroundColor: label.textColor as Any, .paragraphStyle: paragraphStyle, ] - if currentItem?.data["height"] != nil + if currentItem?.hasExplicitHeight == true && currentItem?.data.string("presentation") == "networkSelector" { let baselineOffset = max(0, (lineHeight - label.font.lineHeight) / 2) @@ -562,10 +562,10 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { paragraphStyle.maximumLineHeight = lineHeight let isSelectorValue = currentItem?.data.string("presentation") == "networkSelector" - && currentItem?.data["height"] != nil && currentItem?.data.string("variant") != "summary" + && currentItem?.hasExplicitHeight == true && currentItem?.data.string("variant") != "summary" let isSelectorSummary = currentItem?.data.string("presentation") == "networkSelector" - && currentItem?.data["height"] != nil && currentItem?.data.string("variant") == "summary" + && currentItem?.hasExplicitHeight == true && currentItem?.data.string("variant") == "summary" (button as? NativeListAccessoryButton)?.selectorSummaryLineHeight = isSelectorSummary ? lineHeight : nil // OneKey patch: summary text uses its source line box; currency retains trailing alignment. @@ -605,7 +605,7 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { current.flatMap { $0.length > 0 ? $0.attributes(at: 0, effectiveRange: nil) : nil } ?? [:] attributes[.foregroundColor] = color if currentItem?.data.string("presentation") == "networkSelector" - && currentItem?.data["height"] != nil + && currentItem?.hasExplicitHeight == true { let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() @@ -643,7 +643,7 @@ final class NativeListSectionHeaderCell: NativeListRendererCell { let value = item.data.string("value") let isExplicitNetworkHeader = - item.data.string("presentation") == "networkSelector" && item.data["height"] != nil + item.data.string("presentation") == "networkSelector" && item.hasExplicitHeight let valueButton = accessoryButtons[0] valueButton.isHidden = value.isEmpty if value.isEmpty { diff --git a/native-views/react-native-native-list/ios/NativeListWalletGroupRenderer.swift b/native-views/react-native-native-list/ios/NativeListWalletGroupRenderer.swift index 8bf81d353..15f9a2e6b 100644 --- a/native-views/react-native-native-list/ios/NativeListWalletGroupRenderer.swift +++ b/native-views/react-native-native-list/ios/NativeListWalletGroupRenderer.swift @@ -20,6 +20,8 @@ final class NativeListWalletGroupCell: NativeListRendererCell, UIGestureRecogniz override var defaultBorderWidth: CGFloat { 1 } override var pressChangesBackground: Bool { false } override var showsSelection: Bool { false } + // Legacy fill is `subduedBackground`; the group's row-level `backgroundColor` is ignored. + override var honorsRowBackgroundColor: Bool { false } override func defaultBorderColor(_ theme: [String: Any]?) -> UIColor { nativeListColor(theme, "separator", "#E0E0E0") } From b08c2a59c51ab382166a7b6db28edcb26133117a Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 15:00:11 +0800 Subject: [PATCH 70/71] fix(native-list/web): restore warning measurement and style clearing - Keep Market subscript runs at the legacy ceil(0.6 x fontSize) under an explicit fontSize. - Lay out automatic-height System warnings at their rendered height again. - Rebuild System/MetricCard text when text styles change so clearing lines/offsetY matches a fresh unstyled render. - Restore margin-bottom on reused walletSidebar visuals after clearing leadingGap. - Docs: review round 3 notes, selector geometry on all platforms, Android source-scale trigger, test counts and CHANGELOG. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../react-native-native-list/docs/DESIGN.md | 11 +- .../docs/ROW_TEMPLATES.md | 10 +- .../react-native-native-list/docs/SPEC.md | 21 +- .../docs/STYLE_SPEC.md | 16 +- .../src/__tests__/NativeList.web.test.ts | 217 +++++++++++++++++- .../web/templates/MetricCardRowRenderer.ts | 13 +- .../src/web/templates/RowText.ts | 19 +- .../src/web/templates/RowVisual.ts | 1 + .../src/web/templates/SystemRowRenderer.ts | 18 +- 10 files changed, 299 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9fbb44c0..072b8c434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to this project will be documented in this file. ### Bug Fixes - **native-list**: Review fixes for the renderer migration. Restore the legacy unstyled rendering of every template inside the new renderers (for example native linear DataRow single-label columns, pressed-row corners, Activity/Identity leading slots, and WalletGroup appearance and drag rules), without restoring key-based styling. Fix an Android sticky-header overlay crash and a leaked pre-draw listener on detached lists. On Web, keep loaded MetricCard images visible after rebinding, restore the legacy message-only retry row (no Web retry button for any presentation) and disabled WalletGroup member actions, include WalletGroup vertical padding in its height, and stop container alignment leaking into reused rows. On iOS, make `end` text alignment follow RTL layout and cache Message text measurement to cut its per-bind cost. - **native-list**: Second legacy-parity review round. Web: remove the Market retry button, keep hover title actions keyed on the member only, make WalletGroup `verticalPadding` replace the legacy border inset in measurement and rendering, and undo container writes before each renderer rebind. Android: switch DataRow off the legacy layout only for its text-style keys, apply selector source-scale typography to every row text while honouring an explicit `fontSize`/`lineHeight`, let `truncate` override the Market retry/noMatch ellipsis, and restore the legacy wallet leading-overlay metrics. iOS: exact `titleBadgeGap` inside legacy badge pills, type-aware theme/listStyle change detection, start/end alignment re-resolved on layout-direction change (including the footer), and WalletGroup ignores `row.height` as before. +- **native-list**: Third review round. Keep Market subscripts at the legacy 60% of an explicit `fontSize` on all platforms. Web: lay out automatic-height System warnings at their rendered height again, and make clearing System/MetricCard text styles or a walletSidebar `leadingGap` match a fresh unstyled render. iOS/Android: selector geometry follows `style.container.height` as well as `row.height`; Android rebinds the fixed footer on a `listStyle`-only change; iOS WalletGroup ignores row-level `backgroundColor` in its full-width fill too. - **native-list**: Align the configurable row-style contract across Web, iOS and Android. Apply non-Market image/spacing overrides; isolate primary and secondary data-column text in linear/table layouts; preserve styles through selection and summary updates; reuse the ordinary Android row renderer for pinned headers including values and checkboxes. Reject template-inapplicable box keys and nonportable text colors. Keep template structure, local styling and shared list capabilities distinct; overflow fitting remains caller-owned. - **native-list**: Apply Web selector styles after presentation defaults, isolate wallet-member styles, and target composite metric-card headings by their semantic role. Restore native text state on reuse while retaining unspecified iOS attributed-string properties. Propagate Android list chrome on initial snapshots and restrict custom group radius to actual grouped rows. Reject malformed nested text/image styles and unknown nested keys; developers integrating the list own overflow prevention. - **image (iOS)**: Show a memory-cached image on the mount frame instead of an icon skeleton (OK-63873). The memory probe skipped every image whose JS side left `cachePolicy` unset (Fabric hands Nitro a nil, which the request path already treats as memory-disk), and before layout it keyed the decode thumbnail from the empty bounds, so a freshly mounted image never matched what the prewarm or a previous mount had stored. The probe now coalesces a nil policy to memory-disk and, before layout, tries the decode-thumbnail keys the laid-out request (`resizeWidth`/`resizeHeight` hint + the view's `contentFit`) and a preload (hint + cover) would use. An image already on screen for the same identity is kept as the placeholder while a load for another key runs, instead of being blanked to a skeleton. diff --git a/native-views/react-native-native-list/docs/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index 480473d73..22fab9890 100644 --- a/native-views/react-native-native-list/docs/DESIGN.md +++ b/native-views/react-native-native-list/docs/DESIGN.md @@ -562,7 +562,8 @@ layout helpers keep their existing behavior in shared primitive/host files. The registry has only the twelve supported families; native parsing rejects unknown types. Footer initialization starts with the lightweight Action host. -Web removes the legacy pool and warning-specific container measurement cache. +Web removes the legacy pool and warning-specific container measurement cache; +the System renderer reports a warning's rendered height through the registry. Selector tabular typography belongs to Identity, Header and Action. WalletGroup provides its compact parent preview through the internal renderer registration; the container no longer selects a parent DOM node from the composite internals. @@ -624,6 +625,14 @@ renderer structure; implicit key-based styling stays removed. Notable outcomes: rendering, container writes are undone before each renderer rebind (no leaked alignment, no reverted renderer values), every System retry is message-only again, and selector geometry follows either explicit height field. +- Review round 3: Web restores the legacy Market subscript ratio under an + explicit `fontSize` (iOS/Android too), System warning DOM-height layout, a + text-style rebuild for System/MetricCard so cleared `lines`/`offsetY` leave + no wrapper or rewritten text, and `margin-bottom` restoration on reused + visuals. iOS/Android selector geometry now follows either explicit height + field (Android source scale still keys on `row.height`), Android rebinds the + fixed footer on a `listStyle`-only change, and iOS WalletGroup ignores the + row-level `backgroundColor` in its full-width fill too. Web validation is jsdom/unit level (package typecheck and tests); native validation for this round is reported with the native changes. diff --git a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md index 459095235..489f36439 100644 --- a/native-views/react-native-native-list/docs/ROW_TEMPLATES.md +++ b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md @@ -57,8 +57,8 @@ separately from source implementation in STYLE_SPEC §9. - **Variants:** default horizontal row; `accountSelector` and `networkSelector` preserve selector control geometry; `walletSidebar` uses a compact, centered wallet presentation. A variant is explicit model data, never inferred from font size. - Selector control geometry applies when the row has an explicit height from - either `style.container.height` or the legacy `row.height`. + Selector control geometry applies on all platforms when the row has an + explicit height from either `style.container.height` or the legacy `row.height`. - **Separator:** the default native separator inset is 60 for identity rows and 12 for other templates (Web 0). - **Platform default:** without `subtitleLines` or `style.subtitle.lines`, the @@ -211,6 +211,9 @@ separately from source implementation in STYLE_SPEC §9. `contentTrailingGap`, `subtitleTrailingPadding`, `changeWidth`, `changeHeight`, `changeCornerRadius`, plus box/image metrics. Badge models and subtitle prefix have their own existing style fields; they do not create arbitrary child views. + A `subscript` segment is `ceil(0.6 × field fontSize)` on a `fontSize` line box + (legacy), including under an explicit `fontSize`. Web uses a `fontSize` line box; + iOS/Android keep the field line height, as their legacy renderers did. - **Layout boundary:** price and change keep their regions; the localized name compresses independently from volume where supported. Styling does not change token/stock/perp structure or affect sections, index navigation or pagination. @@ -302,6 +305,9 @@ separately from source implementation in STYLE_SPEC §9. retry/noMatch message and the retry `actionText` ellipsize at the end by default; an explicit `lines` or `truncate` (including `clip`) wins. A spacer has no text slot; skeleton/spinner styling does not become arbitrary drawing through these text roles. +- **Height:** without `row.height`/`style.container.height`, Web lays out a + warning at its rendered height (padding, both borders, wrapped text), as the + legacy engine did; native measures its text. - **Layout boundary:** keep each variant's indicator/text/action structure and bounded height. Styling cannot convert loading into retry, turn a spacer into content, or hide adjacent rows. diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index 74e6c2a22..be9f374f7 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -252,7 +252,8 @@ unknown row type before serialization. If one still reaches native code, iOS keeps its legacy behavior: an empty 56-point placeholder row with shared chrome and a single log message. Android rejects the snapshot while parsing it. Footer initialization uses a registered Action host. Web has no legacy pool or warning-specific container measurement path; template -renderers own selector typography and WalletGroup compact-preview lookup. +renderers own selector typography, System warning DOM measurement and +WalletGroup compact-preview lookup. The consumer audit in MIGRATION_BASELINE.md allows removal of history-key header inference, Android token-section typography inference, and separator-key exceptions. @@ -290,7 +291,20 @@ while its member press stays blocked; title-help hover checks only the resolved member. Every System retry (Market or not) is message-only with a row-press action, as in the legacy engine. Text styling no longer reads computed style, so attached and detached rows style identically. -Validation: package typecheck and 176 tests pass (jsdom/unit level); this is + +Review round 3 (2026-09-24). Web: a Market subscript run keeps the legacy +`ceil(0.6 × fontSize)` size on a `fontSize` line box when the field has an +explicit `fontSize`, and iOS/Android again draw it at `ceil(0.6 × fontSize)` on the +field's own line height (legacy on each platform); +an automatic-height System warning is laid out at its rendered border-box +height again; System and MetricCard rebuild their text when a text style +changes, so clearing `lines`/`offsetY` restores the original text and removes +the layout wrapper; a reused visual restores `margin-bottom`, so a cleared +walletSidebar `leadingGap` leaves no residue. iOS and Android gate every +selector explicit-height geometry on `style.container.height ?? row.height` +(Android's list-wide source scale stays keyed on `row.height` only); Android +rebinds the fixed footer on a `listStyle`-only snapshot. +Validation: package typecheck and 179 tests pass (jsdom/unit level); this is not new rendered browser or native device evidence. ### Platform interaction and appearance differences (legacy, retained) @@ -326,7 +340,8 @@ audit; they are not new in this change. wallet-sidebar exception (12). Web changes only the pressed background and keeps the resting (group or template) radius. - **WalletGroup row appearance.** The group's row-level `backgroundColor` is - ignored on iOS and Android (legacy): the group fill is + ignored on iOS and Android (legacy), in both the resting and the + `backgroundFullWidth` fill: the group fill is `style.container.backgroundColor` or the theme `subduedBackground`. Web (legacy) applies it inline over the group fill. Use `style.container.backgroundColor` for a cross-platform group fill. diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index d0954f75d..0cb3cb618 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -722,6 +722,9 @@ exposed — a hairline is correct on iOS and a whole pixel is correct elsewhere. - **Source scale is list-wide on Android.** `usesSelectorSourceScale` is computed with `items.any { … }`, so one selector row switches the metric system (the sub-400dp 0.9 factor) for the entire list. + Only the legacy `row.height` (a WalletGroup's parent `height`) triggers it; + `style.container.height` switches its own row to selector explicit-height + geometry (as on iOS and Web) but never this list-wide metric system (§7 rule 3). `NativeListTableColumnView`, the market skeleton, and the section index never follow it. Explicit non-Market row styles now use density directly, so this legacy baseline policy cannot scale an explicitly supplied style value. @@ -765,7 +768,7 @@ exposed — a hairline is correct on iOS and a whole pixel is correct elsewhere. | --- | --- | | Web clip height guessed before CSS was mounted; iOS clip disabled natural wrapping | CSS line-height-relative clipping and iOS wrapping without ellipsis | | Web ignored legacy identity line limits and Market badge metrics | Apply declared fields with style precedence and restore defaults on clear | -| Market rich subscripts retained their old size despite explicit font size | Explicit field size overrides every run on all three platforms | +| Market rich subscripts retained their old size despite explicit font size | Superseded 2026-09-24: legacy (all platforms) sized a subscript at `ceil(0.6 × field fontSize)`. Web, iOS and Android restore that ratio for an explicit `fontSize` (native keeps the field line height, Web a `fontSize` line box, each as in legacy) | | Web Market center fit used an invalid CSS value; change color used reversed precedence | Map center to object-fit none; explicit style color wins | | Android line height excluded first/last text lines | Apply an exact line-height span including first/last font metrics | | Legacy native centering overrode container alignment | Apply legacy positioning only when container alignment is omitted | @@ -875,7 +878,9 @@ needs it; it must never obtain defaults from another template's previous binding Measurement uses the current data/theme/style inputs. Web renderers report rendered intrinsic heights through the registry; the container has no separate -warning-view measurement fallback. Image primitives retain unchanged effective +warning-view measurement fallback. The System renderer reports an automatic-height +warning's rendered border-box height (padding, both borders, wrapped text), as +the legacy engine did; `row.height`/`style.container.height` skip it. Image primitives retain unchanged effective requests and invalidate callbacks on source/slot replacement or recycle. Selector tabular typography belongs to Identity, SectionHeader and Action. @@ -932,7 +937,12 @@ the contract; no shared cross-language renderer or code generator is required. ### Verification — 2026-09-24 (Web legacy-default audit) -- Package typecheck passes; 176 package tests in five suites pass, including +- Package typecheck passes; 179 package tests in five suites pass, including + (review round 3) Market subscript runs keeping the legacy 60% size under an + explicit `fontSize`, System warning layout at its rendered DOM height, + System/MetricCard text-layout styles (`lines: 1` text rewrite, `offsetY` + wrapper) clearing to a fresh unstyled render, walletSidebar `leadingGap` + cleared from reused visuals (standalone and WalletGroup members), regressions for loaded MetricCard images across content/style rebinding, WalletGroup vertical padding replacing the legacy inset in measured and rendered height, message-only retry rows (Market included) firing on click diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index 9f732c000..fd7eabee5 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -732,7 +732,7 @@ describe('web row style', () => { ).not.toBe('none'); }); - it('gives explicit Market typography priority over rich runs and legacy change colors', () => { + it('scales Market subscript runs with explicit typography and legacy change colors', () => { const body = render({ type: 'market', key: 'market-rich', @@ -759,18 +759,43 @@ describe('web row style', () => { '.ok-native-list-market-change' )!; expect( - Array.from(price.querySelectorAll('span')).map( - (run) => run.style.fontSize - ) - ).toEqual(['18px', '18px']); + Array.from(price.querySelectorAll('span')).map((run) => [ + run.style.fontSize, + run.style.lineHeight, + ]) + ).toEqual([ + ['18px', ''], + // Legacy subscript metrics: ceil(size * 0.6) on a field-size line box. + ['11px', '18px'], + ]); expect( Array.from( change.querySelectorAll( '.ok-native-list-text-content > span' ) ).map((run) => run.style.fontSize) - ).toEqual(['17px', '17px']); + ).toEqual(['17px', '11px']); expect(change.style.color).toBe('rgb(18, 52, 86)'); + const styledLine = render({ + type: 'market', + key: 'market-rich-line', + variant: 'token', + title: 'Market', + leading: { kind: 'icon', name: 'coin' }, + price: '$01', + priceSegments: [{ text: '$0' }, { text: '1', style: 'subscript' }], + change: { text: '+1', tone: 'positive' }, + style: { price: { fontSize: 20, lineHeight: 28 } }, + }).querySelectorAll('.ok-native-list-market-price span'); + expect( + Array.from(styledLine).map((run) => [ + run.style.fontSize, + run.style.lineHeight, + ]) + ).toEqual([ + ['20px', '28px'], + ['12px', '20px'], + ]); }); it('allows Market measurement changes while giving explicit style height precedence', () => { @@ -1319,7 +1344,10 @@ describe('web row style', () => { const body = host.querySelector( '[data-nl-renderer="system"]' )!; - const title = body.querySelector('[data-nl-slot="title"]')!; + // Text styles rebuild the text nodes (see the set→clear regression test); + // the renderer host is what stays. + const title = () => + body.querySelector('[data-nl-slot="title"]')!; try { engine.applySnapshot( snapshot({ kind: 'sectioned' }, [ @@ -1335,10 +1363,10 @@ describe('web row style', () => { ]) ); expect(host.querySelector('[data-nl-renderer="system"]')).toBe(body); - expect(body.querySelector('[data-nl-slot="title"]')).toBe(title); - expect(title.style.fontSize).toBe('18px'); + expect(title().style.fontSize).toBe('18px'); engine.applySnapshot(snapshot({ kind: 'sectioned' }, [warning])); - expect(title.style.fontSize).toBe(''); + expect(host.querySelector('[data-nl-renderer="system"]')).toBe(body); + expect(title().style.fontSize).toBe(''); expect(body.style.rowGap).toBe(''); engine.applySnapshot( snapshot({ kind: 'sectioned' }, [ @@ -3356,4 +3384,173 @@ describe('legacy default regressions', () => { global.HTMLElement = previousHTMLElement; } }); + + it('lays out an automatic-height warning at its legacy DOM border-box height', () => { + const warning: RowModel = { + type: 'system', + key: 'warning', + variant: 'warning', + title: 'Warning', + message: 'A long message that wraps in the rendered row', + }; + const { window } = new JSDOM(''); + const domWindow = window as unknown as { HTMLElement: typeof HTMLElement }; + // Legacy read offsetHeight (padding + both borders + wrapped text). + Object.defineProperty(domWindow.HTMLElement.prototype, 'offsetHeight', { + configurable: true, + get(this: HTMLElement) { + return this.classList.contains('ok-native-list-warning') ? 150 : 0; + }, + }); + const host = window.document.createElement('div'); + window.document.body.appendChild(host); + const engine = new NativeListWebEngine( + host, + snap([ + warning, + { ...warning, key: 'row-height', height: 80 }, + { + ...warning, + key: 'container-height', + style: { container: { height: 90 } }, + }, + ]), + {}, + false + ); + const itemHeight = (key: string) => + host.querySelector(`[data-native-list-row-key="${key}"]`)! + .style.height; + try { + expect(itemHeight('warning')).toBe('150px'); + expect(itemHeight('row-height')).toBe('80px'); + expect(itemHeight('container-height')).toBe('90px'); + } finally { + engine.destroy(); + } + }); + + it('restores a fresh unstyled render after clearing System and MetricCard text layout styles', () => { + const warning: RowModel = { + type: 'system', + key: 'warning', + variant: 'warning', + title: 'Warning', + message: 'One\nTwo', + }; + const metric: RowModel = { + type: 'metricCard', + key: 'metric', + title: 'Portfolio', + value: '$42,000', + visual: { kind: 'image', image }, + }; + const markup = (host: HTMLElement, renderer: string) => { + const body = host.querySelector( + `[data-nl-renderer="${renderer}"]` + )!; + // Image load start times differ between the two engines by design. + return body.outerHTML.replace( + / data-native-list-image-started-at="\d+"/g, + '' + ); + }; + const fresh = mount(snap([warning, metric])); + const reused = mount(snap([warning, metric])); + try { + const image = reused.host.querySelector('img'); + reused.engine.applySnapshot( + snap([ + { + ...warning, + style: { message: { lines: 1, offsetY: 3 } }, + }, + { ...metric, style: { value: { offsetY: 4, lines: 1 } } }, + ]) + ); + const message = reused.host.querySelector( + '[data-nl-slot="message"]' + )!; + expect(message.textContent).toBe('One Two'); + expect( + message.querySelector('.ok-native-list-text-content')! + .style.transform + ).toBe('translateY(3px)'); + expect( + reused.host.querySelector( + '[data-nl-slot="value"] > .ok-native-list-text-content' + )!.style.transform + ).toBe('translateY(4px)'); + reused.engine.applySnapshot(snap([warning, metric])); + expect(markup(reused.host, 'system')).toBe(markup(fresh.host, 'system')); + expect(markup(reused.host, 'metricCard')).toBe( + markup(fresh.host, 'metricCard') + ); + expect( + reused.host.querySelector('[data-nl-slot="message"]')!.textContent + ).toBe('One\nTwo'); + // Rebuilding text reuses the loaded image node. + expect(reused.host.querySelector('img')).toBe(image); + } finally { + fresh.engine.destroy(); + reused.engine.destroy(); + } + }); + + it('clears walletSidebar leadingGap from reused visuals, standalone and in a WalletGroup', () => { + const sidebar: IdentityRow = { + type: 'identity', + key: 'sidebar', + presentation: 'walletSidebar', + title: 'Wallet', + leading: { kind: 'wallet', image }, + }; + const group: Extract = { + type: 'walletGroup', + key: 'group', + parent: { ...sidebar, key: 'group', title: 'Parent' }, + children: [{ ...sidebar, key: 'child', title: 'Child' }], + }; + const visuals = (host: HTMLElement) => + Array.from( + host.querySelectorAll( + '[data-nl-renderer="identity"] > .ok-native-list-visual' + ) + ); + const fresh = mount(snap([sidebar, group])); + const reused = mount(snap([sidebar, group])); + try { + const nodes = visuals(reused.host); + expect(nodes).toHaveLength(3); + const styled = { leadingGap: 10 }; + reused.engine.applySnapshot( + snap([ + { ...sidebar, style: styled }, + { + ...group, + parent: { ...group.parent, style: styled }, + children: [{ ...group.children[0]!, style: styled }], + }, + ]) + ); + expect(visuals(reused.host)).toEqual(nodes); + nodes.forEach((node) => expect(node.style.marginBottom).not.toBe('')); + reused.engine.applySnapshot(snap([sidebar, group])); + expect(visuals(reused.host)).toEqual(nodes); + // Restoring re-sets properties, so compare declarations, not order. + const declarations = (node: HTMLElement) => + node.style.cssText + .split(';') + .map((part) => part.trim()) + .filter(Boolean) + .sort(); + expect(nodes.map(declarations)).toEqual( + visuals(fresh.host).map(declarations) + ); + nodes.forEach((node) => expect(node.style.marginBottom).toBe('')); + } finally { + fresh.engine.destroy(); + reused.engine.destroy(); + } + }); }); diff --git a/native-views/react-native-native-list/src/web/templates/MetricCardRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/MetricCardRowRenderer.ts index ceaf2dbc9..752e8b86c 100644 --- a/native-views/react-native-native-list/src/web/templates/MetricCardRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/MetricCardRowRenderer.ts @@ -209,7 +209,18 @@ function bind( ) { const v = views.get(body)!; v.primitives = primitives; - const contentKey = JSON.stringify({ ...row, style: undefined }); + // Text styles are part of the key: applyTextLayout inserts wrappers and + // rewrites text (lines: 1), which restoring inline styles cannot undo. + // Rebuilding reuses equal visual slots, so images never restart. + const contentKey = JSON.stringify({ + ...row, + style: { + title: row.style?.title, + value: row.style?.value, + trend: row.style?.trend, + subtitle: row.style?.subtitle, + }, + }); if (contentKey !== v.contentKey) { let index = 0; restoreInlineStyles(v.defaults); diff --git a/native-views/react-native-native-list/src/web/templates/RowText.ts b/native-views/react-native-native-list/src/web/templates/RowText.ts index 21f71c6c8..a630bc43f 100644 --- a/native-views/react-native-native-list/src/web/templates/RowText.ts +++ b/native-views/react-native-native-list/src/web/templates/RowText.ts @@ -22,12 +22,17 @@ export function applyValueSegments( segment.text ); if (segment.style === 'subscript') { - span.style.fontSize = String(Math.ceil(fontSize * 0.6)) + 'px'; - span.style.lineHeight = String(fontSize) + 'px'; + span.dataset.nlSegment = 'subscript'; + applySubscriptMetrics(span, fontSize); } element.appendChild(span); }); } +// Legacy subscript metrics: 60% of the field size on a field-size line box. +function applySubscriptMetrics(span: HTMLElement, fontSize: number) { + span.style.fontSize = String(Math.ceil(fontSize * 0.6)) + 'px'; + span.style.lineHeight = String(fontSize) + 'px'; +} export function applyTextLayout( element: HTMLElement, style: NativeListTextStyle, @@ -146,13 +151,17 @@ export function applyTextStyleToSlot( element.style.fontWeight = String(fontWeight(style.fontWeight, 400)); if (style.color !== undefined) element.style.color = style.color; // Explicit overrides also apply to rich text runs, but never to sibling badges. + // Subscript segments keep their legacy size relative to the field size. element.querySelectorAll('span').forEach((run) => { + const subscript = run.dataset.nlSegment === 'subscript'; if (style.color !== undefined) run.style.color = style.color; - if (style.fontSize !== undefined) - run.style.fontSize = String(style.fontSize) + 'px'; + if (style.fontSize !== undefined) { + if (subscript) applySubscriptMetrics(run, style.fontSize); + else run.style.fontSize = String(style.fontSize) + 'px'; + } if (style.fontWeight !== undefined) run.style.fontWeight = String(fontWeight(style.fontWeight, 400)); - if (style.lineHeight !== undefined) + if (style.lineHeight !== undefined && !subscript) run.style.lineHeight = String(style.lineHeight) + 'px'; if (style.lines !== undefined || style.truncate !== undefined) run.style.whiteSpace = 'inherit'; diff --git a/native-views/react-native-native-list/src/web/templates/RowVisual.ts b/native-views/react-native-native-list/src/web/templates/RowVisual.ts index 098846630..e77127dd5 100644 --- a/native-views/react-native-native-list/src/web/templates/RowVisual.ts +++ b/native-views/react-native-native-list/src/web/templates/RowVisual.ts @@ -41,6 +41,7 @@ const properties = [ 'overflow', 'object-fit', 'margin-inline-end', + 'margin-bottom', 'align-self', 'border', 'box-sizing', diff --git a/native-views/react-native-native-list/src/web/templates/SystemRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/SystemRowRenderer.ts index cd7c88633..6aacf81c3 100644 --- a/native-views/react-native-native-list/src/web/templates/SystemRowRenderer.ts +++ b/native-views/react-native-native-list/src/web/templates/SystemRowRenderer.ts @@ -3,6 +3,7 @@ import type { RowPrimitives } from './RowVisual'; import { captureInlineStyles, createElement, + hasExplicitRowHeight, restoreInlineStyles, setData, tagSlot, @@ -155,7 +156,12 @@ function createSystemRow( type Binding = { key: string; defaults: Map }; const bindings = new WeakMap(); function bind(body: HTMLElement, row: SystemRow, primitives: RowPrimitives) { - const content = { ...row, style: undefined }; + // Text styles are part of the key: applyTextLayout inserts wrappers and + // rewrites text (lines: 1), which restoring inline styles cannot undo. + const content = { + ...row, + style: { title: row.style?.title, message: row.style?.message }, + }; const key = JSON.stringify([content, primitives.background]); let binding = bindings.get(body); if (!binding || binding.key !== key) { @@ -261,8 +267,14 @@ export const systemRowRenderer = { create, bind, measure, - measureRendered: (_body: HTMLElement, _row: SystemRow): number | undefined => - undefined, + // Legacy Web lays out an automatic-height warning at its rendered border-box + // height (padding, both borders and wrapped text), not the estimate above. + measureRendered: (body: HTMLElement, row: SystemRow): number | undefined => { + if (row.variant !== 'warning' || hasExplicitRowHeight(row)) + return undefined; + const height = body.offsetHeight; + return height > 0 ? height : undefined; + }, recycle: (body: HTMLElement) => { bindings.delete(body); body.replaceChildren(); From a54f57289a284f9ff71bb63f1f962caefbdd7688 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 16:12:01 +0800 Subject: [PATCH 71/71] fix(native-list): address review feedback --- CHANGELOG.md | 2 +- .../com/onekey/nativelist/NativeListModels.kt | 7 ++++ .../com/onekey/nativelist/NativeListView.kt | 9 +++-- .../NativeListSelectorBackgroundColorTest.kt | 34 +++++++++++++++++++ .../react-native-native-list/docs/SPEC.md | 2 +- .../docs/STYLE_SPEC.md | 2 +- .../src/__tests__/NativeList.web.test.ts | 2 +- .../src/__tests__/validation.test.ts | 11 ++++++ .../src/validation.ts | 13 +++++++ 9 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListSelectorBackgroundColorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 072b8c434..4952652fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ All notable changes to this project will be documented in this file. ### Bug Fixes - **native-list**: Review fixes for the renderer migration. Restore the legacy unstyled rendering of every template inside the new renderers (for example native linear DataRow single-label columns, pressed-row corners, Activity/Identity leading slots, and WalletGroup appearance and drag rules), without restoring key-based styling. Fix an Android sticky-header overlay crash and a leaked pre-draw listener on detached lists. On Web, keep loaded MetricCard images visible after rebinding, restore the legacy message-only retry row (no Web retry button for any presentation) and disabled WalletGroup member actions, include WalletGroup vertical padding in its height, and stop container alignment leaking into reused rows. On iOS, make `end` text alignment follow RTL layout and cache Message text measurement to cut its per-bind cost. - **native-list**: Second legacy-parity review round. Web: remove the Market retry button, keep hover title actions keyed on the member only, make WalletGroup `verticalPadding` replace the legacy border inset in measurement and rendering, and undo container writes before each renderer rebind. Android: switch DataRow off the legacy layout only for its text-style keys, apply selector source-scale typography to every row text while honouring an explicit `fontSize`/`lineHeight`, let `truncate` override the Market retry/noMatch ellipsis, and restore the legacy wallet leading-overlay metrics. iOS: exact `titleBadgeGap` inside legacy badge pills, type-aware theme/listStyle change detection, start/end alignment re-resolved on layout-direction change (including the footer), and WalletGroup ignores `row.height` as before. -- **native-list**: Third review round. Keep Market subscripts at the legacy 60% of an explicit `fontSize` on all platforms. Web: lay out automatic-height System warnings at their rendered height again, and make clearing System/MetricCard text styles or a walletSidebar `leadingGap` match a fresh unstyled render. iOS/Android: selector geometry follows `style.container.height` as well as `row.height`; Android rebinds the fixed footer on a `listStyle`-only change; iOS WalletGroup ignores row-level `backgroundColor` in its full-width fill too. +- **native-list**: Third review round. Keep Market subscripts at the legacy 60% of an explicit `fontSize` on all platforms. Web: lay out automatic-height System warnings at their rendered height again, and make clearing System/MetricCard text styles or a walletSidebar `leadingGap` match a fresh unstyled render. iOS/Android: selector geometry follows `style.container.height` as well as `row.height`; Android rebinds the fixed footer on a `listStyle`-only change; WalletGroup ignores row-level `backgroundColor` in the native full-width fill. - **native-list**: Align the configurable row-style contract across Web, iOS and Android. Apply non-Market image/spacing overrides; isolate primary and secondary data-column text in linear/table layouts; preserve styles through selection and summary updates; reuse the ordinary Android row renderer for pinned headers including values and checkboxes. Reject template-inapplicable box keys and nonportable text colors. Keep template structure, local styling and shared list capabilities distinct; overflow fitting remains caller-owned. - **native-list**: Apply Web selector styles after presentation defaults, isolate wallet-member styles, and target composite metric-card headings by their semantic role. Restore native text state on reuse while retaining unspecified iOS attributed-string properties. Propagate Android list chrome on initial snapshots and restrict custom group radius to actual grouped rows. Reject malformed nested text/image styles and unknown nested keys; developers integrating the list own overflow prevention. - **image (iOS)**: Show a memory-cached image on the mount frame instead of an icon skeleton (OK-63873). The memory probe skipped every image whose JS side left `cachePolicy` unset (Fabric hands Nitro a nil, which the request path already treats as memory-disk), and before layout it keyed the decode thumbnail from the empty bounds, so a freshly mounted image never matched what the prewarm or a previous mount had stored. The probe now coalesces a nil policy to memory-disk and, before layout, tries the decode-thumbnail keys the laid-out request (`resizeWidth`/`resizeHeight` hint + the view's `contentFit`) and a preload (hint + cover) would use. An image already on screen for the same identity is kept as the placeholder while a load for another key runs, instead of being blanked to a skeleton. diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index 52f7f715c..78b57f658 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -27,6 +27,13 @@ internal fun isNativeListWholeRowInteractive( pressDisabled = pressDisabled, ) +internal fun resolveNativeListSelectorBackgroundColor( + rowType: String, + styleBackgroundColor: String?, + rowBackgroundColor: String?, +): String? = styleBackgroundColor?.takeIf(String::isNotEmpty) + ?: rowBackgroundColor?.takeIf { rowType != "walletGroup" && it.isNotEmpty() } + // The image fallback-state cache is process-wide, so it is keyed by a digest of the request // identity instead of the raw headers, which can carry credentials such as Authorization. internal fun nativeListSourceFallbackStateKey(uri: String, headers: Map): String? { diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt index db3bb0583..aa1b52fcd 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt @@ -2768,8 +2768,13 @@ private class SelectorBackgroundDecoration(private val adapter: NativeListAdapte val child = parent.getChildAt(index) val item = adapter.itemAt(parent.getChildAdapterPosition(child)) ?: continue if (!item.json.optBoolean("backgroundFullWidth", false)) continue - val color = item.json.optJSONObject("style")?.optJSONObject("container")?.optString("backgroundColor")?.takeIf { it.isNotEmpty() } ?: item.json.optString("backgroundColor") - if (color.isEmpty()) continue + val color = resolveNativeListSelectorBackgroundColor( + rowType = item.type, + styleBackgroundColor = item.json.optJSONObject("style") + ?.optJSONObject("container") + ?.optString("backgroundColor"), + rowBackgroundColor = item.json.optString("backgroundColor"), + ) ?: continue paint.color = try { parseNativeListColor(color) } catch (_: IllegalArgumentException) { Color.TRANSPARENT } val opacity = item.json.optJSONObject("style")?.optJSONObject("container")?.optDouble("opacity", item.json.optDouble("opacity", 1.0)) ?: item.json.optDouble("opacity", 1.0) paint.alpha = (Color.alpha(paint.color) * opacity * (if (item.json.optBoolean("disabled", false)) 0.5 else 1.0)).toInt() diff --git a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListSelectorBackgroundColorTest.kt b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListSelectorBackgroundColorTest.kt new file mode 100644 index 000000000..e4fdf794a --- /dev/null +++ b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListSelectorBackgroundColorTest.kt @@ -0,0 +1,34 @@ +package com.margelo.nitro.nativelist + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class NativeListSelectorBackgroundColorTest { + @Test + fun walletGroupUsesOnlyTheContainerStyleColor() { + assertNull( + resolveNativeListSelectorBackgroundColor( + rowType = "walletGroup", + styleBackgroundColor = null, + rowBackgroundColor = "#112233", + ), + ) + assertEquals( + "#445566", + resolveNativeListSelectorBackgroundColor( + rowType = "walletGroup", + styleBackgroundColor = "#445566", + rowBackgroundColor = "#112233", + ), + ) + assertEquals( + "#112233", + resolveNativeListSelectorBackgroundColor( + rowType = "identity", + styleBackgroundColor = null, + rowBackgroundColor = "#112233", + ), + ) + } +} diff --git a/native-views/react-native-native-list/docs/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md index be9f374f7..3397a849c 100644 --- a/native-views/react-native-native-list/docs/SPEC.md +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -304,7 +304,7 @@ walletSidebar `leadingGap` leaves no residue. iOS and Android gate every selector explicit-height geometry on `style.container.height ?? row.height` (Android's list-wide source scale stays keyed on `row.height` only); Android rebinds the fixed footer on a `listStyle`-only snapshot. -Validation: package typecheck and 179 tests pass (jsdom/unit level); this is +Validation: package typecheck and 180 tests pass (jsdom/unit level); this is not new rendered browser or native device evidence. ### Platform interaction and appearance differences (legacy, retained) diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index 0cb3cb618..f10846bc3 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -937,7 +937,7 @@ the contract; no shared cross-language renderer or code generator is required. ### Verification — 2026-09-24 (Web legacy-default audit) -- Package typecheck passes; 179 package tests in five suites pass, including +- Package typecheck passes; 180 package tests in five suites pass, including (review round 3) Market subscript runs keeping the legacy 60% size under an explicit `fontSize`, System warning layout at its rendered DOM height, System/MetricCard text-layout styles (`lines: 1` text rewrite, `offsetY` diff --git a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts index fd7eabee5..67f676e9a 100644 --- a/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts +++ b/native-views/react-native-native-list/src/__tests__/NativeList.web.test.ts @@ -3451,7 +3451,7 @@ describe('legacy default regressions', () => { )!; // Image load start times differ between the two engines by design. return body.outerHTML.replace( - / data-native-list-image-started-at="\d+"/g, + / data-native-list-image-started-at="[^"]*"/g, '' ); }; diff --git a/native-views/react-native-native-list/src/__tests__/validation.test.ts b/native-views/react-native-native-list/src/__tests__/validation.test.ts index 591a42e68..b3100facf 100644 --- a/native-views/react-native-native-list/src/__tests__/validation.test.ts +++ b/native-views/react-native-native-list/src/__tests__/validation.test.ts @@ -868,6 +868,17 @@ describe('NativeList model validation', () => { ); }); + it('rejects an unknown row type before renderer lookup', () => { + const unknown = { + type: 'unknown', + key: 'unknown-row', + } as unknown as RowModel; + + expect(() => validateSnapshot(snapshot([unknown]))).toThrow( + 'rows[0].type: is not supported' + ); + }); + it('rejects accessory and badge counts above their hard caps', () => { const invalid = row('btc') as unknown as { trailing: unknown[]; diff --git a/native-views/react-native-native-list/src/validation.ts b/native-views/react-native-native-list/src/validation.ts index 4fb4a3f0a..f928c3add 100644 --- a/native-views/react-native-native-list/src/validation.ts +++ b/native-views/react-native-native-list/src/validation.ts @@ -958,11 +958,24 @@ function assertWalletGroup(row: WalletGroupRow, path: string): void { }); } +function assertRowType( + type: unknown, + path: string +): asserts type is RowModel['type'] { + if ( + typeof type !== 'string' || + !Object.prototype.hasOwnProperty.call(ROW_BOX_STYLE_KEYS_BY_TYPE, type) + ) { + fail(`${path}.type`, 'is not supported'); + } +} + function assertRow( row: RowModel, index: number, path = `rows[${index}]` ): void { + assertRowType(row.type, path); assertKey(row.key, `${path}.key`); // OneKey patch: explicit dimensions and opacity cannot corrupt list layout. if (row.height !== undefined && (row.height < 0 || row.height > 4096))