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 (iOS and Android)**: Apply the row style natively. A style key is mapped to the view that renders that model field by `nativeListStyleSlot` (Kotlin, unit-tested) and `styleSlot` (Swift), because the view pool is shared and the mapping is not one to one. Both platforms gained a `resetRowStyle` that runs before the binder: Android's `resetViews` restores neither `textSize`, `typeface` nor `lineHeight`, and iOS's `reset` misses the data, metric and media label fonts, so without it a style would leak into the next row reusing the view. On iOS the pass rebuilds the attributed string, because the binders install one through `setLineHeight` and a plain `font` assignment would not take effect.
- **native-list**: Add a bounded per-template row style. `style` keys name the model field they modify rather than the view that carries it, so `metricCard.style.value` reaches the large number even though it shares a view with `identity.style.title`. Typography can be given as a named step (`{ token: '$bodyLg' }`) which is resolved to numbers in JavaScript before serialization, so no native renderer learns the vocabulary; explicit values still override. Keys a template does not declare are rejected by `validateSnapshot` and `validatePatches`, and styles carried by a patch are resolved the same way. Implemented on Web: text slots for every template plus `horizontalPadding`, `verticalPadding`, and `lineGap`. Row heights are unchanged — a styled row that grows still needs an explicit `height`.

### Documentation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,77 @@ internal fun isNativeListWholeRowInteractive(
pressDisabled = pressDisabled,
)

/**
* Maps a style key - which names a model field - to the view slot that renders it.
* The view pool is shared across templates and the mapping is not one to one:
* metricCard renders `value` through the title view and its own `title` through
* the subtitle view, and one status view carries rail.status, activity.status,
* message.time and metricCard.trend. See docs/STYLE_SPEC.md section 4.
*
* Returns null when the template does not render that field, so an unmapped key
* is ignored rather than reaching an unrelated view.
*/
internal fun nativeListStyleSlot(type: String, variant: String, field: String): String? =
when (type) {
"identity" -> when (field) {
"title", "subtitle", "tertiary", "badge", "value", "valueSecondary" -> field
else -> null
}
"rail" -> when (field) {
"title", "badge", "status" -> field
else -> null
}
"activity" -> when (field) {
"title", "status" -> field
"description" -> "subtitle"
"primaryAmount" -> "value"
"secondaryAmount" -> "valueSecondary"
else -> null
}
"message" -> when (field) {
"title" -> "title"
"body" -> "subtitle"
"time" -> "status"
else -> null
}
"dataRow" -> when (field) {

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: [Data-row secondary styles are silently dropped on native]

When a dataRow provides the valid style.columnSecondary field, this mapping returns null, so Android ignores it; the new iOS mapping has the same omission. Web renders this slot, and table rows also use separate column-label views that the columns path never reaches.

Implement columnSecondary in the native linear and table data-row renderers (secondary spans/labels respectively) and cover both layouts with a parity test.

"columns" -> "dataPrimary"
"index" -> "value"
else -> null
}
"mediaTile" -> when (field) {
"title", "subtitle" -> field
"badge" -> "mediaBadge"
else -> null
}
// The large number and the small label sit in swapped views.
"metricCard" -> when (field) {
"value" -> "title"
"title" -> "subtitle"
"subtitle" -> "metricSubtitle"
"trend" -> "status"
else -> null
}
"sectionHeader" -> when (field) {
"title", "subtitle", "value" -> field
else -> null
}
"action" -> when (field) {
"title", "value" -> field
else -> null
}
"system" -> when (field) {
"title" -> "title"
// Only the warning variant renders a separate title; every other variant
// puts its message in the title view.
"message" -> if (variant == "warning") "subtitle" else "title"
"actionText" -> "value"
else -> null
}
// Market owns its own richer style path.
else -> null
}

// The image fallback-state cache is process-wide, so it is keyed by a digest of the request
// identity instead of the raw headers, which can carry credentials such as Authorization.
internal fun nativeListSourceFallbackStateKey(uri: String, headers: Map<String, String>): String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,8 @@ internal class NativeListRowView(
private var marketTouchX = 0f
private var marketTouchY = 0f
private var marketLongPressFired = false
// OneKey patch: a row style wrote view properties that resetViews does not restore.
private var styledViewsDirty = false
private var reorderActive = false
private var checkboxCheckedColor = Color.rgb(32, 32, 32)
private var checkboxUncheckedColor = Color.rgb(252, 252, 252)
Expand Down Expand Up @@ -1023,10 +1025,84 @@ internal class NativeListRowView(
TextViewCompat.setLineHeight(subtitle, dp(20))
minimumHeight = 0
}
applyRowStyle(item)
applyListOrientation(item, listOrientation)
applySelectorTypography(item)
}

/**
* docs/STYLE_SPEC.md section 8. Must run after applySize(), which re-dispatches
* font size and typeface by row type and would otherwise overwrite the style.
* A style key names a model field; nativeListStyleSlot maps it to the view that
* renders that field, because the view pool is shared across templates.
*/
private fun applyRowStyle(item: NativeListItem) {
// Market owns its own richer style path, applied inside bindMarket.
if (item.type == "market") return
val style = item.json.optJSONObject("style") ?: return
styledViewsDirty = true
if (style.has("horizontalPadding")) {
val inset = dp(style.optDouble("horizontalPadding").roundToInt())
setPadding(inset, paddingTop, inset, paddingBottom)
}
if (style.has("verticalPadding")) {
val inset = dp(style.optDouble("verticalPadding").roundToInt())
setPadding(paddingLeft, inset, paddingRight, inset)
}
if (style.has("lineGap")) {
(subtitle.layoutParams as? MarginLayoutParams)?.topMargin =
dp(style.optDouble("lineGap").roundToInt())
}
val variant = item.json.optString("variant")
val fields = style.keys()
while (fields.hasNext()) {
val field = fields.next()
val slotStyle = style.optJSONObject(field) ?: continue
when (val slot = nativeListStyleSlot(item.type, variant, field)) {
null -> continue
"dataPrimary" -> dataColumns.forEach { applyStyledText(it, slotStyle) }
else -> styledSlotView(slot)?.let { applyStyledText(it, slotStyle) }
}
}
}

private fun styledSlotView(slot: String): TextView? = when (slot) {
"title" -> title
"subtitle" -> subtitle
"tertiary" -> tertiary
"status" -> status
"metricSubtitle" -> metricSubtitle
"badge" -> badgeLine
"mediaBadge" -> mediaBadge
"value" -> trailingViews[0]
"valueSecondary" -> trailingViews[1]
else -> null
}

private fun applyStyledText(view: TextView, style: JSONObject) {
if (style.has("fontSize")) view.textSize = sp(style.optDouble("fontSize").toFloat())
style.optString("fontWeight").takeIf(String::isNotEmpty)?.let {
view.typeface = marketTypeface(it, "regular")
}
style.optString("color").takeIf(String::isNotEmpty)?.let {
view.setTextColor(safeColor(it, view.currentTextColor))
}
if (style.has("lineHeight")) {
TextViewCompat.setLineHeight(view, dp(style.optDouble("lineHeight").roundToInt()))
}
if (style.has("lines")) {
view.maxLines = style.optInt("lines").coerceIn(1, 2)
view.ellipsize = TextUtils.TruncateAt.END
}
style.optString("alignment").takeIf(String::isNotEmpty)?.let {
view.gravity = (view.gravity and Gravity.VERTICAL_GRAVITY_MASK) or when (it) {
"center" -> Gravity.CENTER_HORIZONTAL
"end" -> Gravity.END
else -> Gravity.START
}
}
}

// OneKey patch: keep a small idle member pool after reuse or a direct rebind.
// The compact proxy/expansion keeps every member until it leaves that state.
private fun trimWalletGroupRows(required: Int) {
Expand Down Expand Up @@ -1209,7 +1285,33 @@ internal class NativeListRowView(
bindingEpoch += 1
}

/**
* docs/STYLE_SPEC.md section 7 rule 2: resetViews() restores visibility, gravity,
* maxLines, layout params, background and padding - but not textSize, typeface or
* lineHeight, which each binding path re-establishes for itself. A style that wrote
* one of those would therefore leak into the next row reusing the view, so put them
* back to the constructor baseline before the binder runs.
*/
private fun resetRowStyle() {

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 style alignment leaks across recycled rows]

A styled row with alignment: "center" or "end" changes the shared label's gravity. On the next bind, resetRowStyle() restores only typeface and line spacing for labels such as subtitle and status; their gravity is not restored by resetViews() either. An unstyled row reusing that view can therefore keep the previous row's alignment after scrolling or a direct rebind.

Reset the baseline horizontal gravity for every styleable shared label and add a styled-to-unstyled reuse regression test.

if (!styledViewsDirty) return
styledViewsDirty = false
listOf(title, subtitle, tertiary, status, metricSubtitle).forEach { view ->
view.typeface = NativeListFonts.regular(context)
view.setLineSpacing(0f, 1f)
}
badgeLine.typeface = NativeListFonts.medium(context)
badgeLine.setLineSpacing(0f, 1f)
mediaBadge.typeface = NativeListFonts.regular(context)
mediaBadge.setLineSpacing(0f, 1f)
trailingViews.forEach { it.setLineSpacing(0f, 1f) }
dataColumns.forEach {
it.typeface = NativeListFonts.medium(context)
it.setLineSpacing(0f, 1f)
}
}

private fun resetViews() {
resetRowStyle()
clipChildren = true
clipToPadding = true
selectorOriginalFontFeatures.forEach { (view, original) -> view.fontFeatureSettings = original }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.margelo.nitro.nativelist

import org.junit.Assert.assertEquals
import org.junit.Test

class NativeListStyleSlotTest {
private data class Case(
val type: String,
val field: String,
val variant: String = "",
val expected: String?,
)

@Test
fun styleKeyResolvesToTheViewThatRendersThatModelField() {
val cases = listOf(
// metricCard swaps the two views: the large number is drawn by the title
// view and the small label by the subtitle view.
Case(type = "metricCard", field = "value", expected = "title"),
Case(type = "metricCard", field = "title", expected = "subtitle"),
Case(type = "metricCard", field = "subtitle", expected = "metricSubtitle"),
Case(type = "metricCard", field = "trend", expected = "status"),
// identity keeps its own names.
Case(type = "identity", field = "title", expected = "title"),
Case(type = "identity", field = "valueSecondary", expected = "valueSecondary"),
// One status view carries four different model fields.
Case(type = "rail", field = "status", expected = "status"),
Case(type = "activity", field = "status", expected = "status"),
Case(type = "message", field = "time", expected = "status"),
// Amounts, indices and values share the two trailing views.
Case(type = "activity", field = "primaryAmount", expected = "value"),
Case(type = "activity", field = "secondaryAmount", expected = "valueSecondary"),
Case(type = "dataRow", field = "index", expected = "value"),
Case(type = "sectionHeader", field = "value", expected = "value"),
// Only the warning variant renders a separate title.
Case(type = "system", field = "message", variant = "warning", expected = "subtitle"),
Case(type = "system", field = "message", variant = "noMatch", expected = "title"),
// A field the template does not render is ignored, never remapped onto
// whichever view happens to be free.
Case(type = "identity", field = "price", expected = null),
Case(type = "rail", field = "subtitle", expected = null),
Case(type = "market", field = "title", expected = null),
Case(type = "walletGroup", field = "title", expected = null),
)

assertEquals(
cases.map(Case::expected),
cases.map { nativeListStyleSlot(it.type, it.variant, it.field) },
)
}

@Test
fun noTemplateMapsTwoStyleKeysOntoOneView() {
// A collision would make one of the two keys silently win.
val templates = listOf(
Triple("identity", "", listOf("title", "subtitle", "tertiary", "badge", "value", "valueSecondary")),
Triple("rail", "", listOf("title", "badge", "status")),
Triple("activity", "", listOf("title", "description", "status", "primaryAmount", "secondaryAmount")),
Triple("message", "", listOf("title", "body", "time")),
Triple("dataRow", "", listOf("columns", "index")),
Triple("mediaTile", "", listOf("title", "subtitle", "badge")),
Triple("metricCard", "", listOf("title", "value", "subtitle", "trend")),
Triple("sectionHeader", "", listOf("title", "subtitle", "value")),
Triple("action", "", listOf("title", "value")),
// Only `warning` carries both a title and a message; the other variants
// have no title field at all, so their message owning the title view is
// not a collision.
Triple("system", "warning", listOf("title", "message", "actionText")),
)

templates.forEach { (type, variant, fields) ->
val slots = fields.mapNotNull { nativeListStyleSlot(type, variant, it) }
assertEquals(
"$type maps two style keys onto one view: $slots",
slots.size,
slots.toSet().size,
)
}
}
}
24 changes: 19 additions & 5 deletions native-views/react-native-native-list/docs/STYLE_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,16 @@ object identity.
| Layer | State |
| --- | --- |
| Contract, validation, token resolution, patch support | Implemented |
| Web — text slots for every template, `horizontalPadding`, `verticalPadding`, `lineGap` | Implemented |
| Web — `leadingGap`, `trailingGap`, `titleBadgeGap`, `image` | Validated but applied only on `market`; the others need a per-template default gap that cannot be read back from the DOM |
| iOS, Android | Not started |
| Text slots, `horizontalPadding`, `verticalPadding`, `lineGap` | Implemented on Web, iOS and Android |
| `leadingGap`, `trailingGap`, `titleBadgeGap`, `image` | Validated but applied only on `market`. The others need a per-template default gap, which is not readable from the DOM on Web and is spread across the binders on the native sides |
| `dataRow` columns in the `table` layout | Not applied. The text lives inside `NativeListTableColumnView` / `tableDataColumns`, which own their own labels; the `linear` layout is covered |
| Row heights | Unchanged. A styled row that grows still needs an explicit `row.height` (§6.1) |
| Android list-wide source scale (§6.3) | Unchanged. Making it per-row would move every selector list's metrics and needs device verification |

The field-to-view mapping exists twice, once per native language — `nativeListStyleSlot`
in `NativeListModels.kt` and `styleSlot` in `NativeListCell.swift`. §4 is the source of
truth for both; the Kotlin copy is unit-tested, including a check that no template maps
two style keys onto one view.

## 3. T1 — Design tokens

Expand Down Expand Up @@ -427,11 +433,19 @@ affect any other row. Four rules:
Each renderer already runs a pass after the per-template binder. The style pass
belongs there, so none of the 13 binders change.

All three are implemented as `applyRowStyle`.

| Platform | Anchor | Note |
| --- | --- | --- |
| iOS | `NativeListCell.bind()`, after the `switch item.type` | Also covers box metrics: the four root constraints, stack spacings, leading size |
| iOS | `NativeListCell.bind()`, after the `switch item.type` | The binders install an attributed string through `setLineHeight`, so assigning `font` or `textColor` alone would not take effect — the style pass rebuilds the line box |
| Android | `NativeListRowView.bind()`, **after** `applySize(item)` | `applySize` re-dispatches font size and typeface by row type and would otherwise overwrite the style |
| Web | `renderElement()`, after `createRowBody()` — **implemented** as `applyRowStyle()` | The selector inline overrides already live here |
| Web | `renderElement()`, after `createRowBody()` | The selector inline overrides already live here |

Each platform also gained a `resetRowStyle` that runs before the binder, per §7 rule 2.
On Android `resetViews()` restores neither `textSize`, `typeface` nor `lineHeight`; on iOS
`reset()` restores the shared label fonts but not the data, metric and media label fonts,
nor any text alignment. Without the reset, a style would leak into the next row that
reuses the view.

The existing market helpers generalize rather than being rewritten:
`applyMarketTextStyle` / `applyMarketButtonStyle` / `marketAttributedText` (iOS),
Expand Down
Loading