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; };