diff --git a/CHANGELOG.md b/CHANGELOG.md index 54b1864b5..4952652fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,34 @@ 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**: 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 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; 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. - **image**: Clip the RN overlay container to the same circle as a `round` native image. `round` clips the native view to an oval on Android and to a `min(width, height) / 2` corner radius on iOS, but the wrapper that hosts a custom `placeholder` or `fallback` only ever clipped to the caller's own `borderRadius`, so those overlays were painted square over a round image — against the clipping contract the README states. The container and the drawn border overlay now take a percentage radius when `round` is set, overriding any per-corner radius from the caller's style. -### Features -- **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. +### Documentation +- **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. 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. 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/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/example/react-native/pages/NativeListRowStylePage.tsx b/example/react-native/pages/NativeListRowStylePage.tsx new file mode 100644 index 000000000..eac1e955c --- /dev/null +++ b/example/react-native/pages/NativeListRowStylePage.tsx @@ -0,0 +1,474 @@ +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. + 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 }, + horizontalPadding: 20, + lineGap: 2, + leadingGap: 16, + titleBadgeGap: 6, + image: { width: 32, height: 32, shape: 'rounded' as const }, + }, + } + : {}), + }); + + // 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, + lines: 3 as const, + truncate: 'clip' as const, + verticalAlignment: 'top' as const, + }, + container: { backgroundColor: '#FFF8E7', cornerRadius: 8 }, + 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' } } } + : {}), + }); + + // Cover remaining templates with local text/image/gap overrides. Outer row + // callers can override allocation with container.height and choose fitting dimensions. + 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' }, + columns: { fontSize: 16, color: '#0D74CE' }, + columnSecondary: { fontSize: 12, color: '#108303' }, + lineGap: 4, + }, + ], + [ + { + 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' }, + image: { width: 120, height: 80, shape: 'rounded' }, + leadingGap: 8, + lineGap: 4, + }, + ], + [ + { + 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')); + 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')); + 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, + }, + } + : {}), + }); + + rows.push( + header('text-layout', 'Text layout / row container', 'height: 180 → 200'), + ); + 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: { + height: 200, + 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; +} + +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, + // 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], + ); + + 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 }} /> + () + 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 + 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 + + /** 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() { + 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 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 + 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, + horizontal: Boolean = false, + ) { + selectorUsesSourceScale = sourceScale + reset() + tag = item + val primaryText = color(theme, "primaryText", "#000000DF") + checkboxCheckedColor = primaryText + checkboxUncheckedColor = color(theme, "inverseText", "#FCFCFC") + 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 + // 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) + 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) { + 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 + } + } + } + visibility = + if ((0 until childCount).any { getChildAt(it).visibility != GONE }) VISIBLE else GONE + } + + fun updateSelection( + item: NativeListItem, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + tag = item + 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 reset() { + tag = null + endInset = null + requestsUnclippedHost = false + clipChildren = true + clipToPadding = true + semanticValueViews.clear() + boundCheckboxData = null + trailingViews.forEachIndexed { index, v -> + v.paintFlags = trailingPaintFlags[index] + 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) + // Legacy trailing text: END gravity, one line, no ellipsis. + v.gravity = Gravity.END + v.opticalOffsetY = 0f + v.maxLines = 1 + v.setHorizontallyScrolling(false) + v.ellipsize = null + 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, + ) { + // Row-level disabled gating is per control (see showTrailing/bindCheckbox), as in legacy. + 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.hasExplicitHeight + checkbox.usesSelectorGeometry = usesSourceCheckboxGeometry + 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( + 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.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 + 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 + requestsUnclippedHost = true + 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..01330da93 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActionRenderer.kt @@ -0,0 +1,169 @@ +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)) + } + } + + private val titlePaintFlags = title.paintFlags + private val fallbackPaintFlags = visual.fallbackTextView.paintFlags + + 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.hasExplicitHeight + val descriptors = + item.json.optJSONObject("checkbox")?.let { JSONArray().put(it) } + ?: 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) + 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 + // 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 = + if (selector && tone != "primary") "secondaryText" + else if (!selector && tone == "danger") "negative" else "primaryText" + val fallback = + if (token == "negative") "#C40006D3" + else if (selector || token == "secondaryText") "#0000009B" else "#000000DF" + 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.paintFlags = titlePaintFlags + // 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. + visual.iconBorder = !selector && icon?.has("backgroundColor") == true + 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", scaledDp(8f) / resources.displayMetrics.density) + 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")) + // Legacy: Yoga rounds the cumulative selector edges, not each gap. + else if (selector && item.hasExplicitHeight) dp(56) - dp(12) - dp(32) + else dp(12) + } + 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) { + super.onLayout(changed, l, t, r, b) + val item = tag as? NativeListItem ?: return + if ( + item.hasExplicitHeight && + 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/NativeListActivityRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt new file mode 100644 index 000000000..45d97f0d7 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt @@ -0,0 +1,243 @@ +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 + val hasFooterActions = (data.optJSONArray("footerActions")?.length() ?: 0) > 0 + gravity = + when (style.optJSONObject("container")?.optString("contentVerticalAlignment")) { + "bottom" -> Gravity.BOTTOM + "top" -> Gravity.TOP + "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")) { + "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( + // Legacy: a missing visual still reserves an empty, transparent slot. + data.optJSONObject("leading") ?: JSONObject().put("backgroundColor", "#00000000"), + 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 0 + } + 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", + ) + // Legacy: both amounts default to primaryText; only "+" amounts are positive. + else color("primaryText", "#000000DF"), + 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, + // 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", + ), + 20, + 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/NativeListAdapter.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt index 41a5b5344..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() { @@ -44,7 +47,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 @@ -52,18 +55,28 @@ 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() 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 + override fun getItemViewType(position: Int): Int = + 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, @@ -79,6 +92,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, @@ -135,7 +149,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) @@ -201,14 +215,14 @@ internal class NativeListAdapter( marketSourceEdges = DoubleArray(0) reorderItems = null suppressDifferUpdates = false - createdRows.forEach(NativeListRowView::dispose) + createdRows.forEach(NativeListRowHost::dispose) createdRows.clear() } 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/NativeListDataRowRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt new file mode 100644 index 000000000..2980ef2c7 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListDataRowRenderer.kt @@ -0,0 +1,487 @@ +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") 20 else 12) + val vp = + 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 + 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") + // 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 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, + 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" + else -> "primaryText" + } + val fallback = + when (token) { + "positive" -> "#00713FDE" + "negative" -> "#C40006D3" + "secondaryText" -> "#0000009B" + 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 companion object { + val TEXT_STYLE_KEYS = listOf("columns", "columnSecondary", "lineGap", "titleBadgeGap") + } +} + +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 + 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 { + 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, + fixedTableGeometry: Boolean = false, + ) { + 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 (!fixedTableGeometry) 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) { + 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/NativeListIdentityRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt new file mode 100644 index 000000000..6d53c4c06 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt @@ -0,0 +1,820 @@ +package com.margelo.nitro.nativelist + +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.hasExplicitHeight && + 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) + // Legacy: tertiary always ellipsizes at the end. + tertiary.ellipsize = TextUtils.TruncateAt.END + 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 + } + val explicitTextStyles = mutableMapOf() + 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) + explicitTextStyles[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) { 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 + } + + 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, + explicitStyle: (TextView) -> JSONObject?, + ) { + 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) + // 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)) + } + visit(this) + } + + 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() + // 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 (walletSource && fallbackIconName == "LockSolid") 40 + else if (walletSource && fallbackIconName == "PlusSmallOutline") 24 + else 18 + leadingFrame.walletTextOverlays = walletSource + leadingFrame.roundedImageRadius = + 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) + 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 { + // Legacy: Yoga rounds cumulative selector edges, not each gap separately. + marginEnd = + if ( + item.hasExplicitHeight && + item.json.optString("presentation") in setOf("accountSelector", "networkSelector") + ) + dp(12 + sizeDp + spacingDp) - dp(12) - dp(sizeDp) + else 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.hasExplicitHeight && + 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.hasExplicitHeight) 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.hasExplicitHeight) 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.hasExplicitHeight) 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.hasExplicitHeight + 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) }, + ) + } + 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") { + // 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.hasExplicitHeight) 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.hasExplicitHeight && + 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/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..626184a4f --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt @@ -0,0 +1,453 @@ +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>() + val fallbackTextView: TextView + get() = fallback + + var bitmapBorderWidth = 0 + + /** + * 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) = + 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 + 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, + secondaryVisual: JSONObject? = null, + ) { + 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") } + 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) + 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" && iconBorder) setStroke(1, color("#0000001F")) + if (visual.optString("borderStyle") == "dashed") + setStroke( + dp(if (walletTextOverlays) 1 else 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" && + (roundFollowsVisualShape || !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(overlayHeight(data)), + ) / 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 + 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) + 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) + 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 { (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() + } + + 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 + } + 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) + (minOf(w, h) * + if (visual.optJSONObject("fallbackIcon")?.optString("name") == "GlobusOutline") 1.2f + 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, + ) + 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), + ) + val inset = if (index == 0) bitmapBorderWidth else 0 + place(slot.view, x + inset, y + inset, imageWidth - 2 * inset, size - 2 * inset) + 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) { + if (index == 0 && style.optString("shape") == "circle" && !style.has("cornerRadius")) + outline.setOval(0, 0, view.width, view.height) + else outline.setRoundRect(-inset, -inset, view.width + inset, view.height + inset, 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 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 + 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/NativeListMarketRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMarketRenderer.kt new file mode 100644 index 000000000..0cab624e0 --- /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(), + ) + } + // Legacy parity: keep marketText's subscript AbsoluteSizeSpan (ceil(0.6 x fontSize)). + val text = SpannableStringBuilder(view.text) + 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.roundFollowsVisualShape = true + 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/NativeListMediaTileRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt new file mode 100644 index 000000000..03704432c --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt @@ -0,0 +1,234 @@ +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") == 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) + badge.background = + GradientDrawable().apply { + setColor(parseNativeListColor("#000000DF")) + cornerRadius = dp(10).toFloat() + 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) } + 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() }, + 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 = + when (state) { + "error" -> GradientDrawable().apply { setColor(tint("strongBackground", "#0000000F")) } + "empty" -> GradientDrawable().apply { setColor(Color.WHITE) } + else -> null + } + 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/NativeListMessageRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt new file mode 100644 index 000000000..b698dd6c9 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMessageRenderer.kt @@ -0,0 +1,232 @@ +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.LinearLayout +import com.facebook.react.uimanager.ThemedReactContext +import kotlin.math.roundToInt +import org.json.JSONObject + +internal object NativeListMessageRenderer { + 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 + }, + ) + } + + 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 { + 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(context: ThemedReactContext) : + NativeListRendererRowView(context) { + override val assetFields = listOf("leading", "thumbnail") + private val views = NativeListMessageRenderer.Views(context) + + override fun bindContent( + item: NativeListItem, + theme: JSONObject?, + layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + views.bind( + this, + NativeListMessageRenderer.resolve(reactContext, item, theme, sourceScale), + item, + theme, + sourceScale, + ) + } + + override fun recycleContent() = views.recycle() + + override fun disposeContent() = views.dispose() +} 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..a1a560aff --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListMetricCardRenderer.kt @@ -0,0 +1,551 @@ +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 + // 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")) + 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) + // 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) + 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 36bc73b9c..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 @@ -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,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? { @@ -47,6 +56,9 @@ private fun updateLengthPrefixed(digest: MessageDigest, value: String) { digest.update(bytes) } +// 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, val type: String, @@ -55,6 +67,15 @@ internal data class NativeListItem( val json: JSONObject, ) { val content: String = json.toString() + val rendererKey: NativeListRendererKey + get() = NativeListRendererRegistry.key(type) + 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 @@ -107,6 +128,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, @@ -141,6 +163,7 @@ internal data class NativeListConfig( val sectionIndexHapticsEnabled: Boolean, val sectionIndexCenteredInWindow: Boolean, val theme: JSONObject?, + val listStyle: JSONObject?, val fixedFooter: NativeListItem?, val items: List, ) { @@ -190,6 +213,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/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/NativeListRailRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRailRenderer.kt new file mode 100644 index 000000000..358bfd49b --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRailRenderer.kt @@ -0,0 +1,143 @@ +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) + // 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. + 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 new file mode 100644 index 000000000..b96e3d0ce --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRegistry.kt @@ -0,0 +1,42 @@ +package com.margelo.nitro.nativelist + +import com.facebook.react.uimanager.ThemedReactContext + +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.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( + "sectionHeader" to NativeListRendererKey.SECTION_HEADER, + "walletGroup" to NativeListRendererKey.WALLET_GROUP, + "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.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/NativeListRendererRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt new file mode 100644 index 000000000..514bd4162 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRendererRowView.kt @@ -0,0 +1,415 @@ +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.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, +} + +// 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 open fun bindSelectionContent( + item: NativeListItem, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) {} + + protected abstract fun recycleContent() + + protected abstract fun disposeContent() + + // 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) {} + + 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 { + 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 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 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 + + protected open fun pressContent(pressed: Boolean) {} + + 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 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 val rowSelected + get() = selected + + 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 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) = + runCatching { parseNativeListColor(value) }.getOrDefault(fallback) + + init { + orientation = HORIZONTAL + setOnClickListener { + if (consumesRowClick()) return@setOnClickListener + current + ?.takeIf { it.isRowPressEnabled } + ?.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) + // Rebuild drawables only when the pressed state actually changes. + if (touchPressed != wasPressed) 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 = usesSourceScale(item, 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.itemIndex = itemIndex + this.selected = selected + 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) minimumContentHeight(item, layout, sizeDelta) 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 = accessibilityText(item) + 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 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 + this.itemIndex = itemIndex + bindSelectionContent(item, checkboxState) + appearance() + } + + protected fun emitAction( + key: String, + view: View, + source: String, + slot: Int? = null, + target: NativeSelectionTarget? = null, + anchorInset: Int = 0, + ) { + val item = current ?: return + // 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, + target, + NativeListActionOrigin(view, this, bindingEpoch, source, slot, anchorInset), + ) + } + + protected open 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 = + 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 = backgroundGroupPosition(item, listLayout, itemIndex, showSelected) + 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") + // 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 = + GradientDrawable().apply { + setColor(fill) + cornerRadii = radii + } + foreground = + if (container.has("borderWidth") || defaultBorderWidth > 0) + GradientDrawable().apply { + setColor(Color.TRANSPARENT) + cornerRadii = radii + setStroke( + if (container.has("borderWidth")) stylePx(container.optDouble("borderWidth")) + else dp(defaultBorderWidth), + color(container.optString("borderColor"), defaultBorderColor(theme)), + ) + } + 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, + resolvedMeasureHeight(explicitHeight)?.let { + MeasureSpec.makeMeasureSpec(it, MeasureSpec.EXACTLY) + } ?: heightMeasureSpec, + ) + } + + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + current?.takeIf { it.json.optBoolean("separator") }?.let { + val start = + listStyle + ?.optJSONObject("separator") + ?.takeIf { it.has("inset") } + ?.let { stylePx(it.optDouble("inset")) } ?: dp(defaultSeparatorInset) + 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/NativeListResolvedText.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt new file mode 100644 index 000000000..a93e48d37 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListResolvedText.kt @@ -0,0 +1,163 @@ +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 android.widget.TextView +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 + if (lineHeight > 0) 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) ?: 0.0) * density).toFloat(), + ) + } + } +} + +/** + * 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/NativeListRowHost.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowHost.kt new file mode 100644 index 000000000..d3980d84d --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowHost.kt @@ -0,0 +1,70 @@ +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 template renderers. */ +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 deleted file mode 100644 index f3669e328..000000000 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ /dev/null @@ -1,4538 +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 - -// 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: NativeListRowView, - 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?) - -// 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) : TextView(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 - } - } -} - -private 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. -private 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) - } -} - -private class NativeListTableColumnView(context: android.content.Context) : LinearLayout(context) { - private val primaryLine = LinearLayout(context) - private val primary = TextView(context) - private val badges = LinearLayout(context) - private val secondaryLine = LinearLayout(context) - private val secondaryLeading = TextView(context) - private val secondary = TextView(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, dp(16))) - secondaryLine.addView(secondary, LayoutParams(LayoutParams.WRAP_CONTENT, dp(16)).apply { - marginStart = dp(4) - }) - primaryLine.addView(primary, LayoutParams(LayoutParams.WRAP_CONTENT, dp(20))) - primaryLine.addView(badges, LayoutParams(LayoutParams.WRAP_CONTENT, dp(16)).apply { - marginStart = dp(6) - }) - addView(primaryLine, LayoutParams(LayoutParams.WRAP_CONTENT, dp(20))) - addView(secondaryLine, LayoutParams(LayoutParams.MATCH_PARENT, dp(16)).apply { - topMargin = dp(4) - }) - } - - fun reset() { - 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, - ) { - reset() - 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, dp(16)).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 - } - } - - private fun dp(value: Int): Int = NativeListScale.dp(resources, value) - 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) { - 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 favoriteIcon = OneKeyIconView(context) - private val headerTitleIcon = OneKeyIconView(context) - 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 = TextView(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 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 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) { TextView(context) } - 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) - private val mediaBadge = TextView(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 { - 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 isMediaTile = false - 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 - 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 - 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 - - 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 - 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.addView(mediaBadge) - 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) - 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) - metricSubtitle.typeface = NativeListFonts.regular(context) - badgeLine.typeface = NativeListFonts.medium(context) - listOf(title, subtitle, tertiary, status, metricSubtitle, 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 - 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) - 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 - trailingViews.forEach { trailingColumn.addView(it) } - trailingIcons.forEach { trailingColumn.addView(it) } - 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) } - - 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()) - } - } - 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) - } - } - if ((tag as? NativeListItem)?.type == "mediaTile") { - leadingFrame.alpha = 0.8f - } else { - 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 start = 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 = 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, - ) - } - if (isMediaTile) { - val availableWidth = (MeasureSpec.getSize(widthMeasureSpec) - paddingLeft - paddingRight) - .coerceAtLeast(0) - leadingFrame.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, availableWidth) - } - // OneKey patch: RecyclerView must use the adapter's measured selector height. - super.onMeasure(widthMeasureSpec, 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 - 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) { - 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) { - 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 (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.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")) 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) - 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) - } - } - - fun bind( - item: NativeListItem, - theme: JSONObject?, - layout: String, - listOrientation: String, - itemIndex: Int?, - selected: Boolean, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - useSourceScale: Boolean = false, - ) { - 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.type == "system" && item.json.optString("presentation") == "market" && item.json.optString("variant") == "retry" || item.usesSelectorSourceScale || useSourceScale - reorderActive = false - if (!reusesImageIdentity) { - selectorImages.forEach(OneKeyImageReusableView::prepareForReuse) - leadingImages.forEach(OneKeyImageReusableView::prepareForReuse) - secondaryImage.prepareForReuse() - mediaNetworkImage.prepareForReuse() - metricVisualImages.forEach(OneKeyImageReusableView::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( - 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", - ), - ) - 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)) - } - } - title.setTextColor(primary) - subtitle.setTextColor(secondary) - tertiary.setTextColor(secondary) - status.setTextColor(secondary) - metricSubtitle.setTextColor(secondary) - badgeLine.setTextColor(accent) - separatorPaint.color = 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" - 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) - "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) - "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 - // their independently bound accessory controls to remain interactive. - val wholeRowPressEnabled = item.isWholeRowInteractive - 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) - } - - // 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 - } - } - - fun recycle() { - cancelMarketLongPress() - marketLongPressFired = false - touchPressed = false - restoreRestingBackground() - invalidateCurrentBinding() - boundKey = null - selectorImages.forEach(OneKeyImageReusableView::prepareForReuse) - leadingImages.forEach(OneKeyImageReusableView::prepareForReuse) - secondaryImage.prepareForReuse() - mediaNetworkImage.prepareForReuse() - metricVisualImages.forEach(OneKeyImageReusableView::prepareForReuse) - walletGroupRows.forEach { row -> - row.visibility = VISIBLE - row.alpha = 1f - row.recycle() - } - if (!reorderActive && walletGroupExpandAnimator?.isRunning != true) trimWalletGroupRows(0) - } - - 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 - 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 - 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) } - } - } - - fun bindStableSummary(item: NativeListItem) { - if ( - boundKey != item.key || - item.type != "sectionHeader" || - item.json.optString("variant") != "summary" - ) { - return - } - 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) - } - - fun dispose() { - cancelMarketLongPress() - invalidateCurrentBinding() - restoreRestingBackground() - selectorImages.forEach(OneKeyImageReusableView::dispose) - selectorImages.clear() - leadingImages.forEach(OneKeyImageReusableView::dispose) - secondaryImage.dispose() - mediaNetworkImage.dispose() - metricVisualImages.forEach(OneKeyImageReusableView::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") || item.type == "system" && item.json.optString("variant") == "warning" - 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 || item.type == "system" && item.json.optString("variant") == "warning" - 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, SelectorLineHeightSpan::class.java).forEach(text::removeSpan) - text.setSpan(SelectorLineHeightSpan(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 - } - - private fun resetViews() { - 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() - } - 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. - 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)) - isMediaTile = false - 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) - mediaMetadataRow.removeView(subtitle) - mediaMetadataRow.removeView(mediaNetworkImage) - mainColumn.removeView(mediaMetadataRow) - mainColumn.removeView(titleLine) - mainColumn.removeView(subtitle) - mainColumn.removeView(tertiary) - mainColumn.addView(titleLine, 0) - mainColumn.addView(subtitle, 1) - mainColumn.addView(tertiary, 2) - mainColumn.addView(actionLine) - 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 = "" - metricSubtitle.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 - metricSubtitle.visibility = GONE - 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() - 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 - mediaNetworkImage.visibility = GONE - metricVisualImages.forEach { - (it.parent as? ViewGroup)?.removeView(it) - it.visibility = GONE - it.background = null - it.clipToOutline = false - 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) - leadingIcon.glyphSizeDp = null - favoriteIcon.visibility = GONE - favoriteIcon.iconName = "" - 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 - dataContainer.visibility = GONE - dataColumns.forEach { it.visibility = GONE } - tableDataContainer.visibility = GONE - tableDataColumns.forEach { - it.reset() - it.visibility = GONE - } - 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 - mainColumn.removeView(skeletonPrimary) - mainColumn.removeView(skeletonSecondary) - 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 - } - - 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() - } - - 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 - } - - fun finishWalletGroupReorder( - durationMs: Long, - interpolator: TimeInterpolator, - completion: (() -> Unit)? = null, - ) { - 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) - 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) - 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.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, 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 = TextView(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)) - line.addView(badge, LayoutParams(LayoutParams.WRAP_CONTENT, dp(badgeHeight)).apply { if (index > 0) marginStart = dp(4) }) - } - 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 = TextView(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) - 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 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"), - 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 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") - } - } - - 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") - 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 - 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") - }, - ) - view.layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, column.optInt("weight", 1).toFloat()) - } - addView(dataContainer, weighted()) - } - - 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, 2) ?: 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 = sp(style.optDouble("fontSize").toFloat()) * resources.displayMetrics.density - view.setTextSize(TypedValue.COMPLEX_UNIT_PX, kotlin.math.ceil(sourceSize.toDouble()).toFloat()) - } - val text = SpannableStringBuilder(view.text) - 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() - text.setSpan(SelectorLineHeightSpan(lineHeight), 0, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - view.setLineSpacing(0f, 1f) - } - view.text = text - } - - 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) } - } - - 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 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") - "negative" -> color(theme, "negative", "#C40006D3") - 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 - 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, - ), - 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, - ) = TextView(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?, - 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 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") - 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, "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?, - sizeDp: Int = 40, - secondaryVisual: JSONObject? = null, - spacingDp: Int = 12, - heightDp: Int = sizeDp, - cornerRadiusDp: Float? = null, - ) { - leadingFrame.visibility = VISIBLE - leadingFrame.layoutParams = LayoutParams(dp(sizeDp), dp(heightDp)).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) - } - addView(leadingFrame) - leadingFallback.layoutParams = FrameLayout.LayoutParams(dp(sizeDp), dp(heightDp)) - if (visual == null) return - val kind = visual.optString("kind") - val shape = visual.optString( - "shape", - if (kind == "image" || isMediaTile) "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(sizeDp, heightDp) - 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(sizeDp, heightDp)), - ) - } - leadingFallback.background = roundedFill( - if (handlesSourceFallback) parseNativeListColor(imagePlaceholderColor) else visualBackground, - cornerRadiusDp ?: leadingCornerRadius(shape, minOf(sizeDp, heightDp)), - ) - 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 ?: sizeDp), - dp(fallbackIconSizeDp ?: heightDp), - 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(sizeDp, heightDp))) - } - 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 ?: sizeDp), - dp(fallbackIconSizeDp ?: heightDp), - 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 + sizeDp + 4) * density).roundToInt() } - val badgeLeft = marketPadding?.let { ((it + sizeDp - 16) * density).roundToInt() } - val avatarRight = marketPadding?.let { ((it + sizeDp) * 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 = sizeDp, - heightDp = heightDp, - 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(sizeDp, heightDp)) - // 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 + (sizeDp - 1) * density).roundToInt() - (sourceLeft + density).roundToInt() - image.layoutParams = FrameLayout.LayoutParams(imageWidth, dp(heightDp - 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", - 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(sizeDp / 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)) - applyValueSegments(trailingViews[textIndex], accessory.optJSONArray("textSegments")) - textIndex++ - } - "valuePair" -> showTrailingValuePair(textIndex++, accessory, theme) - "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", - ) - 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. - rowBackground = color(theme, "rowBackground", "#FFFFFF") - } else 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" - "rail" -> "rail" - else -> item.json.optString("groupPosition") - } - } - var backgroundGroupPosition = if (item.type == "mediaTile") "mediaTile" else 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 = "" - } 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) - 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 == "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( - "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 isAccountSelectorAction = - item.type == "action" && item.json.optString("presentation") == "accountSelector" - val isNetworkSelectorSection = - item.type == "sectionHeader" && item.json.optString("presentation") == "networkSelector" - title.textSize = sp( - if (isWalletSidebar) { - 12f - } else { - when (item.type) { - "message" -> 14f - "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 - } - "rail" -> 12f - "system" -> 14f - "metricCard" -> if (item.json.optString("size") == "large") 24f else 18f - else -> 16f - } - }, - ) - title.typeface = if (isAccountSelectorAction && item.json.has("icon")) { - NativeListFonts.medium(context) - } else if (isWalletSidebar || isAccountSelectorIdentity || isAccountSelectorAction) { - 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) - } - "system" -> NativeListFonts.regular(context) - "message", "metricCard" -> NativeListFonts.semibold(context) - else -> NativeListFonts.medium(context) - } - } - subtitle.textSize = sp(when (item.type) { - "mediaTile" -> 12f - "metricCard" -> 11f - 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)) - } else if (item.type == "action") { - 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) - 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. - 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) { - 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 == "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. - // (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) { - "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 - "performance" -> 0 - else -> 132 - } - "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 - } - "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 - } - "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 { - 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 - 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 - 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)) - } - - 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 - if (listOrientation == "horizontal") { - params.width = when (item.type) { - "rail" -> railWidth() - "mediaTile" -> dp(200) - 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 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) - 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 = 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 = 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 } - "rail" -> FloatArray(8) { scaledDp(8f) } - "mediaTile" -> FloatArray(8) { scaledDp(16f) } - else -> FloatArray(8) - } - } - -} - -private 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", - ), - ) - } -} - -private 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: NativeListRowView) : - androidx.recyclerview.widget.RecyclerView.ViewHolder(rowView) 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..ae48631bb --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt @@ -0,0 +1,748 @@ +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" -> 16 + 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" -> 12f + else -> 14f + } + ) + title.typeface = + when { + network && + item.hasExplicitHeight && + 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) + else -> NativeListFonts.semibold(context) + } + TextViewCompat.setLineHeight( + title, + dp( + when { + network && variant != "summary" -> 20 + variant in setOf("gallery", "summary") -> 24 + layout == "table" -> 14 + variant == "history" -> 16 + else -> 20 + } + ), + ) + if (layout == "table") TextViewCompat.setLineHeight(trailingViews[0], dp(14)) + if ( + network && + item.hasExplicitHeight && + 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) + // 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) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val item = currentItem ?: return + if ( + item.hasExplicitHeight && + 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.hasExplicitHeight && + 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" + clipToPadding = true + // 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" + val isExplicitNetworkHeader = isNetworkSelector && item.hasExplicitHeight + 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 (!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) { + 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) } + // Legacy: selector checkbox borders may round into the row padding. + if (checkboxControl.requestsUnclippedHost) clipToPadding = false + } + 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/NativeListSystemRenderer.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSystemRenderer.kt new file mode 100644 index 000000000..2c27c10d9 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListSystemRenderer.kt @@ -0,0 +1,464 @@ +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? { + // 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" && + 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) + + // 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?, + layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + recycleContent() + val data = item.json + val variant = data.optString("variant") + val market = data.optString("presentation") == "market" + 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", "#000000DF") + 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()) { + // 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 (!hasExplicitTruncation(style, "message")) + title.ellipsize = android.text.TextUtils.TruncateAt.END + 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, + ) + // 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) + .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, + ) + if (!hasExplicitTruncation(style, "message")) + title.ellipsize = android.text.TextUtils.TruncateAt.END + 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"), 6) + 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, + // Legacy: only the market retry reads actionText; this label is fixed. + "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 { + // 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")) + 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() + } +} + +/** 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 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..667018e17 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListTextView.kt @@ -0,0 +1,25 @@ +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) + } +} 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..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 @@ -226,12 +226,69 @@ 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.ACTION.ordinal) + private var footerRendererKey = NativeListRendererKey.ACTION private val sectionIndexView = NativeListSectionIndexView(context) private val sectionIndexPreview = TextView(context) private var config: NativeListConfig? = null private var usesSelectorSourceScale = false - private var stickyDecoration: StickySectionHeaderDecoration? = null + 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) { + 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) { + startForwarding(initial, event) + return true + } + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { down?.recycle(); down = null } + } + 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) + 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 + } + // 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 @@ -307,6 +364,9 @@ 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)) contentContainer.addView( sectionIndexView, FrameLayout.LayoutParams( @@ -342,6 +402,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 @@ -459,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")) { @@ -467,6 +531,7 @@ class NativeListView( } override fun onDetachedFromWindow() { + stopStickyHeaderTracking() stopSectionIndexAttachmentTracking() scheduleSectionIndexAttachmentToList() updateRefreshIndicatorOffset(0) @@ -500,9 +565,13 @@ 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 + // 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 } @@ -535,6 +604,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 @@ -1119,6 +1189,9 @@ class NativeListView( itemTouchHelper = null footerView.recycle() footerView.dispose() + stopStickyHeaderTracking() + stickyHeaderView.dispose() + stickyHeaderHost.visibility = GONE recyclerView.swapAdapter(null, false) adapter.dispose() } @@ -1151,10 +1224,63 @@ 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) { + stickyHeaderHost.visibility = GONE + 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 + // 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) { @@ -1264,6 +1390,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 @@ -1380,7 +1520,21 @@ 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( footer, next.theme, @@ -1490,7 +1644,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") @@ -2593,109 +2747,18 @@ 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!!.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 } } -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() { @@ -2705,9 +2768,16 @@ 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") - 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() 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/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..a738e8f38 --- /dev/null +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListWalletGroupRenderer.kt @@ -0,0 +1,407 @@ +package com.margelo.nitro.nativelist + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.TimeInterpolator +import android.animation.ValueAnimator +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 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 unselectedBackground( + item: NativeListItem, + theme: JSONObject?, + layout: String, + itemIndex: Int?, + ) = parseNativeListColor(theme?.optString("subduedBackground", "#F9F9F9") ?: "#F9F9F9") + + override fun resolvedMeasureHeight(explicit: Int?): Int? = + if (compact) compactHeight 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 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(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 = NativeListFonts.semibold(context) + 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") + 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 styledVertical = + style?.takeIf { it.has("verticalPadding") }?.let { stylePx(it.optDouble("verticalPadding")) } + val vertical = styledVertical ?: 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) + ?: 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 -> + // 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 -> + 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, + ) + // 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 + 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 { + // Legacy: the badge is inset 4dp from the group's own edges. + rightMargin = dp(4) - horizontal + bottomMargin = dp(4) - vertical + } + } + + 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) + // 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() + 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() { + 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 + if (active) { + membersColumn.visibility = GONE + } else { + showExpandedMembers(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.coerceAtLeast(dp(68)) + compact = false + compactParent?.setReorderActive(false) + 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 + 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 + showExpandedMembers(1f) + requestLayout() + val pending = expansionCompletion + expansionCompletion = null + pending?.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() {} +} 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/DESIGN.md b/native-views/react-native-native-list/docs/DESIGN.md index 6f5bf092e..22fab9890 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. @@ -22,16 +23,30 @@ Business concepts such as asset, account, network, wallet, and settings map to the same presentation templates instead of introducing business-specific row types. +Each row also accepts a bounded `style`, whose keys name model fields rather than +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. 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. 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 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 @@ -47,6 +62,113 @@ cells. 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. 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. + +Message completes the first renderer pilot, with one platform per commit: + +| Platform | Renderer and host ownership | Container ownership | +| --- | --- | --- | +| 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 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. 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 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 +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. + +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. 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. +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. + +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. + +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 @@ -55,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` @@ -109,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 @@ -118,8 +277,11 @@ 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 +`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. @@ -127,3 +289,350 @@ 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. + +### 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. + +### 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. + +### 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. + +### 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. + +### 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 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 +`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 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 +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`. + +## 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. + +## 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 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 +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. 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 +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 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; +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. +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. + +### 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 + `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. +- 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/MIGRATION_BASELINE.md b/native-views/react-native-native-list/docs/MIGRATION_BASELINE.md new file mode 100644 index 000000000..609fdb60b --- /dev/null +++ b/native-views/react-native-native-list/docs/MIGRATION_BASELINE.md @@ -0,0 +1,117 @@ +# 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. | 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. | 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, +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 +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. + +## 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. + +## 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. + +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/docs/ROW_TEMPLATES.md b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md new file mode 100644 index 000000000..489f36439 --- /dev/null +++ b/native-views/react-native-native-list/docs/ROW_TEMPLATES.md @@ -0,0 +1,359 @@ +# 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. 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 | +| --- | --- | --- | +| [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 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 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. + +## 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. + 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 + 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 + 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. +- **Verification:** Web style ordering and removal have DOM regression coverage + for both selector presentations. Rendered geometry remains to be verified. + +## 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. +- **Default appearance:** the group border is drawn beneath member content. + 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 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. + 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. +- **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` 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. +- **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. 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. + `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. 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 (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. + +## 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. + 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. + +## 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`. Primary-image overrides are + supported on all three platforms; network/close overlays retain their anchors. + +## 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. + 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. +- **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 + +![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. +- **Pinned rendering:** the container reuses the ordinary header renderer on all + platforms, including headers with a value or checkbox. Summary is non-sticky. + +## 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 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. +- **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. +- **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 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/SPEC.md b/native-views/react-native-native-list/docs/SPEC.md new file mode 100644 index 000000000..3397a849c --- /dev/null +++ b/native-views/react-native-native-list/docs/SPEC.md @@ -0,0 +1,358 @@ +# 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. 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. 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 + +`row.key` identifies data; renderer reuse identity describes compatible view +structure. Content, styles, height and placement must not enter reuse 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 + +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). 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 + +Snapshots and patches cross the bridge in batches. Visible rows use platform +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. + +## Conformance and six-stage migration + +| 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 | 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 | 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 | 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 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 +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. 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. +This can change **automatic** Message heights; explicit row/container heights +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. + +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. + +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. + +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. +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 +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. + +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. + +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. + +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 + +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 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. +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. 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; +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. Stages 5 and 6 acceptance follow below. This is not a performance signoff. + +### 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, +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. 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. + +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. + +### 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. 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, 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. +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. + +### 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 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. + +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 180 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 + 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 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), 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. +- **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 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 + 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 new file mode 100644 index 000000000..f10846bc3 --- /dev/null +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -0,0 +1,978 @@ +# NativeList Style Spec + +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. + +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. +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 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: + +- 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). + +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, 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 +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. + +### 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. `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 + +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: '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 | +| --- | --- | +| 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 | `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 | 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 +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. +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 + +### 3.1 Color tokens + +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 | +| --- | --- | --- | +| `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 | 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). + +## 4. T2 — Template style surface + +`style.X` modifies the rendering of the **model field named `X`**, regardless of +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 +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 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). + +### 4.1 Shared configurable properties + +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 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. + +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 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**. + +| 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 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. + +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.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 | +| --- | --- | --- | +| `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 | +| `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, 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 style itself can allocate a new row height. +style: { + container: { + height: 112, 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. + +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. + +`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. + +### 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 **=** | +| `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 **=** | +| 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`, drawn beneath members | 1 | dp(1) | 1px **=** | +| compact drag height | 68 | 68 | max(68, parent + 2 × inset) | 68 **≠** | + +### 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 / 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 **≠** | +| 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. Style keys follow these +model fields, whatever the renderer's internal view names are. + +| 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 + +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 pinned +header reuses the ordinary header row view and callbacks (§6.3). + +## 5. T3 — List chrome + +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 | 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. 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 | +| 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 | +| --- | --- | --- | --- | --- | +| Content padding | yes (`layout.contentPadding*`) | `contentInset` | `setPadding` | `paddingValues` | +| Item spacing | yes (`layout.itemSpacing`) | flow layout spacing | `ItemSpacingDecoration` | layout gap | +| 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 | +| 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. + +`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. + +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 +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 +that cites this table and carries device verification. + +### 6.1 Row heights + +Row height is computed from data by three independent implementations — +the iOS registered renderer measurement, Android registered row measurement, and +Web registered estimate/DOM measurement. Preserved defaults still differ: + +| Template | iOS | Android | Web | +| --- | --- | --- | --- | +| `rail` | 40 | 28 | 40 | +| `activity` + footer actions | 100 | 104 | 100 | +| `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 +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. + +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 | +| `identity` subtitle lines (no `subtitleLines`) | 1 | **2** | 1 (CSS single line) | +| 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` | + +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 + +- **Android pinned headers reuse normal row rendering.** The old Canvas-only + `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 + 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. + 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. + 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. + +### 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. + +### 6.5 Consolidated gap audit + +| Former gap | Resolution in source | Remaining verification | +| --- | --- | --- | +| 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 container appearance and padding; members bind their own styles | Independent group/member styling during reorder | +| 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 | 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 | +| 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 +unstyled defaults in §6 remain distinct from the unified configurable surface; +this change is not a wholesale redesign of those defaults. + +## 7. Isolation 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`/`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. 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; + 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 Template structure and integration layout responsibility + +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 + 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. **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) <= 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 + 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 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; + 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 + +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. + +| Platform | Owner | Order and reset | +| --- | --- | --- | +| 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. 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. +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 + +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 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)? +- [ ] 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 | +| 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 | +| 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 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-24 (Web legacy-default audit) + +- 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` + 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 + 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 + 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 + 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/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-style-regions.svg b/native-views/react-native-native-list/docs/images/row-style-regions.svg new file mode 100644 index 000000000..7ce2b2588 --- /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. container.height can define the 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: 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/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. + + 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..47b6325e7 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListAccessoryStack.swift @@ -0,0 +1,680 @@ +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 { + 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 firstVisibleControl: UIView? { accessoryButtons.first { !$0.isHidden } } + 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], defaultSpacing: CGFloat = 2, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + reset() + currentItem = item + currentTheme = theme + 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) + 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 } + } + func updateSelection( + _ item: NativeListItem, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + currentItem = item + 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 + } + 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.transform = .identity + 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.hasExplicitHeight { + 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?.hasExplicitHeight == true && 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?.hasExplicitHeight == true + { + 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..bb9dfd145 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListActionRenderer.swift @@ -0,0 +1,108 @@ +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) + 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 { 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)) + 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 { + // 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 { + 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) + if !keepsBorder { 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/NativeListActivityRenderer.swift b/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift new file mode 100644 index 000000000..1f1223bf0 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListActivityRenderer.swift @@ -0,0 +1,179 @@ +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() + 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($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 + 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) + 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 + ) { + 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(direction: button.effectiveUserInterfaceLayoutDirection), 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) + // 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( + 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 deleted file mode 100644 index 4908595e6..000000000 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ /dev/null @@ -1,4300 +0,0 @@ -import Foundation -// OneKey patch: preserve native font faces while enabling tabular number features. -import CoreText -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() } - 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: NativeListCell? - 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: NativeListCell, - 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. -private final class NativeListAccessoryButton: UIButton { - var pressedBackgroundColor: UIColor? - - 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() - 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 - titleLabel.frame = CGRect( - x: floor(x * sourcePixelScale) / sourcePixelScale, - y: (bounds.height - lineHeight) / 2, - width: width, height: lineHeight - ) - 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 - } -} - -private final class NativeListInsetLabel: UILabel { - 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: UILabel { - 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 - } -} - -private final class NativeListTableColumnView: UIStackView { - private let primaryLine = UIStackView() - private let primaryLabel = UILabel() - private let badgesStack = UIStackView() - private let secondaryLine = UIStackView() - private let secondaryLeadingLabel = UILabel() - private let secondaryLabel = UILabel() - - 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() { - 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.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 - } - } - - 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: UICollectionViewCell { - static let reuseIdentifier = "NativeListCell" - - private let rootStack = UIStackView() - 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 mediaNetworkImage = 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) - 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 = UILabel() - // 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 statusLabel = NativeListInsetLabel() - private let metricSubtitleLabel = UILabel() - 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) } - 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) } - 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() - private let skeletonPrimary = UIView() - private let skeletonSecondary = UIView() - 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 mediaHeight: 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 dataWeightConstraints: [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 footerActionKeys: [String] = [] - 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" - 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] = [:] - private(set) var bindingEpoch = 0 - - var onAction: ((NativeListItem, String, NativeSelectionTarget?, NativeListActionOrigin?) -> Void)? - var onBindingInvalidated: ((NativeListCell, Int) -> Void)? - - 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 - favoriteIconImageView.translatesAutoresizingMaskIntoConstraints = false - favoriteIconImageView.contentMode = .scaleAspectFit - favoriteIconImageView.image = nativeListIcon(named: "StarOutline") - 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([ - 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), - 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) - mediaHeight = leadingContainer.heightAnchor.constraint(equalToConstant: 120) - 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) - 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 - NSLayoutConstraint.activate([ - unreadDot.widthAnchor.constraint(equalToConstant: 8), - unreadDot.heightAnchor.constraint(equalToConstant: 8), - unreadDot.topAnchor.constraint(equalTo: leadingContainer.topAnchor), - 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 - 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) - 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] - .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 - 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) - - 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 - tableDataStack.spacing = 8 - tableDataColumns.forEach(tableDataStack.addArrangedSubview) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - 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 = 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) - } - if currentItem?.type == "mediaTile" { - mediaHeight.constant = max(0, contentView.bounds.width - 20) - } - if currentItem?.type == "identity", currentItem?.data["height"] != 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() - mediaNetworkImage.prepareForReuse() - } - - 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() - mediaNetworkImage.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 - metricSubtitleLabel.textColor = secondary - 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 - separatorView.backgroundColor = nativeListColor(theme, "separator", "#E0E0E0") - separatorLeadingConstraint.constant = item.type == "identity" ? 60 : 12 - separatorView.isHidden = !item.data.bool("separator") - restingBackgroundColor = selectionBackgroundColor( - item: item, - theme: theme, - layout: layout, - itemIndex: itemIndex, - selected: selected - ) - 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["backgroundColor"] as? String { - selectorFullWidthBackground.backgroundColor = UIColor(nativeListHex: background, fallback: .clear).cgColor - contentView.layer.insertSublayer(selectorFullWidthBackground, at: 0) - 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 - } - } - 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.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 "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) - 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 - } - 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) - } - } - - func updateSelection( - item: NativeListItem, - selected: Bool, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String - ) { - guard currentItem?.key == item.key else { return } - restoreSelectorTypography() - defer { 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")) || item.type == "system" && item.data.string("variant") == "warning" 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 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. - 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. - 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 { - return UIColor(nativeListHex: backgroundColor, fallback: color) - } - return color - } - - private func reset() { - titleLabel.transform = .identity - restoreSelectorTypography() - // 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() - mediaMetadataStack.removeArrangedSubview(subtitleLabel) - mediaMetadataStack.removeArrangedSubview(mediaNetworkImage) - mediaMetadataStack.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 - mediaHeight.isActive = false - secondaryWidth.isActive = false - secondaryHeight.isActive = false - NSLayoutConstraint.deactivate(leadingSlotConstraints) - leadingSlotConstraints.removeAll() - NSLayoutConstraint.deactivate(dataWeightConstraints) - dataWeightConstraints.removeAll() - NSLayoutConstraint.deactivate(accessorySizeConstraints) - accessorySizeConstraints.removeAll() - dataStack.distribution = .fill - 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 - favoriteIconImageView.isHidden = true - favoriteIconImageView.image = nativeListIcon(named: "StarOutline") - leadingActionButton.isHidden = true - leadingActionButton.setImage(nil, for: .normal) - leadingActionButton.setImage(nil, for: .disabled) - 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 - 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 - 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 - 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, metricSubtitleLabel, badgeLabel, actionStack] - .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 - 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 - dataLabels.forEach { - $0.isHidden = true - $0.text = nil - $0.attributedText = nil - $0.numberOfLines = 1 - } - tableDataColumns.forEach { - $0.reset() - $0.isHidden = true - } - skeletonPrimary.removeFromSuperview() - skeletonSecondary.removeFromSuperview() - accessoryActions = [] - footerActionKeys = [] - checkboxAction = nil - boundCheckboxData = nil - boundCheckboxTarget = nil - leadingActionKey = nil - marketBadgeActionKeys = [] - layer.maskedCorners = [] - layer.cornerRadius = 0 - layer.cornerCurve = .circular - contentView.layer.cornerRadius = 0 - 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: 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.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?.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 - } - } - - func setPressed(_ pressed: Bool) { - if currentItem?.type == "walletGroup", walletGroupCompactAppearanceActive { - walletGroupCompactCell?.setPressed(pressed) - isHighlighted = false - return - } - isHighlighted = pressed && isUserInteractionEnabled - } - - 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 - } - - 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() - } - } - - func prepareWalletGroupReorderExpansion() { - guard currentItem?.type == "walletGroup" else { return } - walletGroupCompactAppearanceActive = false - rootStack.isHidden = false - rootStack.alpha = 0 - walletGroupCompactContainer.isHidden = false - walletGroupCompactContainer.alpha = 1 - restoreWalletGroupOuterAppearance() - } - - func animateWalletGroupReorderExpansion() { - guard currentItem?.type == "walletGroup" else { return } - rootStack.alpha = 1 - walletGroupCompactContainer.alpha = 0 - } - - 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 updateBackgroundColor() { - 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 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" - let radius: CGFloat - if isWalletSidebar { - radius = 20 - } else { - radius = currentItem?.type == "rail" ? 8 : 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 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 - 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) - line.addArrangedSubview(label) - } - 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 = UILabel() - 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 - } - 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 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"), - 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 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))) - ) - 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( - _ 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") - 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.. 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(2, 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(2, 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 = 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) - } - - 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]) - 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) - 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 - 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]?, - 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) - } - - 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() { - 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 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]?, - secondaryVisual: [String: Any]? = nil, - key: String, - to stack: UIStackView? = nil - ) { - 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" || mediaHeight.isActive ? "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 { sourceFallbackStateKey($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 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, - 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")) - 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) - 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?.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 { - // 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" || currentItem?.type == "system" && currentItem?.data.string("presentation") == "market" && currentItem?.data.string("variant") == "retry" - 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: 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 = isWalletSidebar ? 20 : 12 - layer.cornerCurve = isWalletSidebar ? .continuous : .circular - 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 } - let action = accessoryActions[sender.tag] - guard !action.0.isEmpty else { return } - let source = item.type == "mediaTile" && item.data.string("closeActionKey") == action.0 - ? "mediaClose" - : "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 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 } - 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 - ) - } - - 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/NativeListDataRowRenderer.swift b/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift new file mode 100644 index 000000000..815d3d75d --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListDataRowRenderer.swift @@ -0,0 +1,444 @@ +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() + 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" ? 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( + 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 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( + 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 + } + 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( + 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() + private var linear = false + + 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() { + linear = false + 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 + } + } + + /// 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 + // 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: " \(badge.string("text")) ", + attributes: [ + .font: nativeListFont(ofSize: 12, weight: .medium), + .foregroundColor: nativeListColor(theme, "info", "#0D74CE"), + .backgroundColor: UIColor(nativeListHex: "#008FF519", fallback: .systemBlue), + ])) + } + 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) + } + 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/NativeListIdentityRenderer.swift b/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift new file mode 100644 index 000000000..41f329726 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListIdentityRenderer.swift @@ -0,0 +1,685 @@ +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: .system) + 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.hasExplicitHeight { return 20 } + if isHighlighted + || (item.hasExplicitHeight + && ["accountSelector", "networkSelector"].contains(presentation)) + { + return 12 + } + 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 + } + 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?.hasExplicitHeight == true, + 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 { + // Legacy reset the leading container: no stale avatar, initials, border or overlays. + visual.clear() + 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?.hasExplicitHeight == true + visual.singleOuterMask = + currentItem?.data.string("presentation") == "networkSelector" + && currentItem?.hasExplicitHeight == true + 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.hasExplicitHeight { + 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.hasExplicitHeight + 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) + } + 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" { + 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) + // 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", + 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 + let presentation = currentItem?.data.string("presentation") ?? "" + if presentation == "walletSidebar" { + 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 && presentation == "networkSelector" + if (currentItem?.hasExplicitHeight == true + && ["accountSelector", "walletSidebar"].contains(presentation)) || isNetworkFallback + { + 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/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..5b8b1549a --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListLeadingVisual.swift @@ -0,0 +1,372 @@ +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 + var bitmapBorderWidth: CGFloat = 0 + 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? + var overlayTextHorizontalInset: CGFloat? + 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, secondaryVisual: [String: Any]? = nil + ) { + 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")) + } + 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) + 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 + 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() + 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 + // 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 + paragraph.maximumLineHeight = line + paragraph.alignment = .center + label.attributedText = NSAttributedString( + string: data.string("text"), + 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) + 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 = + 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() + 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) : glyphSize + 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( + 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 && 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 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 + } 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)) + // 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)) + 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: 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 = dashedBorderWidth + dashedBorder.lineDashPattern = [4, 4] + dashedBorder.path = + UIBezierPath(ovalIn: bounds.insetBy(dx: dashedBorderWidth / 2, dy: dashedBorderWidth / 2)) + .cgPath + } + func recycle() { + 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 new file mode 100644 index 000000000..9144e1bd1 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListMarketRenderer.swift @@ -0,0 +1,698 @@ +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: .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: .system) + 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 { + // 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"), + 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/NativeListMediaTileRenderer.swift b/native-views/react-native-native-list/ios/NativeListMediaTileRenderer.swift new file mode 100644 index 000000000..ed7da9449 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListMediaTileRenderer.swift @@ -0,0 +1,184 @@ +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() + 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 ownsImageFill = false + 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, 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, 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, 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") { + // 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") { + // 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() + 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/NativeListMessageRenderer.swift b/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift new file mode 100644 index 000000000..af2c74369 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListMessageRenderer.swift @@ -0,0 +1,168 @@ +import UIKit + +enum NativeListMessageRenderer { + 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 column = UIStackView() + let title = NativeListTextLabel() + let body = NativeListTextLabel() + let time = NativeListInsetLabel() + private var leading: NativeListLeadingVisual? + private var thumbnail: NativeListImageSlot? + private var sizeConstraints: [NSLayoutConstraint] = [] + + init(root: UIStackView = UIStackView()) { + self.root = root + root.axis = .horizontal + root.spacing = 12 + column.axis = .vertical + column.alignment = .fill + column.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal) + column.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + title.setContentCompressionResistancePriority(.required, for: .horizontal) + [title, body, time].forEach(column.addArrangedSubview) + root.addArrangedSubview(column) + } + + 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() + } + NSLayoutConstraint.activate(sizeConstraints) + } + func recycle() { + leading?.recycle() + thumbnail?.recycle() + } + } + +} + +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) } + 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 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/NativeListMetricCardRenderer.swift b/native-views/react-native-native-list/ios/NativeListMetricCardRenderer.swift new file mode 100644 index 000000000..1a021aff4 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListMetricCardRenderer.swift @@ -0,0 +1,420 @@ +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() + 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 = "" + 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 { + // 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 + 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 + // 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( + _ item: NativeListItem, theme: [String: Any]?, layout: String, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + rowKey = item.key + boundTheme = theme + 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 + // 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( + _ 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, placeholder: 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: placeholder) + } 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 195713a1a..1698ec7bc 100644 --- a/native-views/react-native-native-list/ios/NativeListModels.swift +++ b/native-views/react-native-native-list/ios/NativeListModels.swift @@ -1,6 +1,14 @@ import Foundation 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 { let key: String let type: String @@ -9,6 +17,18 @@ struct NativeListItem { let data: [String: Any] let content: String + let rendererKey: NativeListRendererKey + + var styledHeight: CGFloat? { + (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) @@ -33,8 +53,12 @@ struct NativeListItem { 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 self.sectionKey = data["sectionKey"] as? String self.revision = data["revision"] as? Int ?? 0 self.data = data @@ -42,6 +66,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", ] @@ -70,6 +100,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 +163,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/NativeListRailRenderer.swift b/native-views/react-native-native-list/ios/NativeListRailRenderer.swift new file mode 100644 index 000000000..3883b05f0 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListRailRenderer.swift @@ -0,0 +1,152 @@ +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 { + 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() + private let titleLine = UIStackView() + private let textLine = UIStackView() + 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 } + // 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) + 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/NativeListRendererCell.swift b/native-views/react-native-native-list/ios/NativeListRendererCell.swift new file mode 100644 index 000000000..cba877038 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListRendererCell.swift @@ -0,0 +1,311 @@ +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 usesIntrinsicContentHeight: Bool { false } + 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 } + /// 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( + _ item: NativeListItem, theme: [String: Any]?, layout: String, + 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 + { + 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 where !nativeListValuesEqual(old.data[field], new.data[field]) { + return .assets + } + return .content + } + 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]? + 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(set) var selectedState = false + // Last applied list-level inputs; compared structurally (no serialization/hashing per bind). + // 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) + 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") } + + 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 + ) { + let update = update(from: self.item, to: item) + let inputs = ( + theme: theme.map { $0 as NSDictionary }, layout: layout, + listStyle: listStyle.map { $0 as NSDictionary }, + direction: effectiveUserInterfaceLayoutDirection + ) + let inputsChanged = + boundInputs.map { + // 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) } + bindingEpoch &+= 1 + if update == .replace { + 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 || usesIntrinsicContentHeight + ? (defaultVerticalAlignment ?? "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 + } + boundInputs = inputs + } + self.item = item + self.theme = theme + self.layout = layout + self.itemIndex = itemIndex + self.selectedState = selected + bindSelectionContent(item, checkboxState: checkboxState) + accessibilityLabel = accessibilityText(item) + 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 + 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, 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, anchorInset: anchorInset)) + } + + 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 = + showSelection + ? nativeListColor(theme, "rowSelectedBackground", "#F0F0F0") + : unselectedBackground(item, theme: theme, layout: layout, itemIndex: itemIndex) + let rowFill = + container["backgroundColor"] + ?? (honorsRowBackgroundColor ? item.data["backgroundColor"] : nil) + if let color = rowFill as? String { + background = UIColor(nativeListHex: color, fallback: background) + } + contentView.backgroundColor = + isHighlighted && pressChangesBackground + ? 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: 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 + layer.cornerRadius = radius + layer.masksToBounds = radius > 0 + contentView.layer.cornerRadius = radius + contentView.layer.maskedCorners = layer.maskedCorners + contentView.layer.borderWidth = CGFloat( + container.double("borderWidth", default: Double(defaultBorderWidth))) + contentView.layer.borderColor = + UIColor( + nativeListHex: container.string("borderColor"), fallback: defaultBorderColor(theme) + ).cgColor + if item.data.bool("backgroundFullWidth"), + let fill = rowFill 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 + boundInputs = nil + recycleContent() + fullWidthBackground.removeFromSuperlayer() + isHighlighted = false + } +} + +/// 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.flatMap { $0 is NSNull ? nil : $0 }, rhs.flatMap { $0 is NSNull ? nil : $0 }) { + case (nil, nil): return true + 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.. NativeListRowHost)] = [ + .sectionHeader: ( + NativeListSectionHeaderCell.self, { NativeListSectionHeaderCell(frame: .zero) } + ), + .walletGroup: (NativeListWalletGroupCell.self, { NativeListWalletGroupCell(frame: .zero) }), + .identity: (NativeListIdentityCell.self, { NativeListIdentityCell(frame: .zero) }), + .market: (NativeListMarketCell.self, { NativeListMarketCell(frame: .zero) }), + .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) }), + .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 { + "NativeList.\(key.rawValue)" + } + static func register(in collectionView: UICollectionView) { + for (key, host) in hosts { + collectionView.register(host.type, forCellWithReuseIdentifier: reuseIdentifier(for: key)) + } + } + static func appliesSizePreset(_ item: NativeListItem) -> Bool { + hosts[item.rendererKey]!.type.appliesSizePreset(item) + } + static func measure(_ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String) + -> CGFloat? + { + 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 new file mode 100644 index 000000000..48f0f89c2 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListResolvedText.swift @@ -0,0 +1,122 @@ +import UIKit + +struct NativeListResolvedText { + let text: String + let font: UIFont + let color: UIColor + let lineHeight: CGFloat + let lines: Int + /// 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, 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 = + 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 + } + 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))) + explicitLineHeight = style["lineHeight"] != nil + alignmentName = style["alignment"] as? String + 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")) + } + + /// 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) + } + + func attributed(direction: UIUserInterfaceLayoutDirection) -> NSAttributedString { + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = lineHeight + paragraph.maximumLineHeight = lineHeight + paragraph.alignment = alignment(for: direction) + if explicitTruncation || preservesBreakMode { 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) + } + + /// 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 + label.textColor = color + label.numberOfLines = lines + label.textAlignment = alignment(for: direction) + label.lineBreakMode = breakMode + label.rowVerticalAlignment = verticalAlignment + label.rowOffsetY = offsetY + 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 } + 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) + 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/NativeListRowHost.swift b/native-views/react-native-native-list/ios/NativeListRowHost.swift new file mode 100644 index 000000000..c3527b109 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListRowHost.swift @@ -0,0 +1,63 @@ +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 + } +} + +// 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? + { nil } + class func appliesSizePreset(_ item: NativeListItem) -> Bool { true } + + 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/NativeListSectionHeaderRenderer.swift b/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift new file mode 100644 index 000000000..a93464cd7 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListSectionHeaderRenderer.swift @@ -0,0 +1,708 @@ +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")) + } + 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" { + 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) + 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) + // Legacy measured the value after selector typography (tabular digits, kern 0). + if item.data.string("presentation") == "networkSelector", item.hasExplicitHeight, + 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") ?? [:] + 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.hasExplicitHeight + 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" + 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 + var attributes: [NSAttributedString.Key: Any] = [ + .font: label.font as Any, + .foregroundColor: label.textColor as Any, + .paragraphStyle: paragraphStyle, + ] + if currentItem?.hasExplicitHeight == true + && currentItem?.data.string("presentation") == "networkSelector" + { + let baselineOffset = max(0, (lineHeight - label.font.lineHeight) / 2) + let scale = window?.screen.scale ?? traitCollection.displayScale + attributes[.baselineOffset] = + 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( + _ 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 isSelectorValue = + currentItem?.data.string("presentation") == "networkSelector" + && currentItem?.hasExplicitHeight == true && currentItem?.data.string("variant") != "summary" + let isSelectorSummary = + currentItem?.data.string("presentation") == "networkSelector" + && 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. + paragraphStyle.alignment = + isSelectorValue ? .right : isSelectorSummary ? .natural : .center + if isSelectorSummary { + button.contentHorizontalAlignment = .leading + button.titleLabel?.textAlignment = .natural + } + if isSelectorValue { + button.contentHorizontalAlignment = .trailing + button.titleLabel?.textAlignment = .right + } + let baselineOffset: CGFloat = + isSelectorValue || isSelectorSummary + ? max(0, (lineHeight - font.lineHeight) / 2) : 0 + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraphStyle, + .baselineOffset: baselineOffset, + ] + 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?.hasExplicitHeight == true + { + 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.hasExplicitHeight + 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/NativeListSystemRenderer.swift b/native-views/react-native-native-list/ios/NativeListSystemRenderer.swift new file mode 100644 index 000000000..ebab5d58a --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListSystemRenderer.swift @@ -0,0 +1,568 @@ +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() + 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) + // 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] { + 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) + // 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 + } + 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/NativeListTextLabel.swift b/native-views/react-native-native-list/ios/NativeListTextLabel.swift new file mode 100644 index 000000000..ee982e3de --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListTextLabel.swift @@ -0,0 +1,42 @@ +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 + ) + )) + } +} 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..9db68a267 --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListTextStyles.swift @@ -0,0 +1,236 @@ +import CoreText +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 + } + } + + /// 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 + { + 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/NativeListWalletGroupRenderer.swift b/native-views/react-native-native-list/ios/NativeListWalletGroupRenderer.swift new file mode 100644 index 000000000..15f9a2e6b --- /dev/null +++ b/native-views/react-native-native-list/ios/NativeListWalletGroupRenderer.swift @@ -0,0 +1,255 @@ +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 } + // 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") + } + 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")) + // 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( + _ item: NativeListItem, width: CGFloat, theme: [String: Any]?, layout: String + ) -> CGFloat? { + let members = memberItems(item) + return members.reduce( + CGFloat(max(0, members.count - 1) * 12) + padding(item, members: members).vertical * 2 + ) { $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 (horizontal, vertical) = Self.padding(item, members: members) + 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 { + // Legacy: only `draggable: false` excludes a member; disabled members may start a drag. + return (member.data["draggable"] as? Bool) != false + } + 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 06facdc01..286513007 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? @@ -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(.action) + private var footerRendererKey = NativeListRendererKey.action private let sectionIndexView = NativeListSectionIndexView() private let sectionIndexPreview = NativeListSectionIndexPreviewView() private var sectionIndexLayoutConstraints: [NSLayoutConstraint] = [] @@ -102,13 +103,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,7 +130,7 @@ final class NativeListView: UIView { override init(frame: CGRect) { super.init(frame: frame) - collectionView.register(NativeListCell.self, forCellWithReuseIdentifier: NativeListCell.reuseIdentifier) + NativeListRendererRegistry.register(in: collectionView) collectionView.backgroundColor = .clear collectionView.delegate = self collectionView.dragDelegate = self @@ -228,9 +229,9 @@ final class NativeListView: UIView { guard let self, let item = self.itemsByKey[key], let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: NativeListCell.reuseIdentifier, + 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) } @@ -329,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") @@ -352,7 +381,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) }) @@ -371,7 +403,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() @@ -444,7 +482,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 { @@ -685,7 +723,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 @@ -981,9 +1019,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, @@ -1017,7 +1055,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 @@ -1286,8 +1324,14 @@ 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 } + 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) } @@ -1311,13 +1355,27 @@ 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) } - 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( item: item, theme: config.theme, @@ -1360,7 +1418,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) } @@ -1442,7 +1500,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 @@ -1470,6 +1528,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 +1576,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, @@ -1670,149 +1730,29 @@ 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", 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 textWidth = max(1, collectionView.bounds.width - (config?.contentPaddingHorizontal ?? 0) * 2 - 24) - func textHeight(_ key: String, weight: NativeListFontWeight) -> CGFloat { - 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 - } - return 32 + 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 - // 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.double("height", default: data.dictionaries("badges").isEmpty ? 68 : 92)) - } + if item.type == "walletGroup", usingCompactReorderHeight, item.key == interactiveReorderCompactKey { return 68 } + if let height = item.styledHeight { return 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")) } - 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 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" - ? 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" - 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 "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 "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 - } ? 60 : 56 - 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 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 + (NativeListRendererRegistry.appliesSizePreset(item) ? (item.data.string("size") == "small" ? -8 : item.data.string("size") == "large" ? 12 : 0) : 0)) } - 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 - 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) - } - - 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 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 + return 56 } + private func emit(_ block: ((String) -> Void)?, _ value: [String: Any]) { guard JSONSerialization.isValidJSONObject(value), let data = try? JSONSerialization.data(withJSONObject: value), @@ -1894,7 +1834,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() @@ -1961,7 +1901,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 } @@ -2158,7 +2098,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) @@ -2166,34 +2107,20 @@ 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, 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 { - 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) { 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) } @@ -2206,7 +2133,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) } @@ -2365,11 +2292,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() 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..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 @@ -2,9 +2,12 @@ import type { IdentityRow, NativeListSnapshot, RowModel } from '../models'; import { WEB_LIST_CSS, WEB_REORDER_ANIMATION, + NativeListWebEngine, + applyRowStyle, canStartWebWalletGroupReorder, cancelWebReorderRows, computeWebListLayout, + createRowBody, estimateWebRowHeight, hasExceededWebReorderMouseThreshold, isWebRowReorderable, @@ -23,6 +26,7 @@ import { webRowRenderSignature, webWalletGroupReorderBadge, } from '../web/NativeListWebEngine'; +import { applyTextStyleToSlot } from '../web/templates/RowText'; jest.mock('../web/NativeListWebAvatarCache', () => ({ acquireNativeListAvatar: jest.fn(), @@ -188,6 +192,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[] = [ { @@ -561,3 +600,2957 @@ 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('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('54px'); + 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('scales Market subscript runs with explicit typography 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, + 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', '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', () => { + 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(28); // Time is beside the 38px title/body column: max(66,38)-max(22,38). + expect( + estimateWebRowHeight( + { ...multi, style: { ...multi.style, container: { height: 120 } } }, + config, + 400 + ) + ).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('var(--nl-secondary)'); + 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(); + 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 { + engine.destroy(); + } + }); + + 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('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('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'); + 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"]' + )!; + // 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' }, [ + { + ...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(title().style.fontSize).toBe('18px'); + engine.applySnapshot(snapshot({ kind: 'sectioned' }, [warning])); + expect(host.querySelector('[data-nl-renderer="system"]')).toBe(body); + 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(); + // 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"]') + ).toBeNull(); + expect(body.textContent).toBe('Retry connection'); + } finally { + engine.destroy(); + } + }); + + 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, + image: { width: 28, height: 30 }, + }, + }, + ]) + ); + 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'); + 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, + 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', + 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'); + document.body.appendChild(host); + const message = { + type: 'message', + key: 'persistent', + title: 'First', + body: 'Body', + time: 'Now', + height: 136, + leading: { + kind: 'token', + image: { ...image, retryTimes: 2 }, + networkImage: image, + }, + 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(3); + const corner = body.querySelector( + '.ok-native-list-visual-corner' + )!; + const cornerStyle = corner.style.cssText; + 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(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); + 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'); + 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('var(--nl-secondary)'); + 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', + 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', + 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: { + height: 141, + backgroundColor: '#123456', + borderWidth: 2, + borderColor: '#abcdef', + cornerRadius: 7, + contentVerticalAlignment: 'bottom', + }, + }, + } 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.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 ?? '' + ).toBe(row.type === 'message' ? 'var(--nl-primary)' : ''); + } + }); + + 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('2lh'); + 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', + 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'); + // 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', + variant: 'retry', + presentation: 'market', + message: 'Failed', + actionText: 'Try again', + actionKey: 'retry', + style: { actionText: { fontSize: 20 }, message: { fontSize: 15 } }, + }); + 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', () => { + 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('$10\n2 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', + 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.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: { horizontalPadding: 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. + 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', + 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'); + }); + + 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(); + } + } + ); +}); + +describe('DataRow renderer lifecycle', () => { + const dataSnapshot = ( + layout: NativeListSnapshot['layout'], + items: readonly RowModel[] + ): NativeListSnapshot => ({ + schemaVersion: 1, + generation: 1, + layout, + rows: items, + }); + 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(); + } + }); +}); + +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(); + } + }); +}); + +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(); + } + }); + 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('8px'); + 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(); + } + }); + 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); + 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 }] }) + ); + // Legacy Web: a disabled member keeps its own actions. + buttons()[0]!.click(); + 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); + // 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; + } + }); +}); + +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 + ); + // An explicit parent `height` keeps the legacy 2-unit border budget + // (native: a 1-unit inset); a container-only height does not. + const explicit = { + ...group, + parent: { ...group.parent, height: 60 }, + }; + expect(estimateWebRowHeight(explicit, snap([explicit]), 320)).toBe( + 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 { + 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' + )!; + // 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`); + } finally { + engine.destroy(); + } + }); + + 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', + 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 every retry as a 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 domWindow = window as unknown as { + Element: typeof Element; + HTMLElement: typeof HTMLElement; + KeyboardEvent: typeof KeyboardEvent; + }; + const previousElement = global.Element; + const previousHTMLElement = global.HTMLElement; + global.Element = domWindow.Element; + global.HTMLElement = domWindow.HTMLElement; + try { + const bodies = Array.from( + host.querySelectorAll('[data-nl-renderer="system"]') + ); + 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; + } + }); + + 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="[^"]*"/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/__tests__/validation.test.ts b/native-views/react-native-native-list/src/__tests__/validation.test.ts index 90d7b2297..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[]; @@ -1229,3 +1240,439 @@ 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.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' } })) + ).toThrow('token'); + expect(() => validateSnapshot(styled({ title: { fontSize: 72 } }))).toThrow( + 'fontSize' + ); + 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([ + { + 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('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('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([ + { + ...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); + }); +}); + +describe('row container and text layout contract', () => { + const style = { + container: { + height: 120.5, + 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'), + height: 80, + 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: {}, + height: 80, + backgroundColor: '#FFFFFF', + opacity: 0.4, + }); + }); + + 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' }], + ['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'); + }); +}); + +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 a095682ed..9873ef469 100644 --- a/native-views/react-native-native-list/src/models.ts +++ b/native-views/react-native-native-list/src/models.ts @@ -34,16 +34,42 @@ 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; 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; }>; -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 +77,224 @@ export type MarketImageStyle = Readonly<{ contentFit?: ImageContentFit; }>; -export type MarketRowStyle = 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; + 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; - /** 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; - /** 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; }>; +/** 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] | 'container' +>; + +/** + * A style key names the model field it modifies, never the view that carries + * 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<{ + title?: NativeListTextStyle; + subtitle?: NativeListTextStyle; + tertiary?: NativeListTextStyle; + badge?: NativeListTextStyle; + value?: NativeListTextStyle; + valueSecondary?: NativeListTextStyle; + }>; + +export type WalletGroupRowStyle = TemplateBoxStyle<'walletGroup'>; + +export type RailRowStyle = TemplateBoxStyle<'rail'> & + Readonly<{ + title?: NativeListTextStyle; + badge?: NativeListTextStyle; + status?: NativeListTextStyle; + }>; + +export type ActivityRowStyle = TemplateBoxStyle<'activity'> & + Readonly<{ + title?: NativeListTextStyle; + description?: NativeListTextStyle; + status?: NativeListTextStyle; + primaryAmount?: NativeListTextStyle; + secondaryAmount?: NativeListTextStyle; + }>; + +export type MessageRowStyle = TemplateBoxStyle<'message'> & + Readonly<{ + title?: NativeListTextStyle; + body?: NativeListTextStyle; + time?: NativeListTextStyle; + }>; + +export type DataRowStyle = TemplateBoxStyle<'dataRow'> & + Readonly<{ + columns?: NativeListTextStyle; + columnSecondary?: NativeListTextStyle; + index?: NativeListTextStyle; + }>; + +export type MarketRowStyle = TemplateBoxStyle<'market'> & + 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 = TemplateBoxStyle<'mediaTile'> & + Readonly<{ + title?: NativeListTextStyle; + subtitle?: NativeListTextStyle; + badge?: NativeListTextStyle; + }>; + +export type MetricCardRowStyle = TemplateBoxStyle<'metricCard'> & + Readonly<{ + /** Standard-card label or composite-card heading. */ + title?: NativeListTextStyle; + /** Large number in the standard template; absent in composite variants. */ + value?: NativeListTextStyle; + subtitle?: NativeListTextStyle; + trend?: NativeListTextStyle; + }>; + +export type SectionHeaderRowStyle = TemplateBoxStyle<'sectionHeader'> & + Readonly<{ + title?: NativeListTextStyle; + subtitle?: NativeListTextStyle; + value?: NativeListTextStyle; + }>; + +export type ActionRowStyle = TemplateBoxStyle<'action'> & + Readonly<{ + title?: NativeListTextStyle; + value?: NativeListTextStyle; + }>; + +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; + }>; + export type MarketBadgeModel = Readonly<{ key: string; text?: string; @@ -273,6 +494,7 @@ export type IdentityRow = RowBase & badges?: readonly BadgeModel[]; trailing?: readonly TrailingAccessory[]; draggable?: boolean; + style?: IdentityRowStyle; }>; export type WalletGroupRow = RowBase & @@ -281,6 +503,7 @@ export type WalletGroupRow = RowBase & parent: IdentityRow; children: readonly IdentityRow[]; draggable?: boolean; + style?: WalletGroupRowStyle; }>; export type RailRow = RowBase & @@ -291,6 +514,7 @@ export type RailRow = RowBase & status?: 'none' | 'online' | 'warning' | 'error'; badge?: BadgeModel; draggable?: boolean; + style?: RailRowStyle; }>; export type ActivityRow = RowBase & @@ -304,6 +528,7 @@ export type ActivityRow = RowBase & primaryAmount?: string; secondaryAmount?: string; footerActions?: readonly FooterAction[]; + style?: ActivityRowStyle; }>; export type MessageRow = RowBase & @@ -316,6 +541,7 @@ export type MessageRow = RowBase & bodyLines?: 1 | 2 | 3; time: string; thumbnail?: ImageSource; + style?: MessageRowStyle; }>; export type DataColumn = Readonly<{ @@ -340,6 +566,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 +608,7 @@ export type MediaTileRow = RowBase & subtitle?: string; badge?: BadgeModel; closeActionKey?: string; + style?: MediaTileRowStyle; }>; /** Compact KPI card for dashboards and grids. */ @@ -405,6 +633,7 @@ export type MetricCardRow = RowBase & badge?: BadgeModel; metrics?: readonly MetricValue[]; progress?: number; + style?: MetricCardRowStyle; }>; export type SectionHeaderRow = RowBase & @@ -427,6 +656,7 @@ export type SectionHeaderRow = RowBase & titleIcon?: Extract; valueIcon?: Extract; checkbox?: Extract; + style?: SectionHeaderRowStyle; }>; export type ActionRow = RowBase & @@ -439,9 +669,11 @@ export type ActionRow = RowBase & icon?: Extract; checkbox?: Extract; trailing?: readonly TrailingAccessory[]; + style?: ActionRowStyle; }>; export type SystemRow = RowBase & + Readonly<{ style?: SystemRowStyle }> & ( | Readonly<{ type: 'system'; @@ -529,6 +761,34 @@ 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. Must be + * `#RRGGBB` or `#RRGGBBAA`, like every row style color. + */ + 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, `0…40` logical units. Does not affect + * rail or media tiles. + */ + groupCornerRadius?: number; +}>; + export type NativeListSnapshot = Readonly<{ schemaVersion: 1; generation: number; @@ -560,6 +820,7 @@ export type NativeListSnapshot = Readonly<{ emptyState?: ActionRow | SystemRow; fixedFooter?: ActionRow | SystemRow; theme?: NativeListTheme; + listStyle?: NativeListListStyle; }>; // OneKey patch: include selector-only mutable presentation fields. @@ -575,7 +836,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..f928c3add 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, @@ -5,8 +6,11 @@ import type { LeadingVisual, MarketRow, MarketRowStyle, - MarketTextStyle, + NativeListListStyle, NativeListSnapshot, + NativeListTextStyle, + NativeListTypographyToken, + RowBoxStyle, RowModel, RowPatch, WalletGroupRow, @@ -127,11 +131,119 @@ function assertBoundedStyleNumber( } } -function assertMarketTextStyle( - style: MarketTextStyle | undefined, - path: string +/** + * 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' }, +}; + +/** 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, + 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', + 'truncate', + 'alignment', + 'verticalAlignment', + 'offsetY', + ...additionalKeys, + ], + path, + 'text style key' + ); + 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 && + !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 ( @@ -146,44 +258,117 @@ function assertMarketTextStyle( ) { 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 assertMarketStyle( - style: MarketRowStyle | undefined, - path: string -): void { - if (!style) return; +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, + [ + 'height', + '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.height, + `${containerPath}.height`, + 0, + 4096 + ); + 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', '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) { + 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, @@ -208,10 +393,205 @@ 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([ + 'container', + ...ROW_BOX_STYLE_KEYS_BY_TYPE[row.type], + ...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}`) + ); +} + +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); + // 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. */ +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 +615,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 +663,17 @@ 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`, [ + '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`, @@ -571,18 +958,31 @@ 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)) 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( @@ -597,6 +997,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) { @@ -685,8 +1086,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; @@ -964,6 +1365,8 @@ export function validateSnapshot( } } + assertListStyle(snapshot.listStyle, 'snapshot.listStyle'); + const rowKeys = new Set(); const sectionIndexTitles = new Set(); snapshot.rows.forEach((row, index) => { @@ -1016,11 +1419,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`); @@ -1095,9 +1531,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; @@ -1236,7 +1672,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 dd0a0940e..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,10 +1,26 @@ +import { + applyRowContainerStyle, + resetRowContainerStyle, +} from './templates/RowContainerStyle'; +import { hasExplicitRowHeight, rowSizeModifier } from './templates/RowElements'; +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, + rowRendererKey, + recycleRowBody, + type RowRendererKey, +} from './templates/RowRendererRegistry'; import type { ActionAnchorInvalidatedEvent, CheckboxState, ImageSource, LeadingVisual, - MarketRow, - MarketTextStyle, NativeListSnapshot, NativeListActionAnchor, NativeListActionSource, @@ -189,49 +205,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; @@ -460,28 +434,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; -} - -function approximateMessageHeight(row: RowModel, availableWidth: number) { - if (row.type !== 'message') return 0; - const leadingWidth = row.leading ? 52 : 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 bodyLines = Math.min( - row.bodyLines ?? 3, - Math.max(1, Math.ceil(row.body.length / charactersPerLine)) - ); - return 32 + titleLines * 20 + bodyLines * 20 + 22; + return rowSizeModifier(row.size); } export function estimateWebRowHeight( @@ -489,145 +442,21 @@ 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; - 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 - ) - ); - return 32 + 20 * (lines(row.title) + lines(row.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; - return ( - [row.parent, ...row.children].reduce( - (height, member) => - height + estimateWebRowHeight(member, snapshot, availableWidth), - 0 - ) + - 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; - - let base: number; - switch (row.type) { - case 'rail': - base = 40; - break; - case 'activity': - base = row.footerActions?.length ? 100 : 60; - break; - case 'message': - base = approximateMessageHeight(row, availableWidth); - break; - case 'mediaTile': - base = 244; - break; - case 'metricCard': - base = - row.variant === 'activity' - ? 161 - : row.variant === 'performance' - ? 178 - : 132; - break; - 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 '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 'action': - base = row.presentation === 'accountSelector' ? 48 : row.icon ? 60 : 44; - break; - case 'dataRow': - base = row.columns.some((column) => column.secondaryText) ? 60 : 56; - 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); + // 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); + return Math.max( + 0, + registered.measure(availableWidth, snapshot.layout.kind) + + (registered.appliesSizePreset ? sizeModifier(row) : 0) + ); } 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 { @@ -680,9 +509,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; }); @@ -760,9 +594,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, @@ -806,6 +642,8 @@ export function canStartWebWalletGroupReorder( const member = [row.parent, ...row.children].find( (candidate) => candidate.key === memberKey ); + // Legacy: only `draggable: false` excludes a member; disabled members may + // still start the atomic group drag. return member?.draggable !== false; } @@ -951,15 +789,15 @@ 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%} -.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)} @@ -984,10 +822,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)} @@ -1007,18 +848,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} @@ -1044,6 +880,11 @@ 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 [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)} `; function createElement( @@ -1604,17 +1445,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, @@ -1828,20 +1658,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 = createElement( - context.document, - 'span', - 'ok-native-list-badge', - badge.text - ); - setData(element, 'tone', badge.tone); - return element; -} - function selectionKeysForTarget( target: SelectionTarget | undefined, rowKey: string, @@ -1909,1625 +1725,207 @@ function createCheckbox( context.document, 'button', 'ok-native-list-checkbox' - ); - element.setAttribute('type', 'button'); - element.setAttribute('role', 'checkbox'); - element.setAttribute( - 'aria-checked', - state === 'indeterminate' ? 'mixed' : String(state === 'checked') - ); - element.setAttribute('aria-label', 'Select'); - element.toggleAttribute('disabled', Boolean(accessory.disabled)); - setData(element, 'state', state); - setData(element, 'checkboxFallback', accessory.state); - setData(element, 'nativeListAction', accessory.actionKey ?? 'selection'); - setData(element, 'selectionScope', accessory.target?.scope ?? 'row'); - const row = context.snapshot.rows[context.itemIndex]; - if (row && 'presentation' in row && row.presentation === 'networkSelector') { - setData(element, 'selector', 'networkSelector'); - for (const [state, name] of [ - ['checked', 'CheckboxCheckedCustom'], - ['indeterminate', 'CheckboxIndeterminateCustom'], - ]) { - const holder = createElement(context.document, 'span'); - applySelectorIcon(holder, name); - const svg = holder.firstElementChild; - if (svg) { - svg.setAttribute('data-state', state); - element.appendChild(svg); - } - } - } - if (accessory.target?.scope === 'section') - setData(element, 'selectionKey', accessory.target.sectionKey); - else if (accessory.target?.scope === 'row') - setData(element, 'selectionKey', rowKey); - return element; -} - -function createIconAction( - context: RenderContext, - name: string, - actionKey: string | undefined, - disabled?: boolean, - tintColor?: string -): HTMLElement { - const element = createElement( - context.document, - actionKey ? 'button' : 'span', - 'ok-native-list-icon-button', - iconGlyph(name) - ); - if (element.tagName === 'BUTTON') { - element.setAttribute('type', 'button'); - element.toggleAttribute('disabled', Boolean(disabled)); - } - applySelectorIcon(element, name); - if (actionKey) setData(element, 'nativeListAction', actionKey); - if (tintColor) element.style.color = tintColor; - return element; -} - -function markActionAnchorSource( - element: HTMLElement, - source: NativeListActionSource, - slot?: number -) { - setData(element, 'nativeListAnchorSource', source); - if (slot !== undefined) setData(element, 'nativeListAnchorSlot', slot); -} - -// 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, - rowKey: string, - accessory: TrailingAccessory, - slot: number -): HTMLElement { - if (accessory.kind === 'checkbox') { - const element = createCheckbox(context, rowKey, accessory); - markActionAnchorSource(element, 'trailingAccessory', slot); - return element; - } - if (accessory.kind === 'icon') { - const element = createIconAction( - context, - accessory.name, - accessory.actionKey, - accessory.disabled, - accessory.tintColor - ); - markActionAnchorSource(element, 'trailingAccessory', slot); - setData(element, 'testid', accessory.testID); - if (accessory.hoverActionKey) - setData(element, 'nativeListHoverAction', accessory.hoverActionKey); - if (accessory.accessibilityLabel) - element.setAttribute('aria-label', accessory.accessibilityLabel); - const row = context.snapshot.rows[context.itemIndex]; - if ( - row && - 'presentation' in row && - row.presentation === 'accountSelector' - ) { - setData(element, 'nativeListAnchorInset', 7); - // OneKey patch: the create-address button omits IconButton's one-point border. - if (row.height !== undefined && accessory.name === 'PlusSmallOutline') { - setData(element, 'nativeListAccountControl', 'createAddress'); - if (!accessory.tintColor) - element.style.color = 'var(--nl-icon-subdued)'; - } - } - return element; - } - if (accessory.kind === 'spinner') { - return createElement(context.document, 'span', 'ok-native-list-spinner'); - } - const actionKey = 'actionKey' in accessory ? accessory.actionKey : undefined; - const element = createElement( - context.document, - actionKey ? 'button' : 'span', - 'ok-native-list-accessory' - ); - if (element.tagName === 'BUTTON') { - element.setAttribute('type', 'button'); - element.classList.add('ok-native-list-icon-button'); - setData(element, 'nativeListAction', actionKey); - } - switch (accessory.kind) { - case 'value': - element.textContent = accessory.text; - applyValueSegments(element, accessory.textSegments); - if (accessory.secondary) - element.classList.add('ok-native-list-accessory-secondary'); - break; - case 'valuePair': { - const primary = createElement( - context.document, - 'span', - undefined, - accessory.primary - ); - primary.style.color = toneColor(accessory.primaryTone, 'primary'); - const secondary = createElement( - context.document, - 'span', - 'ok-native-list-secondary', - accessory.secondary - ); - secondary.style.color = toneColor(accessory.secondaryTone, 'secondary'); - element.replaceChildren(primary, secondary); - element.classList.add('ok-native-list-amounts'); - break; - } - case 'radio': - element.textContent = accessory.checked ? '●' : '○'; - break; - case 'switch': - element.textContent = accessory.value ? 'ON' : 'OFF'; - setData(element, 'nativeListAction', accessory.actionKey); - break; - case 'chevron': - element.textContent = '›'; - break; - case 'menu': - element.textContent = '⋮'; - break; - case 'drag': - element.textContent = '≡'; - break; - case 'progress': - element.textContent = String(Math.round(accessory.value * 100)) + '%'; - break; - } - if (actionKey) markActionAnchorSource(element, 'trailingAccessory', slot); - 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'); - } - accessories.forEach((accessory, slot) => - container.appendChild(createAccessory(context, rowKey, accessory, slot)) - ); - parent.appendChild(container); -} - -function createTextColumn( - context: RenderContext, - title: string, - subtitle?: string, - tertiary?: string, - tertiaryTone?: 'secondary' | 'info', - badges?: readonly Readonly<{ text: string; tone?: string }>[] -): HTMLElement { - const column = createElement(context.document, 'span', 'ok-native-list-flex'); - const titleLine = createElement( - context.document, - 'span', - 'ok-native-list-title', - title - ); - if (badges?.length) { - 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( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - 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 - ); - 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 = 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 - ); - 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 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 = createElement( - context.document, - 'span', - 'ok-native-list-action-title', - row.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 -): 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 = createElement(context.document, 'span', '', row.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( - createElement( - context.document, - 'span', - 'ok-native-list-warning-title', - row.title - ) - ); - body.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-warning-message', - row.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( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - message - ) - ); - 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( - createElement( - context.document, - 'span', - 'ok-native-list-rail-title', - row.title - ) - ); - if (row.status && row.status !== 'none') - body.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - row.status - ) - ); - if (row.badge) body.appendChild(createBadge(context, row.badge)); - 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( - createElement( - context.document, - 'span', - 'ok-native-list-media-subtitle', - row.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( - createElement( - context.document, - 'div', - 'ok-native-list-media-title', - row.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< - 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( - createElement( - context.document, - 'div', - 'ok-native-list-composite-heading', - row.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( - createElement( - context.document, - 'div', - 'ok-native-list-secondary', - row.title - ) - ); - body.appendChild( - createElement( - context.document, - 'div', - 'ok-native-list-metric-value', - row.value - ) - ); - if (row.trend) { - const trend = createElement( - context.document, - 'div', - 'ok-native-list-secondary', - row.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( - createElement( - context.document, - 'div', - 'ok-native-list-secondary', - row.subtitle - ) - ); - if (row.badge) body.appendChild(createBadge(context, row.badge)); - 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( - createElement( - context.document, - 'span', - 'ok-native-list-index', - String(row.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'); - if (column.secondaryLeadingText) - primary.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - column.secondaryLeadingText - ) - ); - primary.appendChild( - createElement(context.document, 'span', undefined, column.text) - ); - if (column.key === 'asset' && row.badges?.length) { - row.badges.forEach((badge) => - primary.appendChild(createBadge(context, badge)) - ); - } - cell.appendChild(primary); - if (column.secondaryText) { - const secondary = createElement( - context.document, - 'span', - 'ok-native-list-secondary', - column.secondaryText - ); - secondary.style.color = toneColor(column.secondaryTone, 'secondary'); - cell.appendChild(secondary); - } - body.appendChild(cell); - }); - return body; -} - -function createIdentityActivityOrMessageRow( - context: RenderContext, - row: Extract -): HTMLElement { - const presentation = row.type === 'identity' ? row.presentation : undefined; - const body = createElement( - context.document, - 'div', - [ - 'ok-native-list-row', - 'ok-native-list-standard', - row.type === 'identity' && !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.type === 'identity' && row.titleActionKey && row.titleActionOnHover) { - setData(body, 'nativeListHoverAction', row.titleActionKey); - markActionAnchorSource(body, 'leadingAction'); - } - if (row.type === 'identity' && 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 && - row.type === 'identity' && - 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 && - row.type === 'identity' && - row.height !== undefined && - row.presentation === 'walletSidebar' && - 'borderStyle' in row.leading && - row.leading.borderStyle === 'dashed' - ) - 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); - } - 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 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 - ); - // 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 firstText = titleElement.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)) - ); - titleElement.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 - ); - 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); - } - } - if (row.type === 'activity' && row.status) - column.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - row.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( - createElement( - context.document, - 'span', - 'ok-native-list-value', - row.primaryAmount - ) - ); - if (row.secondaryAmount) - amounts.appendChild( - createElement( - context.document, - 'span', - 'ok-native-list-secondary', - row.secondaryAmount - ) - ); - body.appendChild(amounts); - } else if (row.type === 'message') { - body.appendChild( - createElement(context.document, 'span', 'ok-native-list-time', row.time) - ); - if (row.thumbnail) { - const thumbnail = createImage( - context, - row.thumbnail, - 'ok-native-list-thumbnail' - ); - if (thumbnail) body.appendChild(thumbnail); + ); + element.setAttribute('type', 'button'); + element.setAttribute('role', 'checkbox'); + element.setAttribute( + 'aria-checked', + state === 'indeterminate' ? 'mixed' : String(state === 'checked') + ); + element.setAttribute('aria-label', 'Select'); + element.toggleAttribute('disabled', Boolean(accessory.disabled)); + setData(element, 'state', state); + setData(element, 'checkboxFallback', accessory.state); + setData(element, 'nativeListAction', accessory.actionKey ?? 'selection'); + setData(element, 'selectionScope', accessory.target?.scope ?? 'row'); + const row = context.snapshot.rows[context.itemIndex]; + if (row && 'presentation' in row && row.presentation === 'networkSelector') { + setData(element, 'selector', 'networkSelector'); + for (const [state, name] of [ + ['checked', 'CheckboxCheckedCustom'], + ['indeterminate', 'CheckboxIndeterminateCustom'], + ]) { + const holder = createElement(context.document, 'span'); + applySelectorIcon(holder, name); + const svg = holder.firstElementChild; + if (svg) { + svg.setAttribute('data-state', state); + element.appendChild(svg); + } } - } else { - 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) { - 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'; - }); + if (accessory.target?.scope === 'section') + setData(element, 'selectionKey', accessory.target.sectionKey); + else if (accessory.target?.scope === 'row') + setData(element, 'selectionKey', rowKey); + return element; } -function createWalletGroupRow( +function createIconAction( context: RenderContext, - row: Extract + name: string, + actionKey: string | undefined, + disabled?: boolean, + tintColor?: string ): HTMLElement { - const body = createElement( + const element = createElement( context.document, - 'div', - 'ok-native-list-wallet-group' + actionKey ? 'button' : 'span', + 'ok-native-list-icon-button', + iconGlyph(name) ); - [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(createIdentityActivityOrMessageRow(context, member)); - const memberBody = createIdentityActivityOrMessageRow(context, member); - applySelectorTabularNumbers(memberBody, member); - 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'; - memberElement.style.height = memberElement.style.flexBasis; - memberElement.style.opacity = String(member.opacity ?? 1); - body.appendChild(memberElement); - }); - 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; + if (element.tagName === 'BUTTON') { + element.setAttribute('type', 'button'); + element.toggleAttribute('disabled', Boolean(disabled)); + } + applySelectorIcon(element, name); + if (actionKey) setData(element, 'nativeListAction', actionKey); + if (tintColor) element.style.color = tintColor; + return element; } -function applyMarketTextStyle( +function markActionAnchorSource( element: HTMLElement, - style: MarketTextStyle | undefined, - defaults: Readonly<{ - fontSize: number; - lineHeight: number; - weight: number; - alignment: 'start' | 'center' | 'end'; - }> + source: NativeListActionSource, + slot?: number ) { - 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 === 2 ? 'normal' : 'nowrap'; - element.style.display = ''; - element.style.removeProperty('-webkit-line-clamp'); - element.style.removeProperty('-webkit-box-orient'); - if (style?.lines === 2) { - element.style.display = '-webkit-box'; - element.style.setProperty('-webkit-line-clamp', '2'); - element.style.setProperty('-webkit-box-orient', 'vertical'); - } + setData(element, 'nativeListAnchorSource', source); + if (slot !== undefined) setData(element, 'nativeListAnchorSlot', slot); } -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 = row.change.textColor ?? style?.change?.color ?? ''; - change.style.background = row.change.backgroundColor ?? ''; - } - element.setAttribute( - 'aria-label', - row.accessibilityLabel ?? - [row.title, row.subtitle, row.price, row.change.text] - .filter(Boolean) - .join(', ') - ); -} +// OneKey patch: the compact zero-count digits share the amount baseline. -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( +function createAccessory( + context: RenderContext, + rowKey: string, + accessory: TrailingAccessory, + slot: number +): HTMLElement { + if (accessory.kind === 'checkbox') { + const element = createCheckbox(context, rowKey, accessory); + markActionAnchorSource(element, 'trailingAccessory', slot); + return element; + } + if (accessory.kind === 'icon') { + const element = createIconAction( context, - row.leadingAction.name, - row.leadingAction.actionKey, - row.leadingAction.disabled, - row.leadingAction.tintColor + accessory.name, + accessory.actionKey, + accessory.disabled, + accessory.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 ?? image.style.objectFit; - } else { - image.style.width = '20px'; - image.style.height = '20px'; - if (borderColor) { - image.style.right = '-5px'; - image.style.bottom = '-5px'; - } + markActionAnchorSource(element, 'trailingAccessory', slot); + setData(element, 'testid', accessory.testID); + if (accessory.hoverActionKey) + setData(element, 'nativeListHoverAction', accessory.hoverActionKey); + if (accessory.accessibilityLabel) + element.setAttribute('aria-label', accessory.accessibilityLabel); + const row = context.snapshot.rows[context.itemIndex]; + if ( + row && + 'presentation' in row && + row.presentation === 'accountSelector' + ) { + setData(element, 'nativeListAnchorInset', 7); + // OneKey patch: the create-address button omits IconButton's one-point border. + if (hasExplicitRowHeight(row) && accessory.name === 'PlusSmallOutline') { + setData(element, 'nativeListAccountControl', 'createAddress'); + if (!accessory.tintColor) + element.style.color = 'var(--nl-icon-subdued)'; } - }); - visual.style.marginRight = String(layoutStyle.leadingGap) + 'px'; - body.appendChild(visual); + } + return element; } - 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( + if (accessory.kind === 'spinner') { + return createElement(context.document, 'span', 'ok-native-list-spinner'); + } + const actionKey = 'actionKey' in accessory ? accessory.actionKey : undefined; + const element = createElement( context.document, - 'span', - 'ok-native-list-market-title', - row.title + actionKey ? 'button' : 'span', + 'ok-native-list-accessory' ); - 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.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( + if (element.tagName === 'BUTTON') { + element.setAttribute('type', 'button'); + element.classList.add('ok-native-list-icon-button'); + setData(element, 'nativeListAction', actionKey); + } + switch (accessory.kind) { + case 'value': + element.textContent = accessory.text; + applyValueSegments(element, accessory.textSegments); + if (accessory.secondary) + element.classList.add('ok-native-list-accessory-secondary'); + break; + case 'valuePair': { + const primary = createElement( context.document, 'span', - 'ok-native-list-market-subtitle-prefix', - row.subtitlePrefix.text + undefined, + accessory.primary ); - 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( + primary.style.color = toneColor(accessory.primaryTone, 'primary'); + const secondary = 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) + 'ok-native-list-secondary', + accessory.secondary ); - subtitle.style.flex = row.subtitlePrefix ? '0 0 auto' : '1 1 auto'; - subtitleLine.appendChild(subtitle); + secondary.style.color = toneColor(accessory.secondaryTone, 'secondary'); + element.replaceChildren(primary, '\n', secondary); + element.classList.add('ok-native-list-amounts'); + break; } - main.appendChild(subtitleLine); + case 'radio': + element.textContent = accessory.checked ? '●' : '○'; + break; + case 'switch': + element.textContent = accessory.value ? 'ON' : 'OFF'; + setData(element, 'nativeListAction', accessory.actionKey); + break; + case 'chevron': + element.textContent = '›'; + break; + case 'menu': + element.textContent = '⋮'; + break; + case 'drag': + element.textContent = '≡'; + break; + case 'progress': + element.textContent = String(Math.round(accessory.value * 100)) + '%'; + break; } - 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); - return body; + if (actionKey) markActionAnchorSource(element, 'trailingAccessory', slot); + return element; } -function createRowBody(context: RenderContext, row: RowModel): HTMLElement { - switch (row.type) { - case 'walletGroup': - return createWalletGroupRow(context, row); - case 'sectionHeader': - return createSectionHeader(context, row); - case 'action': - return createActionRow(context, row); - case 'system': - return createSystemRow(context, row); - case 'rail': - return createRailRow(context, row); - case 'mediaTile': - return createMediaRow(context, row); - case 'metricCard': - return createMetricRow(context, row); - case 'dataRow': - return createDataRow(context, row); - case 'market': - return createMarketRow(context, row); - case 'identity': - case 'activity': - case 'message': - return createIdentityActivityOrMessageRow(context, row); - } +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, 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, + }; +} + +export function createRowBody( + context: RenderContext, + row: RowModel +): HTMLElement { + return rowRenderer(row).create(context.document, rendererPrimitives(context)); } export class NativeListWebEngine { @@ -3554,7 +1952,21 @@ export class NativeListWebEngine { horizontal: false, }; private readonly mounted = new Map(); - private readonly pool: HTMLElement[] = []; + private readonly pool: Record = { + walletGroup: [], + message: [], + rail: [], + mediaTile: [], + action: [], + system: [], + activity: [], + dataRow: [], + metricCard: [], + market: [], + identity: [], + sectionHeader: [], + }; + private readonly rendererKeys = new WeakMap(); private frameHandle: number | undefined; private resizeObserver: ResizeObserver | undefined; private sectionIndexTransformObserver: MutationObserver | undefined; @@ -3607,8 +2019,11 @@ 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 } + >(); constructor( host: HTMLElement, @@ -3996,14 +2411,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(); } @@ -4013,7 +2432,7 @@ export class NativeListWebEngine { selectedKeys?: ReadonlySet ) { this.cancelMarketPointer(); - this.measuredWarningHeights.clear(); + this.measuredRendererHeights.clear(); this.snapshot = snapshot; this.rows = effectiveRows(snapshot); this.selectedKeys = @@ -4060,6 +2479,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 = () => { @@ -4086,7 +2530,7 @@ export class NativeListWebEngine { viewportHeight !== this.lastViewportHeight) ) { this.invalidateActionAnchor('layout'); - this.measuredWarningHeights.clear(); + this.measuredRendererHeights.clear(); } this.lastViewportWidth = viewportWidth; this.lastViewportHeight = viewportHeight; @@ -4094,11 +2538,14 @@ export class NativeListWebEngine { const measuredSnapshot: NativeListSnapshot = { ...this.snapshot, 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) } + this.measuredRendererHeights.get(row.key)?.signature === + webRowRenderSignature(row) + ? { + ...row, + height: this.measuredRendererHeights.get(row.key)!.height, + } : row ), }; @@ -4190,13 +2637,22 @@ 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)) + const recycled = element.firstElementChild + ? recycleRowBody(element.firstElementChild as HTMLElement) + : false; + if (disposeWebImageRetries(element) || recycled) element.removeAttribute('data-render-signature'); element.remove(); - this.pool.push(element); + if (rendererKey) this.pool[rendererKey].push(element); } }); @@ -4205,9 +2661,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); @@ -4222,24 +2680,30 @@ export class NativeListWebEngine { element.dataset.renderSignature = signature; } }); - let measuredWarningChanged = false; + let measuredHeightChanged = false; this.mounted.forEach((element, index) => { const row = this.rows[index]; - if ( - row?.type !== 'system' || - row.variant !== 'warning' || - row.height !== undefined - ) + const registered = row && rowRenderer(row); + if (registered && element.firstElementChild) { + const height = registered.measureRendered( + element.firstElementChild as HTMLElement + ); + 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, + }); + measuredHeightChanged = true; + } 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; } @@ -4261,7 +2725,13 @@ export class NativeListWebEngine { overlay = false ) { this.invalidateActionAnchorForElement(element); - disposeWebImageRetries(element); + const registered = rowRenderer(row); + const existingBody = element.firstElementChild as HTMLElement | null; + const reusableBody = + existingBody?.dataset.nlRenderer === registered.key + ? existingBody + : undefined; + if (!reusableBody) disposeWebImageRetries(element); const bindingEpoch = String(++this.bindingEpochCounter); element.className = overlay ? 'ok-native-list-item ok-native-list-sticky' @@ -4273,7 +2743,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, @@ -4306,84 +2779,32 @@ export class NativeListWebEngine { selectedKeys: this.selectedKeys, itemIndex: index, }; - const body = createRowBody(context, row); - applySelectorTabularNumbers(body, row); + const body = reusableBody ?? createRowBody(context, row); + 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; - 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; - } - 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'; - }); - } + restingBackground; } - element.replaceChildren(body); + // Template and presentation defaults must precede caller overrides. + applyRowStyle(body, row); + if (body.parentElement !== element) element.replaceChildren(body); if ( row.type === 'market' && row.diagnostics?.imageBindActionKey && @@ -5254,6 +3675,8 @@ export class NativeListWebEngine { action.dataset.nativeListHoverAction === 'true' ? action.dataset.nativeListAction : action.dataset.nativeListHoverAction; + // 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); }; @@ -5298,6 +3721,9 @@ export class NativeListWebEngine { (member) => member.key === memberKey ) ?? row : row; + // 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; @@ -5781,11 +4207,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, @@ -5809,7 +4235,6 @@ export class NativeListWebEngine { } configureWebAvatar(image, avatar.source, avatar.uri); }); - const sourceRow = state.workingRows[state.currentIndex]; const badgeText = sourceRow ? webWalletGroupReorderBadge(sourceRow) : undefined; @@ -6217,3 +4642,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/ActionRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/ActionRowRenderer.ts new file mode 100644 index 000000000..72ca4a582 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/ActionRowRenderer.ts @@ -0,0 +1,123 @@ +import { applyTabularNumbers, hasExplicitRowHeight } from './RowElements'; +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, + style?.image?.width ?? size, + style?.image?.height ?? 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 ( + hasExplicitRowHeight(row) && + 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'; + if (row.presentation === 'accountSelector') applyTabularNumbers(body); +} +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/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/DataRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/DataRowRenderer.ts new file mode 100644 index 000000000..4831d9a07 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/DataRowRenderer.ts @@ -0,0 +1,212 @@ +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); + }); + // 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) label.remove(); + else if (slot === 0) cell.primaryLine.insertBefore(label, cell.primary); + else cell.secondaryLine.appendChild(label); + label.style.cssText = ''; + label.textContent = text ?? ''; + if (slot === 1) + 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' + ); + 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, + // 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 new file mode 100644 index 000000000..197fb63ec --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/IdentityRowRenderer.ts @@ -0,0 +1,527 @@ +import { applyTabularNumbers, hasExplicitRowHeight } from './RowElements'; +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 && + hasExplicitRowHeight(row) && + 'presentation' in row && + row.presentation === 'networkSelector' + ) { + container.style.gap = accessories.some( + (accessory) => accessory.kind === 'checkbox' + ) + ? '12px' + : '20px'; + } + if ( + row && + hasExplicitRowHeight(row) && + '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', + hasExplicitRowHeight(row) ? 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, + hasExplicitRowHeight(row) ? presentation : undefined + ); + if ( + visual && + true && + hasExplicitRowHeight(row) && + 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 && + hasExplicitRowHeight(row) && + 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; + 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); + for (const name of next.getAttributeNames()) + if (name.startsWith('data-')) + body.setAttribute(name, next.getAttribute(name)!); + if (row.type === 'identity' && hasExplicitRowHeight(row)) { + 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/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/MessageRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/MessageRowRenderer.ts new file mode 100644 index 000000000..95058b6e3 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/MessageRowRenderer.ts @@ -0,0 +1,306 @@ +import { RowVisualStyle } from './RowVisual'; +import type { RowPrimitives } from './RowVisual'; +import type { MessageRow, NativeListTextStyle } from '../../models'; + +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. + visualStyle?: RowVisualStyle; + row?: MessageRow; + primitives: RowPrimitives; +}; +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: RowPrimitives +): 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.visualStyle = undefined; + if (views.leading) { + body.insertBefore(views.leading, body.firstChild); + views.visualStyle = new RowVisualStyle(views.leading); + } + } + 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) { + 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: RowPrimitives +): HTMLElement { + 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'; + body.dataset.nlRenderer = 'message'; + const column = span('ok-native-list-flex'); + 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: '', + 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(); + } + if (views.thumbnail) { + views.primitives.dispose(views.thumbnail); + views.thumbnail.remove(); + } + views.leading = undefined; + views.thumbnail = undefined; + views.row = undefined; + views.leadingIdentity = ''; + views.thumbnailIdentity = ''; + views.visualStyle = undefined; +} + +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( + 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(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 ( + 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/MetricCardRowRenderer.ts b/native-views/react-native-native-list/src/web/templates/MetricCardRowRenderer.ts new file mode 100644 index 000000000..752e8b86c --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/MetricCardRowRenderer.ts @@ -0,0 +1,337 @@ +import type { + MetricCardRow, + NativeListTextStyle, + TextTone, +} from '../../models'; +import { + captureInlineStyles, + createElement, + restoreInlineStyles, + 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; + // 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); + 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; + // 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; + } + restoreInlineStyles(v.defaults); + 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/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/RowContainerStyle.ts b/native-views/react-native-native-list/src/web/templates/RowContainerStyle.ts new file mode 100644 index 000000000..2aaedf4c5 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/RowContainerStyle.ts @@ -0,0 +1,131 @@ +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 undoes its own writes on rebind. +const writesByBody = new WeakMap(); + +function restoreContainerWrites(body: HTMLElement, force: boolean) { + 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; + // 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, + 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, false); + 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 + ) + set(body, 'height', '100%'); + const container = row.style?.container; + if (!container) return; + if (container.backgroundColor !== undefined) { + 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) + 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'); + set(body, '--nl-container-border-width', `${container.borderWidth}px`); + set( + body, + '--nl-container-border-color', + container.borderColor ?? 'transparent' + ); + set(body, 'border-color', 'transparent'); + } else if (container.borderColor !== undefined) + set(body, 'border-color', 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) { + 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) => { + set(child as HTMLElement, 'flex-shrink', '0'); + }); + } else { + set(body, 'align-items', alignment); + // Timestamp/accessory defaults may have their own align-self. + Array.from(body.children).forEach((child) => { + 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 new file mode 100644 index 000000000..5e3dd3445 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/RowElements.ts @@ -0,0 +1,160 @@ +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); +} + +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; +} + +// 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'; + }); +} + +/** + * 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/RowRendererRegistry.ts b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts new file mode 100644 index 000000000..1ec18544f --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/RowRendererRegistry.ts @@ -0,0 +1,111 @@ +import type { WalletGroupRow } from '../../models'; +import { walletGroupRowRenderer } from './WalletGroupRowRenderer'; +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'; +import { dataRowRenderer } from './DataRowRenderer'; +import type { ActivityRow } from '../../models'; +import { activityRowRenderer } from './ActivityRowRenderer'; +import type { SystemRow } from '../../models'; +import { systemRowRenderer } from './SystemRowRenderer'; +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'; +import type { RowPrimitives } from './RowVisual'; +import { railRowRenderer } from './RailRowRenderer'; + +type Renderer = { + key: string; + appliesSizePreset?: (row: R) => boolean; + create: ( + document: Document, + row: R, + primitives: RowPrimitives + ) => HTMLElement; + bind: (body: HTMLElement, row: R, primitives: RowPrimitives) => void; + 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 { + key: renderer.key, + appliesSizePreset: renderer.appliesSizePreset?.(row) ?? true, + create: (document: Document, primitives: RowPrimitives) => + renderer.create(document, row, primitives), + bind: (body: HTMLElement, primitives: RowPrimitives) => + renderer.bind(body, row, primitives), + 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 = { + walletGroup: (row: RowModel) => + registration(walletGroupRowRenderer, row as WalletGroupRow), + 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), + 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) => + registration(messageRowRenderer, row as MessageRow), + 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, + sectionHeader: sectionHeaderRowRenderer, + market: marketRowRenderer, + metricCard: metricCardRowRenderer, + dataRow: dataRowRenderer, + activity: activityRowRenderer, + system: systemRowRenderer, + action: actionRowRenderer, + message: messageRowRenderer, + rail: railRowRenderer, + mediaTile: mediaTileRowRenderer, +}; +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) { + 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 row.type; +} +export function recycleRowBody(body: HTMLElement): boolean { + const key = body.dataset.nlRenderer ?? ''; + if (!isRegistered(key)) return false; + renderers[key].recycle(body); + return true; +} 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..a630bc43f --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/RowText.ts @@ -0,0 +1,181 @@ +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.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, + 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' + ); + // 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') + ); + const lines = style.lines ?? defaultLines ?? (inheritedClamp || 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) { + // 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; + } + 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. + // 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) { + 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 && !subscript) + 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 new file mode 100644 index 000000000..e77127dd5 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/RowVisual.ts @@ -0,0 +1,115 @@ +import type { + TrailingAccessory, + ImageSource, + LeadingVisual, + NativeListTextStyle, + NativeListImageStyle, +} from '../../models'; + +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, + presentation?: string + ) => HTMLElement | undefined; + thumbnail: (source: ImageSource) => HTMLElement | undefined; + textStyle: ( + element: HTMLElement, + style: NativeListTextStyle, + defaultLines?: number + ) => void; + dispose: (element: HTMLElement) => void; +}>; + +const properties = [ + 'width', + 'height', + 'flex-basis', + 'border-radius', + 'overflow', + 'object-fit', + 'margin-inline-end', + 'margin-bottom', + 'align-self', + 'border', + 'box-sizing', + 'clip-path', + 'flex', +]; + +/** 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)]) + ) + ); + }); + } + 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`; + 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; + }); + } +} 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..5162f9695 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/SectionHeaderRowRenderer.ts @@ -0,0 +1,243 @@ +import { applyTabularNumbers, hasExplicitRowHeight } from './RowElements'; +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' || !hasExplicitRowHeight(row)) { + title.style.textDecoration = 'underline dotted'; + title.style.textUnderlineOffset = '6px'; + } + if (row.titleActionOnHover) setData(title, 'nativeListHoverAction', true); + } + if (row.presentation === 'networkSelector' && hasExplicitRowHeight(row)) { + 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' && hasExplicitRowHeight(row)) { + 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; + if (row.presentation === 'networkSelector') applyTabularNumbers(body); + 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' + ? 16 + : row.variant === 'summary' + ? 68 + : row.variant === 'gallery' + ? 32 + : row.checkbox + ? 56 + : layout === 'linear' + ? 30 + : 36, + measureRendered: ( + _body: HTMLElement, + _row: SectionHeaderRow + ): number | undefined => undefined, +}; 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..6aacf81c3 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/SystemRowRenderer.ts @@ -0,0 +1,282 @@ +import type { NativeListTextStyle, SystemRow } from '../../models'; +import type { RowPrimitives } from './RowVisual'; +import { + captureInlineStyles, + createElement, + hasExplicitRowHeight, + restoreInlineStyles, + setData, + tagSlot, +} 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' + ) + ); + // 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; +} + +type Binding = { key: string; defaults: Map }; +const bindings = new WeakMap(); +function bind(body: HTMLElement, row: SystemRow, primitives: RowPrimitives) { + // 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) { + 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: captureInlineStyles(body), + }; + bindings.set(body, binding); + } + restoreInlineStyles(binding.defaults); + 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'] 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, + // 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(); + }, +}; 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..83c15b4b8 --- /dev/null +++ b/native-views/react-native-native-list/src/web/templates/WalletGroupRowRenderer.ts @@ -0,0 +1,131 @@ +import type { IdentityRow, WalletGroupRow } from '../../models'; +import { identityRowRenderer } from './IdentityRowRenderer'; +import { + applyRowContainerStyle, + resetRowContainerStyle, +} from './RowContainerStyle'; +import { 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 ?? + 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); + // 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 = `${Math.max( + 0, + row.style.horizontalPadding - 1 + )}px`; + if (row.style?.verticalPadding !== undefined) + 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) { + 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( + 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`; + 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, + 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, + appliesSizePreset: () => false, + // 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 + + (row.style?.verticalPadding ?? (row.parent.height !== undefined ? 1 : 0)) * + 2, + measureRendered: ( + _body: HTMLElement, + _row: WalletGroupRow + ): number | undefined => undefined, +};