Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
## [Unreleased]

### Features
- **native-list**: Add `snapshot.listStyle` for list chrome: `separator.inset`, `separator.color`, and `groupCornerRadius`, applied on all three platforms. The surface carries only what every platform can honour — pull to refresh is a system control on both native platforms, the section index belongs to `capabilities.sectionIndex`, and the reorder preview is drawn with platform-specific primitives — and `validateSnapshot` rejects any other key rather than accepting one that some platform would ignore. Every value is absent by default and each platform keeps its own number as the fallback, so an untouched list renders exactly as before; on Web the inset separator keeps a transparent border for layout and paints the line with a logical-inset overlay, so row heights do not move.
- **native-list (iOS and Android)**: Apply the row style natively. A style key is mapped to the view that renders that model field by `nativeListStyleSlot` (Kotlin, unit-tested) and `styleSlot` (Swift), because the view pool is shared and the mapping is not one to one. Both platforms gained a `resetRowStyle` that runs before the binder: Android's `resetViews` restores neither `textSize`, `typeface` nor `lineHeight`, and iOS's `reset` misses the data, metric and media label fonts, so without it a style would leak into the next row reusing the view. On iOS the pass rebuilds the attributed string, because the binders install one through `setLineHeight` and a plain `font` assignment would not take effect.
- **native-list**: Add a bounded per-template row style. `style` keys name the model field they modify rather than the view that carries it, so `metricCard.style.value` reaches the large number even though it shares a view with `identity.style.title`. Typography can be given as a named step (`{ token: '$bodyLg' }`) which is resolved to numbers in JavaScript before serialization, so no native renderer learns the vocabulary; explicit values still override. Keys a template does not declare are rejected by `validateSnapshot` and `validatePatches`, and styles carried by a patch are resolved the same way. Implemented on Web: text slots for every template plus `horizontalPadding`, `verticalPadding`, and `lineGap`. Row heights are unchanged — a styled row that grows still needs an explicit `height`.

Expand Down
36 changes: 36 additions & 0 deletions example/react-native/pages/NativeListRowStylePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,37 @@ function buildRows(styled: boolean): RowModel[] {
: {}),
});

// listStyle is chrome, not a row: the separator inset and the group card
// radius below come from the snapshot, not from these rows.
rows.push(header('chrome', 'listStyle', 'separator / group'));
rows.push({
type: 'identity',
key: 'chrome-separator',
sectionKey: 'chrome',
leading: { kind: 'icon', name: 'StarOutline' },
title: 'Separator inset',
subtitle: 'Inset comes from listStyle.separator',
separator: true,
});
rows.push({
type: 'identity',
key: 'chrome-group-first',
sectionKey: 'chrome',
groupId: 'chrome-card',
groupPosition: 'first',
leading: { kind: 'icon', name: 'StarOutline' },
title: 'Grouped card, first',
});
rows.push({
type: 'identity',
key: 'chrome-group-last',
sectionKey: 'chrome',
groupId: 'chrome-card',
groupPosition: 'last',
leading: { kind: 'icon', name: 'StarOutline' },
title: 'Grouped card, last',
});

// Growing text needs an explicit height: row heights are not derived from the
// style. See docs/STYLE_SPEC.md section 6.1.
rows.push(header('height', 'explicit height', 'larger text'));
Expand Down Expand Up @@ -194,6 +225,11 @@ export function NativeListRowStylePage() {
generation: styled ? 2 : 1,
layout: { kind: 'sectioned', stickyHeaders: true, contentPadding: 8 },
theme: THEME,
// Absent by default, so the chrome falls back to each platform's own
// numbers - which are not the same everywhere, see STYLE_SPEC section 6.2.
listStyle: styled
? { separator: { inset: 20 }, groupCornerRadius: 16 }
: undefined,
rows: buildRows(styled),
}),
[styled],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ internal class NativeListAdapter(
if (field?.toString() != value?.toString()) needsThemeRebind = true
field = value
}
// Reuses the theme rebind path: chrome lives outside the row payload, so a
// changed list style would otherwise not reach rows DiffUtil considers equal.
var listStyle: JSONObject? = null
set(value) {
if (field?.toString() != value?.toString()) needsThemeRebind = true
field = value
}
var layout: String = "linear"
var orientation: String = "vertical"
var selectedKeys: Set<String> = emptySet()
Expand Down Expand Up @@ -79,6 +86,7 @@ internal class NativeListAdapter(

override fun onBindViewHolder(holder: NativeListViewHolder, position: Int) {
val item = itemAt(position) ?: return
holder.rowView.listStyle = listStyle
holder.rowView.bind(
item,
theme,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ internal data class NativeListConfig(
val sectionIndexHapticsEnabled: Boolean,
val sectionIndexCenteredInWindow: Boolean,
val theme: JSONObject?,
val listStyle: JSONObject?,
val fixedFooter: NativeListItem?,
val items: List<NativeListItem>,
) {
Expand Down Expand Up @@ -261,6 +262,7 @@ internal data class NativeListConfig(
sectionIndexHapticsEnabled = sectionIndex?.optBoolean("hapticsEnabled", true) ?: true,
sectionIndexCenteredInWindow = sectionIndex?.optBoolean("centeredInWindow", false) ?: false,
theme = root.optJSONObject("theme"),
listStyle = root.optJSONObject("listStyle"),
fixedFooter = fixedFooter,
items = items,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,12 @@ internal class NativeListRowView(
private val separatorPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private var showsSeparator = false

/**
* docs/STYLE_SPEC.md section 5. Set by the adapter before bind, so the binder and
* groupedBackground can read it without another parameter.
*/
var listStyle: JSONObject? = null

var onRowPress: ((NativeListItem, NativeListActionOrigin) -> Unit)? = null
var onAction: ((NativeListItem, String, NativeSelectionTarget?, NativeListActionOrigin?) -> Unit)? = null
var onBindingInvalidated: ((NativeListRowView, Long) -> Unit)? = null
Expand Down Expand Up @@ -722,7 +728,14 @@ internal class NativeListRowView(
override fun dispatchDraw(canvas: Canvas) {
super.dispatchDraw(canvas)
if (showsSeparator) {
val start = if ((tag as? NativeListItem)?.type == "identity") dp(60).toFloat() else dp(12).toFloat()
val separatorStyle = listStyle?.optJSONObject("separator")
val start = if (separatorStyle?.has("inset") == true) {
dp(separatorStyle.optDouble("inset").roundToInt()).toFloat()
} else if ((tag as? NativeListItem)?.type == "identity") {
dp(60).toFloat()
} else {
dp(12).toFloat()
}
canvas.drawLine(start, height - 1f, width.toFloat(), height - 1f, separatorPaint)
}
if (reorderActive && (tag as? NativeListItem)?.type == "walletGroup" && walletGroupDragChildCount > 0) {
Expand Down Expand Up @@ -979,7 +992,10 @@ internal class NativeListRowView(
status.setTextColor(secondary)
metricSubtitle.setTextColor(secondary)
badgeLine.setTextColor(accent)
separatorPaint.color = color(theme, "separator", "#0000001F")
separatorPaint.color = safeColor(
listStyle?.optJSONObject("separator")?.optString("color"),
color(theme, "separator", "#0000001F"),
)
separatorPaint.strokeWidth = 1f
showsSeparator = item.json.optBoolean("separator", false) &&
!item.key.startsWith("token-") &&
Expand Down Expand Up @@ -1724,6 +1740,7 @@ internal class NativeListRowView(
memberRow.onBindingInvalidated = { row, epoch ->
onBindingInvalidated?.invoke(row, epoch)
}
memberRow.listStyle = listStyle
memberRow.bind(
member,
theme,
Expand Down Expand Up @@ -4420,7 +4437,9 @@ internal class NativeListRowView(

private fun groupedBackground(position: String, color: Int) = GradientDrawable().apply {
setColor(color)
val radius = scaledDp(12f)
val radius = listStyle?.takeIf { it.has("groupCornerRadius") }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: [Android applies groupCornerRadius outside groupId cards]

When a snapshot sets groupCornerRadius, applySelectionState synthesizes single for wallet-sidebar, sized selector, and metric-card rows even when they have no groupId. groupedBackground then applies the configured radius to those rows. iOS and Web consume the serialized groupPosition, so this creates Android-only geometry changes outside the documented grouped-card surface.

Gate the custom radius on actual groupId membership (or pass an explicit grouped-card flag), while preserving each non-group template's existing radius, and cover these selector and metric-card variants.

?.let { scaledDp(it.optDouble("groupCornerRadius").toFloat()) }
?: scaledDp(12f)
cornerRadii = when (position) {
"first" -> floatArrayOf(radius, radius, radius, radius, 0f, 0f, 0f, 0f)
"last" -> floatArrayOf(0f, 0f, 0f, 0f, radius, radius, radius, radius)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ class NativeListView(
usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale }
adapter.usesSelectorSourceScale = usesSelectorSourceScale
adapter.theme = next.theme
adapter.listStyle = next.listStyle

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: [Android initial snapshots never pass listStyle to rows]

On the normal snapshot path, including first mount, the adapter receives theme, layout, and orientation but not next.listStyle. This assignment exists only in the stable-content fast path, so regular rows bind with a null style and Android does not render the configured separator inset/color or grouped-card radius; only the footer receives the style.

Assign adapter.listStyle = next.listStyle alongside adapter.theme = next.theme in the normal path before submitList, and add Android coverage for an initial snapshot with listStyle.

adapter.layout = next.layout
adapter.orientation = next.orientation
adapter.selectedKeys = next.selectedKeys
Expand Down Expand Up @@ -1381,6 +1382,7 @@ class NativeListView(
footerView.recycle()
} else {
footerView.visibility = VISIBLE
footerView.listStyle = next.listStyle
footerView.bind(
footer,
next.theme,
Expand Down
30 changes: 28 additions & 2 deletions native-views/react-native-native-list/docs/STYLE_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ object identity.
| Contract, validation, token resolution, patch support | Implemented |
| Text slots, `horizontalPadding`, `verticalPadding`, `lineGap` | Implemented on Web, iOS and Android |
| `leadingGap`, `trailingGap`, `titleBadgeGap`, `image` | Validated but applied only on `market`. The others need a per-template default gap, which is not readable from the DOM on Web and is spread across the binders on the native sides |
| `listStyle.separator` and `listStyle.groupCornerRadius` | Implemented on Web, iOS and Android |
| `dataRow` columns in the `table` layout | Not applied. The text lives inside `NativeListTableColumnView` / `tableDataColumns`, which own their own labels; the `linear` layout is covered |
| Row heights | Unchanged. A styled row that grows still needs an explicit `row.height` (§6.1) |
| Android list-wide source scale (§6.3) | Unchanged. Making it per-row would move every selector list's metrics and needs device verification |
Expand Down Expand Up @@ -330,8 +331,8 @@ not yet implemented).
| --- | --- | --- | --- | --- |
| Content padding | yes (`layout.contentPadding*`) | `contentInset` | `setPadding` | `paddingValues` |
| Item spacing | yes (`layout.itemSpacing`) | flow layout spacing | `ItemSpacingDecoration` | layout gap |
| Separator | partial (`row.separator`) | 1/scale, inset 60/12 | 1px, inset dp(60)/dp(12) | `1px`, class rule |
| Group card radius | no | 20 / 12 | dp(12) | 12px |
| Separator | **yes** — `listStyle.separator.{inset,color}` | 1/scale, inset 60/12 | 1px, inset dp(60)/dp(12) | `1px`, class rule, **no inset** |
| Group card radius | **yes** — `listStyle.groupCornerRadius` | 20 / 12 | dp(12) | 12px |
| Section index rail | partial (`capabilities.sectionIndex`) | static constants, label 10 | `SECTION_INDEX_*_DP`, raw density | `SECTION_INDEX_*` + CSS |
| Section index preview | no | 48×48 r14, 22 semibold | preview w/h/margin constants | 48px r14, 22px |
| Pull to refresh | partial (`capabilities.pullToRefresh`) | `UIRefreshControl` | `SwipeRefreshLayout` | custom pill, 12px |
Expand All @@ -341,6 +342,25 @@ not yet implemented).
The section index rail is three separate constant sets for one control; Android's
`sectionIndexDp()` deliberately never follows the source-scale switch.

`listStyle` carries only what all three platforms can honour. The rest of this table
is deliberately excluded rather than declared and half-implemented:

- **Pull to refresh** is a system control on both native platforms (`UIRefreshControl`,
`SwipeRefreshLayout`); only Web draws its own indicator.
- **Section index rail and preview** belong to `capabilities.sectionIndex`, which is
where their geometry should go if it is ever exposed, and are three independent
constant sets today.
- **Reorder preview and count badge** are drawn with platform-specific primitives —
Android paints the badge on `Canvas` inside `dispatchDraw`, Web uses a CSS overlay,
and iOS has neither.
- **Content padding and item spacing** are already `layout.contentPadding*` and
`layout.itemSpacing`; duplicating them here would give one value two homes.

Every `listStyle` value is absent by default, and each platform keeps its own number
as the fallback, so an untouched list renders exactly as before. On Web that required
the inset separator to keep the transparent `border-bottom` for layout and paint the
visible line with a logical-inset overlay, rather than moving the row by a pixel.

## 6. T4 — Known divergences

Registered, **not** fixed by this document. Changing any of these requires a PR
Expand Down Expand Up @@ -382,6 +402,12 @@ here and left alone.
| `metricCard.value` | 18 semibold | sp(18) semibold | **22 / 28 / 700** |
| visual `rounded` radius | `min(10, h/4)` | `min(10, size/4)`; 8 for accountSelector | **10px**; 8 `!important` for account action; 8 for market |
| `walletSidebar` row radius | **20** | 12 | 12 |
| separator default inset | 60 identity / 12 | dp(60) identity / dp(12) | **0** |
| separator thickness | `1 / scale` (hairline) | `1f` raw px | `1px` |

Separator inset and colour are now settable through `listStyle.separator` (§5); the
defaults above are what a list gets when it does not set them. Thickness is not
exposed — a hairline is correct on iOS and a whole pixel is correct elsewhere.

### 6.3 Behavioral

Expand Down
20 changes: 17 additions & 3 deletions native-views/react-native-native-list/ios/NativeListCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,10 @@ final class NativeListCell: UICollectionViewCell {
private var selectorImageRetries: [ObjectIdentifier: DispatchWorkItem] = [:]
private(set) var bindingEpoch = 0

/// docs/STYLE_SPEC.md §5. Set by the list before `bind`, so the row binder and
/// `applyGroupPosition` can read it without another parameter.
var listStyle: [String: Any]?

var onAction: ((NativeListItem, String, NativeSelectionTarget?, NativeListActionOrigin?) -> Void)?
var onBindingInvalidated: ((NativeListCell, Int) -> Void)?

Expand Down Expand Up @@ -950,8 +954,14 @@ final class NativeListCell: UICollectionViewCell {
mediaBadgeLabel.backgroundColor = nativeListColor(theme, "inverseBackground", "#202020")
mediaBadgeLabel.textColor = nativeListColor(theme, "inverseText", "#FCFCFC")
mediaBadgeLabel.layer.borderColor = nativeListColor(theme, "rowBackground", "#FFFFFF").cgColor
separatorView.backgroundColor = nativeListColor(theme, "separator", "#E0E0E0")
separatorLeadingConstraint.constant = item.type == "identity" ? 60 : 12
let separatorStyle = listStyle?.dictionary("separator")
let separatorColor = nativeListColor(theme, "separator", "#E0E0E0")
separatorView.backgroundColor = (separatorStyle?["color"] as? String)
.map { UIColor(nativeListHex: $0, fallback: separatorColor) }
?? separatorColor
separatorLeadingConstraint.constant = separatorStyle?["inset"] == nil
? (item.type == "identity" ? 60 : 12)
: CGFloat(separatorStyle?.double("inset") ?? 0)
separatorView.isHidden = !item.data.bool("separator")
restingBackgroundColor = selectionBackgroundColor(
item: item,
Expand Down Expand Up @@ -1545,6 +1555,7 @@ final class NativeListCell: UICollectionViewCell {
memberCell.onBindingInvalidated = { [weak self] cell, epoch in
self?.onBindingInvalidated?(cell, epoch)
}
memberCell.listStyle = listStyle
memberCell.bind(
item: member,
theme: theme,
Expand All @@ -1556,6 +1567,7 @@ final class NativeListCell: UICollectionViewCell {
rootStack.addArrangedSubview(memberCell)
}
if let parent = walletGroupMembers.first {
walletGroupCompactCell?.listStyle = listStyle
walletGroupCompactCell?.bind(
item: parent,
theme: theme,
Expand Down Expand Up @@ -4414,7 +4426,9 @@ final class NativeListCell: UICollectionViewCell {
}
let isWalletSidebar = currentItem?.type == "identity"
&& currentItem?.data.string("presentation") == "walletSidebar"
layer.cornerRadius = isWalletSidebar ? 20 : 12
layer.cornerRadius = listStyle?["groupCornerRadius"] == nil
? (isWalletSidebar ? 20 : 12)
: CGFloat(listStyle?.double("groupCornerRadius") ?? 12)
layer.cornerCurve = isWalletSidebar ? .continuous : .circular
layer.masksToBounds = true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ struct NativeListConfig {
let sectionIndexHapticsEnabled: Bool
let sectionIndexCenteredInWindow: Bool
let theme: [String: Any]?
let listStyle: [String: Any]?
let fixedFooter: NativeListItem?
var items: [NativeListItem]

Expand Down Expand Up @@ -132,6 +133,7 @@ struct NativeListConfig {
sectionIndexHapticsEnabled: sectionIndex?["hapticsEnabled"] as? Bool ?? true,
sectionIndexCenteredInWindow: sectionIndex?["centeredInWindow"] as? Bool ?? false,
theme: root["theme"] as? [String: Any],
listStyle: root["listStyle"] as? [String: Any],
fixedFooter: footer,
items: items
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,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) })
Expand Down Expand Up @@ -1317,6 +1320,7 @@ final class NativeListView: UIView {

private func bind(cell: NativeListCell, item: NativeListItem, itemIndex: Int? = nil) {
guard let config else { return }
cell.listStyle = config.listStyle
cell.bind(
item: item,
theme: config.theme,
Expand Down Expand Up @@ -1469,6 +1473,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,
Expand Down Expand Up @@ -1516,6 +1521,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,20 @@ describe('web row style', () => {
expect(value?.style.fontWeight).toBe('700');
});

it("keeps today's numbers as the chrome fallbacks", () => {
// An untouched list must render exactly as before, so every chrome variable
// carries the current value as its CSS fallback.
expect(WEB_LIST_CSS).toContain(
'border-bottom:1px solid var(--nl-separator-color,var(--nl-separator))'
);
expect(WEB_LIST_CSS).toContain('border-radius:var(--nl-group-radius,12px)');
// The inset variant keeps the transparent border so row height is unchanged.
expect(WEB_LIST_CSS).toContain('border-bottom-color:transparent');
expect(WEB_LIST_CSS).toContain(
'inset-inline-start:var(--nl-separator-inset,0)'
);
});

it('applies box padding only when the row asks for it', () => {
const base: RowModel = {
type: 'identity',
Expand Down
Loading