From 2200cd782295cba1397673808e4381fe7417417b Mon Sep 17 00:00:00 2001 From: IgnacioNavarro Date: Thu, 20 Aug 2026 20:49:59 +0200 Subject: [PATCH] feat: fraction colour for two-tone amounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A balance is commonly drawn with its decimals dimmed so the figure that matters reads first. `fractionColor` colours the fraction span — the decimal separator, the digits after it, and any trailing affix — leaving the rest in the `style` colour. Omitted, nothing changes. The span is read from the formatted string rather than from the value, so it lands on the separator the locale actually drew, and a format with no fraction digits dims only a trailing affix if there is one. Both renderers keep the transition intact rather than working around it: Android draws the line into one white raster and tints it at composite time, so a colour is a property of the draw and not of the bitmap. The second colour is a second PorterDuffColorFilter chosen per keyed slice. Settled and transitioning frames share one draw path, so both pick it up, and the fraction colour joins the RenderNode cache key because the tint is recorded into the display list. iOS concatenates two Text runs. `.numericText()` is closed, but `Text + Text` is still one Text, so its raster simply carries two coloured runs and the transition is untouched. Two sibling views would not survive: each would rasterise and transition on its own and they would drift apart on any change that moves the decimal point. The key predicate moves to TransitionLogic, next to the encoding it reads, with unit tests over the span it selects. --- README.md | 24 +++++++ .../java/com/numerictext/NumericTextView.kt | 28 +++++++- .../com/numerictext/NumericTextViewManager.kt | 7 ++ .../java/com/numerictext/TransitionLogic.kt | 10 +++ .../java/com/numerictext/FractionSpanTest.kt | 54 ++++++++++++++++ ios/NumericTextSwiftUIHost.swift | 64 ++++++++++++++++++- ios/NumericTextView.mm | 3 +- src/NumericTextView.native.tsx | 1 + src/NumericTextViewNativeComponent.ts | 1 + src/types.ts | 14 +++- 10 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 android/src/test/java/com/numerictext/FractionSpanTest.kt diff --git a/README.md b/README.md index 196bf6a..a610e64 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,7 @@ During rapid updates, automatic direction is resolved against the value the rend | `minimumFractionDigits` | `number` | none | Shorthand for the same field of `format`. | | `maximumFractionDigits` | `number` | none | Shorthand for the same field of `format`. | | `style` | `StyleProp` | none | Text/view style. `fontSize`, `fontWeight`, `fontFamily`, and `color` are forwarded to the native renderer. | +| `fractionColor` | `ColorValue` | none | Second colour for the fraction span. See [Two-colour amounts](#two-colour-amounts). | | `testID` | `string` | none | React Native test identifier. | When `fontSize` or `color` are omitted, the native defaults are `48` and black. @@ -298,6 +299,29 @@ When a value changes again during an active transition, the renderer keeps the r The result is intended for real controls where a user may tap repeatedly, hold a button, or reverse direction while motion is still active. +## Two-colour amounts + +A balance is often drawn with its decimals dimmed, so the figure that matters reads first. `fractionColor` colours the **fraction span** — the decimal separator, the digits after it, and any trailing affix — leaving the rest in the `style` colour. + +```tsx + +// $1,234.56 -> "$1,234" white, ".56" grey +``` + +Omit it and the number is one colour, exactly as before. + +The span is read from the formatted string rather than from the value, so it lands on the separator the locale actually drew — `1.234,56 €` dims `,56 €`. A format with no fraction digits dims only a trailing affix, if there is one. + +Both renderers keep the transition intact rather than working around it: + +- **Android** draws the line into one white raster and tints it at composite time, so a colour is a property of the draw, not of the bitmap. The second colour is a second `PorterDuffColorFilter`, chosen per keyed slice. Settled and transitioning frames go through the same draw path, so both pick it up. +- **iOS** concatenates two `Text` runs. This matters: `.numericText()` is closed — SwiftUI rasterises the text once per value and animates that raster, with nothing per glyph to reach into. But `Text + Text` is still *one* `Text`, so its raster simply carries two coloured runs and the transition is untouched. Two sibling views would not survive; each would rasterise and transition on its own, and they would drift apart on any change that moves the decimal point. + ## Typography ### iOS diff --git a/android/src/main/java/com/numerictext/NumericTextView.kt b/android/src/main/java/com/numerictext/NumericTextView.kt index 696e213..30b583b 100644 --- a/android/src/main/java/com/numerictext/NumericTextView.kt +++ b/android/src/main/java/com/numerictext/NumericTextView.kt @@ -104,6 +104,17 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall private val preparedById = HashMap(16) private val bitmapPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) private var rasterColorFilter = PorterDuffColorFilter(numericTextColor, PorterDuff.Mode.SRC_IN) + + /** + * Optional second tint, for the fraction span. + * + * The raster is drawn white and tinted at composite time, so a colour is a property of the draw + * rather than of the bitmap. A second one therefore costs nothing but choosing a different filter + * per slice — and because every draw goes through [drawRolling], settled and transitioning frames + * pick it up alike. Null draws the whole number in [numericTextColor]. + */ + private var numericFractionColor: Int? = null + private var fractionColorFilter: PorterDuffColorFilter? = null private var lastDesiredWidth = -1 // Render caches @@ -119,6 +130,17 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall recalcTextPaint() } + private fun colorFilterFor(key: String): PorterDuffColorFilter = + if (TransitionLogic.isFractionKey(key)) fractionColorFilter ?: rasterColorFilter + else rasterColorFilter + + internal fun setFractionColor(value: Int) { + if (numericFractionColor == value) return + numericFractionColor = value + fractionColorFilter = PorterDuffColorFilter(value, PorterDuff.Mode.SRC_IN) + invalidate() + } + private fun hHeadroom(): Float = textHeightPx * 0.36f + 4f /** The glyph's vertical middle, signed from its baseline. Negative: ascent is above it. */ @@ -276,7 +298,7 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall ) bitmapPaint.alpha = alpha - bitmapPaint.colorFilter = rasterColorFilter + bitmapPaint.colorFilter = colorFilterFor(sample.key) bitmapPaint.maskFilter = if (sample.stable || sample.blurLengthPx < BLUR_MIN_PX) { null @@ -321,7 +343,7 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall val localBaseline = margin + raster.baseline val cacheKey = - "${paintGeneration}_${numericTextColor}_${sample.rasterId}_${sample.key}_${sample.renderId}_${nodeW}_${nodeH}" + "${paintGeneration}_${numericTextColor}_${numericFractionColor}_${sample.rasterId}_${sample.key}_${sample.renderId}_${nodeW}_${nodeH}" activeGlyphNodeKeys.add(cacheKey) var node = glyphNodeCache[cacheKey] @@ -333,7 +355,7 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall val recording = node.beginRecording() bitmapPaint.alpha = 255 bitmapPaint.maskFilter = null - bitmapPaint.colorFilter = rasterColorFilter + bitmapPaint.colorFilter = colorFilterFor(sample.key) recording.drawBitmap( raster.bitmap, source, diff --git a/android/src/main/java/com/numerictext/NumericTextViewManager.kt b/android/src/main/java/com/numerictext/NumericTextViewManager.kt index 478940f..316e337 100644 --- a/android/src/main/java/com/numerictext/NumericTextViewManager.kt +++ b/android/src/main/java/com/numerictext/NumericTextViewManager.kt @@ -140,6 +140,11 @@ class NumericTextViewManager : SimpleViewManager(), pending(view)?.textColor = color ?: android.graphics.Color.BLACK } + @ReactProp(name = "fractionColor") + override fun setFractionColor(view: NumericTextView?, color: Int?) { + pending(view)?.fractionColor = color + } + override fun onAfterUpdateTransaction(view: NumericTextView) { super.onAfterUpdateTransaction(view) val props = pendingByView.remove(view) ?: return @@ -156,6 +161,7 @@ class NumericTextViewManager : SimpleViewManager(), props.fontWeight?.let(view::setFontWeight) props.fontFamily?.let(view::setFontFamily) props.textColor?.let(view::setTextColor) + props.fractionColor?.let(view::setFractionColor) if (formatChanged) { val transitionValue = finalValue @@ -201,6 +207,7 @@ class NumericTextViewManager : SimpleViewManager(), var fontWeight: String? = null, var fontFamily: String? = null, var textColor: Int? = null, + var fractionColor: Int? = null, ) { fun resolveFormat(base: NumericFormatSpec): NumericFormatSpec = base.copy( locale = locale ?: base.locale, diff --git a/android/src/main/java/com/numerictext/TransitionLogic.kt b/android/src/main/java/com/numerictext/TransitionLogic.kt index c62c56a..42e82e9 100644 --- a/android/src/main/java/com/numerictext/TransitionLogic.kt +++ b/android/src/main/java/com/numerictext/TransitionLogic.kt @@ -37,6 +37,16 @@ object TransitionLogic { val utf16End: Int, ) + /** + * Whether a slot key belongs to the fraction span: the decimal separator (`DEC:.`), the digits + * after it (`F0`, `F1`, ...) and any trailing affix (`X0`, ...). + * + * This is the span [NumericTextView] tints with `fractionColor`. It lives here because it reads + * the key encoding [layoutKeyedSlots] assigns. + */ + internal fun isFractionKey(key: String): Boolean = + key.startsWith("F") || key.startsWith("DEC") || key.startsWith("X") + /** * Integer digits keep logical identity from the left; fractions from the decimal point. Affix * identity, however, is derived from shaped visual geometry rather than logical string order. diff --git a/android/src/test/java/com/numerictext/FractionSpanTest.kt b/android/src/test/java/com/numerictext/FractionSpanTest.kt new file mode 100644 index 0000000..54d110c --- /dev/null +++ b/android/src/test/java/com/numerictext/FractionSpanTest.kt @@ -0,0 +1,54 @@ +package com.numerictext + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The span `fractionColor` tints, read off the keys `layoutKeyedSlots` assigns. */ +class FractionSpanTest { + + private fun line(text: String): TextLineGeometry = + TextLineGeometry( + text = text, + totalWidth = text.length.toFloat(), + horizontals = FloatArray(text.length + 1) { it.toFloat() }, + layout = null, + horizontalOrigin = 0f, + ) + + private fun fractionSpan(text: String): String = + TransitionLogic.layoutKeyedSlots(text, ',', '.', '-', line(text)) + .filter { TransitionLogic.isFractionKey(it.key) } + .joinToString("") { it.char } + + @Test + fun `tints the separator and the digits after it`() { + assertEquals(".56", fractionSpan("1,234.56")) + } + + @Test + fun `leaves the integer part alone`() { + assertEquals("", fractionSpan("1,234")) + } + + @Test + fun `includes a trailing affix`() { + assertTrue(fractionSpan("1,234.56 kr").endsWith("kr")) + } + + @Test + fun `does not tint a leading currency symbol`() { + assertFalse(fractionSpan("$1,234.56").contains("$")) + } + + @Test + fun `classifies keys directly`() { + assertTrue(TransitionLogic.isFractionKey("F0")) + assertTrue(TransitionLogic.isFractionKey("DEC:.")) + assertTrue(TransitionLogic.isFractionKey("X0")) + assertFalse(TransitionLogic.isFractionKey("I0")) + assertFalse(TransitionLogic.isFractionKey("P0")) + assertFalse(TransitionLogic.isFractionKey("G3:,")) + } +} diff --git a/ios/NumericTextSwiftUIHost.swift b/ios/NumericTextSwiftUIHost.swift index 922fea5..2b01253 100644 --- a/ios/NumericTextSwiftUIHost.swift +++ b/ios/NumericTextSwiftUIHost.swift @@ -100,7 +100,7 @@ public final class NumericTextSwiftUIHost: UIView { } // swiftlint:disable:next function_parameter_count - @objc(applyValue:direction:reduceMotion:fontSize:fontWeight:fontFamily:textColor:) + @objc(applyValue:direction:reduceMotion:fontSize:fontWeight:fontFamily:textColor:fractionColor:) public func apply( value: Double, direction: String, @@ -108,7 +108,8 @@ public final class NumericTextSwiftUIHost: UIView { fontSize: CGFloat, fontWeight: String, fontFamily: String?, - textColor: UIColor? + textColor: UIColor?, + fractionColor: UIColor? ) { let nextText = Self.text(value, formatter: formatter) let changed = model.text != nextText @@ -117,6 +118,8 @@ public final class NumericTextSwiftUIHost: UIView { model.weight = Self.weight(from: fontWeight) model.fontFamily = fontFamily model.color = textColor.map(Color.init(uiColor:)) ?? .black + model.fractionColor = fractionColor.map(Color.init(uiColor:)) + model.decimalSeparator = Self.decimalSeparator(of: formatter) model.countsDown = Self.countsDown( direction: direction, @@ -220,6 +223,14 @@ public final class NumericTextSwiftUIHost: UIView { formatter.string(from: NSNumber(value: value)) ?? String(value) } + private static func decimalSeparator(of formatter: NumberFormatter) -> String { + let separator = + formatter.numberStyle == .currency + ? formatter.currencyDecimalSeparator + : formatter.decimalSeparator + return separator ?? "." + } + private static func makeFormatter(_ spec: FormatSpec) -> NumberFormatter { let formatter = NumberFormatter() formatter.locale = Locale(identifier: spec.locale.isEmpty ? "en-US" : spec.locale) @@ -320,6 +331,9 @@ private final class NumericTextModel: ObservableObject { @Published var weight: Font.Weight = .regular @Published var fontFamily: String? @Published var color: Color = .black + @Published var fractionColor: Color? + /// The separator the current format draws, so the fraction span can be found in `text`. + @Published var decimalSeparator: String = "." } private struct NumericTextRoot: View { @@ -362,7 +376,7 @@ private struct NumericTextRoot: View { } var body: some View { - Text(model.text) + numericText .font(font) .monospacedDigit() .foregroundStyle(model.color) @@ -373,6 +387,50 @@ private struct NumericTextRoot: View { .mask(edgeFadeMask) } + /// The number as a single `Text`, in one colour or two. + /// + /// Concatenation matters here. `.numericText()` is closed — SwiftUI rasterises the + /// text once per value and animates that raster, with nothing per glyph to reach + /// into. But `Text + Text` is still *one* `Text`, so the raster it makes simply has + /// two coloured runs in it and the transition is unaffected. Splitting the number + /// into two sibling views would not survive: each would rasterise and transition on + /// its own, and they would drift apart on any change that moves the decimal point. + private var numericText: Text { + let text = model.text + guard let fractionColor = model.fractionColor, + let start = Self.fractionStart(in: text, decimalSeparator: model.decimalSeparator) + else { + return Text(text) + } + + let head = Text(String(text[text.startIndex.. String.Index? { + if let range = text.range(of: decimalSeparator, options: .backwards) { + return range.lowerBound + } + + guard let lastDigit = text.lastIndex(where: { $0.isNumber }) else { return nil } + let afterDigits = text.index(after: lastDigit) + return afterDigits < text.endIndex ? afterDigits : nil + } + private var edgeFadeMask: some View { VStack(spacing: 0) { LinearGradient( diff --git a/ios/NumericTextView.mm b/ios/NumericTextView.mm index aab41d9..82cfe9a 100644 --- a/ios/NumericTextView.mm +++ b/ios/NumericTextView.mm @@ -75,7 +75,8 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & fontSize:next.fontSize fontWeight:RCTNSStringFromString(next.fontWeight) fontFamily:RCTNSStringFromString(next.fontFamily) - textColor:RCTUIColorFromSharedColor(next.textColor)]; + textColor:RCTUIColorFromSharedColor(next.textColor) + fractionColor:RCTUIColorFromSharedColor(next.fractionColor)]; [super updateProps:props oldProps:oldProps]; } diff --git a/src/NumericTextView.native.tsx b/src/NumericTextView.native.tsx index 5d6969d..105fd58 100644 --- a/src/NumericTextView.native.tsx +++ b/src/NumericTextView.native.tsx @@ -50,6 +50,7 @@ function NumericTextViewImpl(props: NumericTextProps) { fontWeight={text.fontWeight} fontFamily={text.fontFamily} textColor={text.textColor} + fractionColor={props.fractionColor} testID={testID} style={[style, box]} /> diff --git a/src/NumericTextViewNativeComponent.ts b/src/NumericTextViewNativeComponent.ts index 059c514..47dc779 100644 --- a/src/NumericTextViewNativeComponent.ts +++ b/src/NumericTextViewNativeComponent.ts @@ -30,6 +30,7 @@ interface NativeProps extends ViewProps { readonly fontWeight?: string; readonly fontFamily?: string; readonly textColor?: ColorValue; + readonly fractionColor?: ColorValue; } export default codegenNativeComponent('NumericTextView'); diff --git a/src/types.ts b/src/types.ts index f36ad86..3e96ad4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,9 @@ -import type { AccessibilityProps, StyleProp, TextStyle } from 'react-native'; +import type { + AccessibilityProps, + ColorValue, + StyleProp, + TextStyle, +} from 'react-native'; /** * How to turn the value into the number that is drawn: a deliberately small subset of @@ -112,5 +117,12 @@ export type NumericTextProps = NumericTextAccessibilityProps & { */ style?: StyleProp; + /** + * Draws the fraction span — the decimal separator, the digits after it, and any + * trailing affix — in this colour, leaving the rest of the number in the `style` + * colour. Omitted, the whole number is one colour. + */ + fractionColor?: ColorValue; + testID?: string; };