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]?,