From e3159eae271f314a1d04a70d46ffc4d748474a6f Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 16 Sep 2026 09:33:30 +0800 Subject: [PATCH] feat(native-list): apply the row style on iOS and Android Both platforms gain applyRowStyle, running after the per-template binder so it is the last writer. Android must run after applySize, which re-dispatches font size and typeface by row type and would otherwise overwrite the style. A style key names a model field, and the view pool is shared, so the same field lands in a different view per template: metricCard draws its value through the view identity uses for its title, and one status view carries rail.status, activity.status, message.time and metricCard.trend. The mapping is nativeListStyleSlot in NativeListModels.kt and styleSlot in NativeListCell.swift, both following docs/STYLE_SPEC.md section 4. The Kotlin copy is unit-tested, including a check that no template maps two style keys onto one view - a collision would make one of them silently win. Both platforms also gain resetRowStyle, running before the binder, because neither reset path is complete (STYLE_SPEC section 7 rule 2). Android's resetViews restores visibility, gravity, maxLines, layout params, background and padding but not textSize, typeface or lineHeight. iOS's reset restores the fonts of the shared labels but not those of the data, metric and media labels, nor any text alignment. Without the reset a style would leak into the next row that reuses the view. Both resets are guarded by a dirty flag, so an unstyled list pays nothing. On iOS the pass rebuilds the attributed string rather than assigning font and textColor: the binders install an attributed string through setLineHeight, so a plain font assignment would not take effect. The rebuilt line box centers font metrics inside an explicit lineHeight, matching React Native and the Web engine. Deliberately unchanged, and recorded in the spec's status table: - Row heights. A styled row that grows still needs an explicit height. - The list-wide source scale on Android. Making it per-row would move every selector list's metrics and needs device verification. - dataRow columns in the table layout, whose text lives inside NativeListTableColumnView; the linear layout is covered. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../com/onekey/nativelist/NativeListModels.kt | 71 ++++++ .../onekey/nativelist/NativeListRowView.kt | 102 ++++++++ .../nativelist/NativeListStyleSlotTest.kt | 80 ++++++ .../docs/STYLE_SPEC.md | 24 +- .../ios/NativeListCell.swift | 230 ++++++++++++++++++ 6 files changed, 503 insertions(+), 5 deletions(-) create mode 100644 native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 14cbc5ea..8204f275 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Features +- **native-list (iOS and Android)**: Apply the row style natively. A style key is mapped to the view that renders that model field by `nativeListStyleSlot` (Kotlin, unit-tested) and `styleSlot` (Swift), because the view pool is shared and the mapping is not one to one. Both platforms gained a `resetRowStyle` that runs before the binder: Android's `resetViews` restores neither `textSize`, `typeface` nor `lineHeight`, and iOS's `reset` misses the data, metric and media label fonts, so without it a style would leak into the next row reusing the view. On iOS the pass rebuilds the attributed string, because the binders install one through `setLineHeight` and a plain `font` assignment would not take effect. - **native-list**: Add a bounded per-template row style. `style` keys name the model field they modify rather than the view that carries it, so `metricCard.style.value` reaches the large number even though it shares a view with `identity.style.title`. Typography can be given as a named step (`{ token: '$bodyLg' }`) which is resolved to numbers in JavaScript before serialization, so no native renderer learns the vocabulary; explicit values still override. Keys a template does not declare are rejected by `validateSnapshot` and `validatePatches`, and styles carried by a patch are resolved the same way. Implemented on Web: text slots for every template plus `horizontalPadding`, `verticalPadding`, and `lineGap`. Row heights are unchanged — a styled row that grows still needs an explicit `height`. ### Documentation diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt index 36bc73b9..52ae588f 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt @@ -25,6 +25,77 @@ internal fun isNativeListWholeRowInteractive( pressDisabled = pressDisabled, ) +/** + * Maps a style key - which names a model field - to the view slot that renders it. + * The view pool is shared across templates and the mapping is not one to one: + * metricCard renders `value` through the title view and its own `title` through + * the subtitle view, and one status view carries rail.status, activity.status, + * message.time and metricCard.trend. See docs/STYLE_SPEC.md section 4. + * + * Returns null when the template does not render that field, so an unmapped key + * is ignored rather than reaching an unrelated view. + */ +internal fun nativeListStyleSlot(type: String, variant: String, field: String): String? = + when (type) { + "identity" -> when (field) { + "title", "subtitle", "tertiary", "badge", "value", "valueSecondary" -> field + else -> null + } + "rail" -> when (field) { + "title", "badge", "status" -> field + else -> null + } + "activity" -> when (field) { + "title", "status" -> field + "description" -> "subtitle" + "primaryAmount" -> "value" + "secondaryAmount" -> "valueSecondary" + else -> null + } + "message" -> when (field) { + "title" -> "title" + "body" -> "subtitle" + "time" -> "status" + else -> null + } + "dataRow" -> when (field) { + "columns" -> "dataPrimary" + "index" -> "value" + else -> null + } + "mediaTile" -> when (field) { + "title", "subtitle" -> field + "badge" -> "mediaBadge" + else -> null + } + // The large number and the small label sit in swapped views. + "metricCard" -> when (field) { + "value" -> "title" + "title" -> "subtitle" + "subtitle" -> "metricSubtitle" + "trend" -> "status" + else -> null + } + "sectionHeader" -> when (field) { + "title", "subtitle", "value" -> field + else -> null + } + "action" -> when (field) { + "title", "value" -> field + else -> null + } + "system" -> when (field) { + "title" -> "title" + // Only the warning variant renders a separate title; every other variant + // puts its message in the title view. + "message" -> if (variant == "warning") "subtitle" else "title" + "actionText" -> "value" + else -> null + } + // Market owns its own richer style path. + else -> null + } + // The image fallback-state cache is process-wide, so it is keyed by a digest of the request // identity instead of the raw headers, which can carry credentials such as Authorization. internal fun nativeListSourceFallbackStateKey(uri: String, headers: Map): String? { diff --git a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt index 6b042043..72c039ed 100644 --- a/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/native-views/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -523,6 +523,8 @@ internal class NativeListRowView( private var marketTouchX = 0f private var marketTouchY = 0f private var marketLongPressFired = false + // OneKey patch: a row style wrote view properties that resetViews does not restore. + private var styledViewsDirty = false private var reorderActive = false private var checkboxCheckedColor = Color.rgb(32, 32, 32) private var checkboxUncheckedColor = Color.rgb(252, 252, 252) @@ -1023,10 +1025,84 @@ internal class NativeListRowView( TextViewCompat.setLineHeight(subtitle, dp(20)) minimumHeight = 0 } + applyRowStyle(item) applyListOrientation(item, listOrientation) applySelectorTypography(item) } + /** + * docs/STYLE_SPEC.md section 8. Must run after applySize(), which re-dispatches + * font size and typeface by row type and would otherwise overwrite the style. + * A style key names a model field; nativeListStyleSlot maps it to the view that + * renders that field, because the view pool is shared across templates. + */ + private fun applyRowStyle(item: NativeListItem) { + // Market owns its own richer style path, applied inside bindMarket. + if (item.type == "market") return + val style = item.json.optJSONObject("style") ?: return + styledViewsDirty = true + if (style.has("horizontalPadding")) { + val inset = dp(style.optDouble("horizontalPadding").roundToInt()) + setPadding(inset, paddingTop, inset, paddingBottom) + } + if (style.has("verticalPadding")) { + val inset = dp(style.optDouble("verticalPadding").roundToInt()) + setPadding(paddingLeft, inset, paddingRight, inset) + } + if (style.has("lineGap")) { + (subtitle.layoutParams as? MarginLayoutParams)?.topMargin = + dp(style.optDouble("lineGap").roundToInt()) + } + val variant = item.json.optString("variant") + val fields = style.keys() + while (fields.hasNext()) { + val field = fields.next() + val slotStyle = style.optJSONObject(field) ?: continue + when (val slot = nativeListStyleSlot(item.type, variant, field)) { + null -> continue + "dataPrimary" -> dataColumns.forEach { applyStyledText(it, slotStyle) } + else -> styledSlotView(slot)?.let { applyStyledText(it, slotStyle) } + } + } + } + + private fun styledSlotView(slot: String): TextView? = when (slot) { + "title" -> title + "subtitle" -> subtitle + "tertiary" -> tertiary + "status" -> status + "metricSubtitle" -> metricSubtitle + "badge" -> badgeLine + "mediaBadge" -> mediaBadge + "value" -> trailingViews[0] + "valueSecondary" -> trailingViews[1] + else -> null + } + + private fun applyStyledText(view: TextView, style: JSONObject) { + if (style.has("fontSize")) view.textSize = sp(style.optDouble("fontSize").toFloat()) + style.optString("fontWeight").takeIf(String::isNotEmpty)?.let { + view.typeface = marketTypeface(it, "regular") + } + style.optString("color").takeIf(String::isNotEmpty)?.let { + view.setTextColor(safeColor(it, view.currentTextColor)) + } + if (style.has("lineHeight")) { + TextViewCompat.setLineHeight(view, dp(style.optDouble("lineHeight").roundToInt())) + } + if (style.has("lines")) { + view.maxLines = style.optInt("lines").coerceIn(1, 2) + view.ellipsize = TextUtils.TruncateAt.END + } + style.optString("alignment").takeIf(String::isNotEmpty)?.let { + view.gravity = (view.gravity and Gravity.VERTICAL_GRAVITY_MASK) or when (it) { + "center" -> Gravity.CENTER_HORIZONTAL + "end" -> Gravity.END + else -> Gravity.START + } + } + } + // OneKey patch: keep a small idle member pool after reuse or a direct rebind. // The compact proxy/expansion keeps every member until it leaves that state. private fun trimWalletGroupRows(required: Int) { @@ -1209,7 +1285,33 @@ internal class NativeListRowView( bindingEpoch += 1 } + /** + * docs/STYLE_SPEC.md section 7 rule 2: resetViews() restores visibility, gravity, + * maxLines, layout params, background and padding - but not textSize, typeface or + * lineHeight, which each binding path re-establishes for itself. A style that wrote + * one of those would therefore leak into the next row reusing the view, so put them + * back to the constructor baseline before the binder runs. + */ + private fun resetRowStyle() { + if (!styledViewsDirty) return + styledViewsDirty = false + listOf(title, subtitle, tertiary, status, metricSubtitle).forEach { view -> + view.typeface = NativeListFonts.regular(context) + view.setLineSpacing(0f, 1f) + } + badgeLine.typeface = NativeListFonts.medium(context) + badgeLine.setLineSpacing(0f, 1f) + mediaBadge.typeface = NativeListFonts.regular(context) + mediaBadge.setLineSpacing(0f, 1f) + trailingViews.forEach { it.setLineSpacing(0f, 1f) } + dataColumns.forEach { + it.typeface = NativeListFonts.medium(context) + it.setLineSpacing(0f, 1f) + } + } + private fun resetViews() { + resetRowStyle() clipChildren = true clipToPadding = true selectorOriginalFontFeatures.forEach { (view, original) -> view.fontFeatureSettings = original } diff --git a/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt new file mode 100644 index 00000000..d1c9ee38 --- /dev/null +++ b/native-views/react-native-native-list/android/src/test/java/com/margelo/nitro/nativelist/NativeListStyleSlotTest.kt @@ -0,0 +1,80 @@ +package com.margelo.nitro.nativelist + +import org.junit.Assert.assertEquals +import org.junit.Test + +class NativeListStyleSlotTest { + private data class Case( + val type: String, + val field: String, + val variant: String = "", + val expected: String?, + ) + + @Test + fun styleKeyResolvesToTheViewThatRendersThatModelField() { + val cases = listOf( + // metricCard swaps the two views: the large number is drawn by the title + // view and the small label by the subtitle view. + Case(type = "metricCard", field = "value", expected = "title"), + Case(type = "metricCard", field = "title", expected = "subtitle"), + Case(type = "metricCard", field = "subtitle", expected = "metricSubtitle"), + Case(type = "metricCard", field = "trend", expected = "status"), + // identity keeps its own names. + Case(type = "identity", field = "title", expected = "title"), + Case(type = "identity", field = "valueSecondary", expected = "valueSecondary"), + // One status view carries four different model fields. + Case(type = "rail", field = "status", expected = "status"), + Case(type = "activity", field = "status", expected = "status"), + Case(type = "message", field = "time", expected = "status"), + // Amounts, indices and values share the two trailing views. + Case(type = "activity", field = "primaryAmount", expected = "value"), + Case(type = "activity", field = "secondaryAmount", expected = "valueSecondary"), + Case(type = "dataRow", field = "index", expected = "value"), + Case(type = "sectionHeader", field = "value", expected = "value"), + // Only the warning variant renders a separate title. + Case(type = "system", field = "message", variant = "warning", expected = "subtitle"), + Case(type = "system", field = "message", variant = "noMatch", expected = "title"), + // A field the template does not render is ignored, never remapped onto + // whichever view happens to be free. + Case(type = "identity", field = "price", expected = null), + Case(type = "rail", field = "subtitle", expected = null), + Case(type = "market", field = "title", expected = null), + Case(type = "walletGroup", field = "title", expected = null), + ) + + assertEquals( + cases.map(Case::expected), + cases.map { nativeListStyleSlot(it.type, it.variant, it.field) }, + ) + } + + @Test + fun noTemplateMapsTwoStyleKeysOntoOneView() { + // A collision would make one of the two keys silently win. + val templates = listOf( + Triple("identity", "", listOf("title", "subtitle", "tertiary", "badge", "value", "valueSecondary")), + Triple("rail", "", listOf("title", "badge", "status")), + Triple("activity", "", listOf("title", "description", "status", "primaryAmount", "secondaryAmount")), + Triple("message", "", listOf("title", "body", "time")), + Triple("dataRow", "", listOf("columns", "index")), + Triple("mediaTile", "", listOf("title", "subtitle", "badge")), + Triple("metricCard", "", listOf("title", "value", "subtitle", "trend")), + Triple("sectionHeader", "", listOf("title", "subtitle", "value")), + Triple("action", "", listOf("title", "value")), + // Only `warning` carries both a title and a message; the other variants + // have no title field at all, so their message owning the title view is + // not a collision. + Triple("system", "warning", listOf("title", "message", "actionText")), + ) + + templates.forEach { (type, variant, fields) -> + val slots = fields.mapNotNull { nativeListStyleSlot(type, variant, it) } + assertEquals( + "$type maps two style keys onto one view: $slots", + slots.size, + slots.toSet().size, + ) + } + } +} diff --git a/native-views/react-native-native-list/docs/STYLE_SPEC.md b/native-views/react-native-native-list/docs/STYLE_SPEC.md index 5da23b0f..4fb99c11 100644 --- a/native-views/react-native-native-list/docs/STYLE_SPEC.md +++ b/native-views/react-native-native-list/docs/STYLE_SPEC.md @@ -53,10 +53,16 @@ object identity. | Layer | State | | --- | --- | | Contract, validation, token resolution, patch support | Implemented | -| Web — text slots for every template, `horizontalPadding`, `verticalPadding`, `lineGap` | Implemented | -| Web — `leadingGap`, `trailingGap`, `titleBadgeGap`, `image` | Validated but applied only on `market`; the others need a per-template default gap that cannot be read back from the DOM | -| iOS, Android | Not started | +| Text slots, `horizontalPadding`, `verticalPadding`, `lineGap` | Implemented on Web, iOS and Android | +| `leadingGap`, `trailingGap`, `titleBadgeGap`, `image` | Validated but applied only on `market`. The others need a per-template default gap, which is not readable from the DOM on Web and is spread across the binders on the native sides | +| `dataRow` columns in the `table` layout | Not applied. The text lives inside `NativeListTableColumnView` / `tableDataColumns`, which own their own labels; the `linear` layout is covered | | Row heights | Unchanged. A styled row that grows still needs an explicit `row.height` (§6.1) | +| Android list-wide source scale (§6.3) | Unchanged. Making it per-row would move every selector list's metrics and needs device verification | + +The field-to-view mapping exists twice, once per native language — `nativeListStyleSlot` +in `NativeListModels.kt` and `styleSlot` in `NativeListCell.swift`. §4 is the source of +truth for both; the Kotlin copy is unit-tested, including a check that no template maps +two style keys onto one view. ## 3. T1 — Design tokens @@ -427,11 +433,19 @@ affect any other row. Four rules: Each renderer already runs a pass after the per-template binder. The style pass belongs there, so none of the 13 binders change. +All three are implemented as `applyRowStyle`. + | Platform | Anchor | Note | | --- | --- | --- | -| iOS | `NativeListCell.bind()`, after the `switch item.type` | Also covers box metrics: the four root constraints, stack spacings, leading size | +| iOS | `NativeListCell.bind()`, after the `switch item.type` | The binders install an attributed string through `setLineHeight`, so assigning `font` or `textColor` alone would not take effect — the style pass rebuilds the line box | | Android | `NativeListRowView.bind()`, **after** `applySize(item)` | `applySize` re-dispatches font size and typeface by row type and would otherwise overwrite the style | -| Web | `renderElement()`, after `createRowBody()` — **implemented** as `applyRowStyle()` | The selector inline overrides already live here | +| Web | `renderElement()`, after `createRowBody()` | The selector inline overrides already live here | + +Each platform also gained a `resetRowStyle` that runs before the binder, per §7 rule 2. +On Android `resetViews()` restores neither `textSize`, `typeface` nor `lineHeight`; on iOS +`reset()` restores the shared label fonts but not the data, metric and media label fonts, +nor any text alignment. Without the reset, a style would leak into the next row that +reuses the view. The existing market helpers generalize rather than being rewritten: `applyMarketTextStyle` / `applyMarketButtonStyle` / `marketAttributedText` (iOS), diff --git a/native-views/react-native-native-list/ios/NativeListCell.swift b/native-views/react-native-native-list/ios/NativeListCell.swift index 3de870c8..d88e2a60 100644 --- a/native-views/react-native-native-list/ios/NativeListCell.swift +++ b/native-views/react-native-native-list/ios/NativeListCell.swift @@ -548,6 +548,8 @@ final class NativeListCell: UICollectionViewCell { private var checkboxBorderColor = UIColor(nativeListHex: "#CECECE", fallback: .lightGray) private var visualBackdropColor = UIColor.white private var currentLayout = "linear" + // OneKey patch: a row style wrote view properties that reset() does not restore. + private var styledViewsDirty = false private var currentTheme: [String: Any]? private var currentItemIndex: Int? // OneKey patch: delayed image retries belong to the current reusable cell binding. @@ -1006,6 +1008,7 @@ final class NativeListCell: UICollectionViewCell { case "system": bindSystem(item, theme: theme) default: break } + applyRowStyle(item) applySelectorTypography(item) if shouldRestoreHighlight && isUserInteractionEnabled { isHighlighted = true @@ -1188,7 +1191,29 @@ final class NativeListCell: UICollectionViewCell { return color } + /** + docs/STYLE_SPEC.md section 7 rule 2: reset() restores the fonts of the labels + every template uses, but not those of the data, metric and media labels, nor any + text alignment. A style that wrote one of those would leak into the next row that + reuses the cell, so put them back before the binder runs. + */ + private func resetRowStyle() { + guard styledViewsDirty else { return } + styledViewsDirty = false + metricSubtitleLabel.font = nativeListFont(ofSize: 12) + mediaBadgeLabel.font = nativeListFont(ofSize: 14, weight: .medium) + dataLabels.forEach { $0.font = nativeListFont(ofSize: 12) } + let aligned: [UILabel] = [ + subtitleLabel, tertiaryLabel, statusLabel, badgeLabel, metricSubtitleLabel, + ] + aligned.forEach { + $0.textAlignment = .natural + $0.lineBreakMode = .byTruncatingTail + } + } + private func reset() { + resetRowStyle() titleLabel.transform = .identity restoreSelectorTypography() // OneKey patch: remove selector decorations before rebinding recycled cells. @@ -2264,6 +2289,211 @@ final class NativeListCell: UICollectionViewCell { } } + /** + docs/STYLE_SPEC.md section 4. A style key names a model field, and the view pool + is shared, so the same field lands in a different view per template. This table + must stay in step with `nativeListStyleSlot` on Android. + */ + private func styleSlot(type: String, variant: String, field: String) -> String? { + switch type { + case "identity": + return ["title", "subtitle", "tertiary", "badge", "value", "valueSecondary"] + .contains(field) ? field : nil + case "rail": + return ["title", "badge", "status"].contains(field) ? field : nil + case "activity": + switch field { + case "title", "status": return field + case "description": return "subtitle" + case "primaryAmount": return "value" + case "secondaryAmount": return "valueSecondary" + default: return nil + } + case "message": + switch field { + case "title": return "title" + case "body": return "subtitle" + case "time": return "status" + default: return nil + } + case "dataRow": + switch field { + case "columns": return "dataPrimary" + case "index": return "value" + default: return nil + } + case "mediaTile": + switch field { + case "title", "subtitle": return field + case "badge": return "mediaBadge" + default: return nil + } + // The large number and the small label sit in swapped views. + case "metricCard": + switch field { + case "value": return "title" + case "title": return "subtitle" + case "subtitle": return "metricSubtitle" + case "trend": return "status" + default: return nil + } + case "sectionHeader": + return ["title", "subtitle", "value"].contains(field) ? field : nil + case "action": + return ["title", "value"].contains(field) ? field : nil + case "system": + switch field { + case "title": return "title" + // Only the warning variant renders a separate title; every other variant + // puts its message in the title view. + case "message": return variant == "warning" ? "subtitle" : "title" + case "actionText": return "value" + default: return nil + } + default: + return nil + } + } + + /** + docs/STYLE_SPEC.md section 8. Runs after the per-template binder, so it is the + last writer. Market owns its own richer path inside bindMarket. + */ + private func applyRowStyle(_ item: NativeListItem) { + if item.type == "market" { return } + guard let style = item.data.dictionary("style") else { return } + styledViewsDirty = true + if style["horizontalPadding"] != nil { + let inset = CGFloat(style.double("horizontalPadding")) + rootLeadingConstraint.constant = inset + rootTrailingConstraint.constant = -inset + } + if style["verticalPadding"] != nil { + let inset = CGFloat(style.double("verticalPadding")) + rootTopConstraint.constant = inset + rootBottomConstraint.constant = -inset + } + if style["lineGap"] != nil { + mainStack.spacing = CGFloat(style.double("lineGap")) + } + let variant = item.data.string("variant") + for (field, value) in style { + guard let slotStyle = value as? [String: Any], + let slot = styleSlot(type: item.type, variant: variant, field: field) + else { continue } + switch slot { + case "title": applyStyledText(titleLabel, slotStyle) + case "subtitle": applyStyledText(subtitleLabel, slotStyle) + case "tertiary": applyStyledText(tertiaryLabel, slotStyle) + case "status": applyStyledText(statusLabel, slotStyle) + case "metricSubtitle": applyStyledText(metricSubtitleLabel, slotStyle) + case "badge": applyStyledText(badgeLabel, slotStyle) + case "mediaBadge": applyStyledText(mediaBadgeLabel, slotStyle) + case "dataPrimary": dataLabels.forEach { applyStyledText($0, slotStyle) } + case "value": applyStyledButton(accessoryButtons[0], slotStyle) + case "valueSecondary": applyStyledButton(accessoryButtons[1], slotStyle) + default: break + } + } + } + + /** + The binders install an attributed string through setLineHeight, so assigning + `font` or `textColor` alone would not take effect. Rebuild the line box. + */ + private func applyStyledText(_ label: UILabel, _ style: [String: Any]) { + let text = label.attributedText?.string ?? label.text ?? "" + guard !text.isEmpty else { return } + let baseFont = label.font ?? nativeListFont(ofSize: 14) + let size = CGFloat(style.double("fontSize", default: Double(baseFont.pointSize))) + let font: UIFont + if let weightName = style["fontWeight"] as? String { + font = nativeListFont( + ofSize: size, + weight: marketFontWeight(weightName, fallback: .regular) + ) + } else { + font = baseFont.withSize(size) + } + let color = (style["color"] as? String) + .map { UIColor(nativeListHex: $0, fallback: label.textColor ?? .black) } + ?? label.textColor ?? .black + if style["lines"] != nil { + label.numberOfLines = min(2, max(1, style.int("lines", default: 1))) + label.lineBreakMode = .byTruncatingTail + } + if let alignmentName = style["alignment"] as? String { + label.textAlignment = marketTextAlignment(alignmentName) + } + label.font = font + label.textColor = color + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = label.textAlignment + paragraph.lineBreakMode = label.lineBreakMode + var attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraph, + ] + if style["lineHeight"] != nil { + let box = CGFloat(style.double("lineHeight")) + paragraph.minimumLineHeight = box + paragraph.maximumLineHeight = box + // React Native centers font metrics inside an explicit line height. + attributes[.baselineOffset] = max(0, (box - font.lineHeight) / 2) + } + label.attributedText = NSAttributedString(string: text, attributes: attributes) + } + + private func applyStyledButton(_ button: UIButton, _ style: [String: Any]) { + let text = button.attributedTitle(for: .normal)?.string + ?? button.title(for: .normal) + ?? "" + guard !text.isEmpty else { return } + let baseFont = button.titleLabel?.font ?? nativeListFont(ofSize: 14) + let size = CGFloat(style.double("fontSize", default: Double(baseFont.pointSize))) + let font: UIFont + if let weightName = style["fontWeight"] as? String { + font = nativeListFont( + ofSize: size, + weight: marketFontWeight(weightName, fallback: .regular) + ) + } else { + font = baseFont.withSize(size) + } + let fallbackColor = button.titleColor(for: .normal) ?? .black + let color = (style["color"] as? String) + .map { UIColor(nativeListHex: $0, fallback: fallbackColor) } + ?? fallbackColor + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = button.titleLabel?.textAlignment ?? .natural + if let alignmentName = style["alignment"] as? String { + paragraph.alignment = marketTextAlignment(alignmentName) + button.contentHorizontalAlignment = alignmentName == "start" + ? .leading + : alignmentName == "end" ? .trailing : .center + } + var attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraph, + ] + if style["lineHeight"] != nil { + let box = CGFloat(style.double("lineHeight")) + paragraph.minimumLineHeight = box + paragraph.maximumLineHeight = box + attributes[.baselineOffset] = max(0, (box - font.lineHeight) / 2) + } + if style["lines"] != nil { + button.titleLabel?.numberOfLines = min(2, max(1, style.int("lines", default: 1))) + } + button.titleLabel?.font = font + button.setAttributedTitle( + NSAttributedString(string: text, attributes: attributes), + for: .normal + ) + } + private func applyMarketTextStyle( _ label: UILabel, data: [String: Any]?,