Skip to content

feat(native-list): add listStyle for separator and group chrome - #112

Open
huhuanming wants to merge 1 commit into
codex/native-list-row-style-examplefrom
codex/native-list-chrome-style
Open

huhuanming wants to merge 1 commit into
codex/native-list-row-style-examplefrom
codex/native-list-chrome-style

Conversation

@huhuanming

Copy link
Copy Markdown
Contributor

Stacked on #111#110#109#107. This PR's diff is the chrome surface only.

Important

The iOS and Android halves are not compiled here (same constraint as #110). Swift parses cleanly; the Kotlin integration is review-only. Please build both apps before merging.

What

snapshot.listStyle — the list chrome that sits outside a row:

listStyle: {
  separator: { inset: 20, color: '#E0E0E0' },
  groupCornerRadius: 16,
}

Applied on Web, iOS and Android.

Why the surface is this small

I originally deferred listStyle because most chrome cannot be honoured on every platform. Rather than drop it or declare knobs that silently do nothing, the surface carries only what all three can actually do, and validateSnapshot rejects anything else:

NativeList snapshot.listStyle.pullToRefresh: is not a list style key
NativeList snapshot.listStyle.separator.thickness: is not a separator style key

Excluded, with the reason recorded in STYLE_SPEC.md §5:

Excluded Why
Pull to refresh UIRefreshControl / SwipeRefreshLayout are system controls; only Web draws its own
Section index rail and preview Belongs to capabilities.sectionIndex, and is three independent constant sets today
Reorder preview and count badge Platform-specific primitives — Android paints the badge on Canvas inside dispatchDraw, iOS has neither
Content padding, item spacing Already layout.contentPadding* / layout.itemSpacing; duplicating gives one value two homes
Separator thickness A hairline is correct on iOS and a whole pixel elsewhere; registered as a divergence instead

Defaults are untouched

Every value is absent by default and each platform keeps its own number as the fallback — including the ones that disagree (separator inset is 60/12 on the native sides and 0 on Web; now registered in §6.2).

Web needed care to keep that true. An inset separator:

  • keeps the transparent border-bottom, so the row's height does not move by a pixel;
  • paints the visible line with an inset-inline-start overlay, so it follows RTL — which the border-based separator never could.

Chrome must reach unchanged rows

listStyle lives outside the row payload, so a changed value has to rebind rows whose own content is identical. Android routes it through the existing theme-rebind flag; iOS folds it into the themeChanged comparison and into both snapshot fast-path guards, otherwise a chrome-only change would be swallowed by the controlled-selection path.

Threading is by property (cell.listStyle / rowView.listStyle) rather than a new bind parameter, so no existing call site changed shape. Wallet-group members and the fixed footer get it propagated.

Example page

Gains a listStyle section — a row with an inset separator and a two-row grouped card, both driven by the snapshot rather than by the rows. The existing toggle covers it, so with the style off the chrome falls back to today's rendering.

Verification

  • jest — 5 suites, 80 tests (was 78), pass
  • tsc -b — clean
  • eslint — clean
  • swiftc -parse on all three modified Swift files — clean
  • The example page typechecks against the worktree package source (throwaway tsconfig, not committed)
  • Not done: iOS build, Android build, on-device check

What to check on device

  1. With the style toggle off, separators and grouped cards must look exactly as before.
  2. Toggle on: the separator should start 20pt in, and the grouped card corners should go from 12 to 16.
  3. On an RTL locale, the inset separator should inset from the right.

🤖 Generated with Claude Code

snapshot.listStyle carries the list chrome that sits outside a row:
separator.inset, separator.color and groupCornerRadius, applied on Web, iOS
and Android.

The surface is deliberately small. It carries only what all three platforms can
honour, and validateSnapshot rejects any other key rather than accepting one
that some platform would quietly ignore. Pull to refresh is a system control on
both native platforms and only Web draws its own indicator; the section index
rail belongs to capabilities.sectionIndex and is three independent constant sets
today; the reorder preview and count badge are drawn with platform-specific
primitives, including a Canvas badge inside Android's dispatchDraw; and content
padding and item spacing already live on layout, so repeating them here would
give one value two homes. docs/STYLE_SPEC.md section 5 records all of that.

Every value is absent by default and each platform keeps its own number as the
fallback, so an untouched list renders exactly as before. Web needed care for
that: an inset separator keeps the transparent border-bottom so the row's height
does not change, and paints the visible line with an inset-inline-start overlay
so it also follows RTL, which the border could not.

Chrome lives outside the row payload, so a changed listStyle has to reach rows
whose own content is unchanged. Android routes it through the existing theme
rebind flag and iOS folds it into the themeChanged comparison and into both
snapshot fast-path guards.

The example page gains a listStyle section: a row with an inset separator and a
two-row grouped card, both driven by the snapshot rather than by the rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants