Skip to content
Draft
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextStyle>` | 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.
Expand Down Expand Up @@ -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
<NumericText
value={balance}
currency="USD"
style={{ fontSize: 50, color: '#FFFFFF' }}
fractionColor="#8A9BA8"
/>
// $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
Expand Down
28 changes: 25 additions & 3 deletions android/src/main/java/com/numerictext/NumericTextView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall
private val preparedById = HashMap<Int, PreparedText>(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
Expand All @@ -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. */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ class NumericTextViewManager : SimpleViewManager<NumericTextView>(),
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
Expand All @@ -156,6 +161,7 @@ class NumericTextViewManager : SimpleViewManager<NumericTextView>(),
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
Expand Down Expand Up @@ -201,6 +207,7 @@ class NumericTextViewManager : SimpleViewManager<NumericTextView>(),
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,
Expand Down
10 changes: 10 additions & 0 deletions android/src/main/java/com/numerictext/TransitionLogic.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions android/src/test/java/com/numerictext/FractionSpanTest.kt
Original file line number Diff line number Diff line change
@@ -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:,"))
}
}
64 changes: 61 additions & 3 deletions ios/NumericTextSwiftUIHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,16 @@ 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,
reduceMotion: String,
fontSize: CGFloat,
fontWeight: String,
fontFamily: String?,
textColor: UIColor?
textColor: UIColor?,
fractionColor: UIColor?
) {
let nextText = Self.text(value, formatter: formatter)
let changed = model.text != nextText
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -362,7 +376,7 @@ private struct NumericTextRoot: View {
}

var body: some View {
Text(model.text)
numericText
.font(font)
.monospacedDigit()
.foregroundStyle(model.color)
Expand All @@ -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..<start]))
let tail = String(text[start...])

if #available(iOS 17.0, tvOS 17.0, *) {
return head + Text(tail).foregroundStyle(fractionColor)
}
return head + Text(tail).foregroundColor(fractionColor)
}

/// Where the fraction span begins: the decimal separator, or — when the format carries no
/// fraction digits — the trailing affix after the last digit. Nil when the number is all
/// integer with nothing following it.
///
/// Read from the formatted string rather than from the value, so it lands on the separator the
/// locale actually drew.
fileprivate static func fractionStart(
in text: String,
decimalSeparator: String
) -> 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(
Expand Down
3 changes: 2 additions & 1 deletion ios/NumericTextView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand Down
1 change: 1 addition & 0 deletions src/NumericTextView.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]}
/>
Expand Down
1 change: 1 addition & 0 deletions src/NumericTextViewNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface NativeProps extends ViewProps {
readonly fontWeight?: string;
readonly fontFamily?: string;
readonly textColor?: ColorValue;
readonly fractionColor?: ColorValue;
}

export default codegenNativeComponent<NativeProps>('NumericTextView');
14 changes: 13 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -112,5 +117,12 @@ export type NumericTextProps = NumericTextAccessibilityProps & {
*/
style?: StyleProp<TextStyle>;

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