Skip to content
Closed
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
18 changes: 18 additions & 0 deletions .agent/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,24 @@ shrinking a glyph and dimming it are the same act.
**Measure before hypothesising, replicate before chasing.** Two burst defects were reported from
one run each and both turned out to be in the *other* direction once replicated three times.

**Formatting is now three implementations of one rule, and they have to stay in step.**
`src/numberFormat.ts` resolves the `format` prop; `NumericTextFormatter.kt` and the `FormatSpec`
half of `NumericTextSwiftUIHost.swift` each reproduce it natively. All three implement ECMA-402's
digit-bound rule and round half-away-from-zero, which is `Intl`'s default and neither platform's.
Changing one without the other two makes the two renderers draw different numbers, and the JS
width estimate size a box for a third.

The transition side of that is the affix key in `TransitionLogic`: a currency symbol, a percent
sign and an accounting bracket are keyed by distance from the digits (`P0` inward from the left,
`X0` inward from the right), so they survive the number gaining or losing a digit. Keyed by string
offset, which is what `O$i` did, a `$` dies and is reborn on every carry.

Verified so far: 39 JS unit tests, 25 Kotlin unit tests, `compileDebugKotlin`, `swiftc -typecheck`
in both configurations, a full `xcodebuild` of the example, and both renderers driven by hand on a
simulator through every format. **Not** verified against a recording: no ground-truth run has been
taken with a currency format, so the affix's motion during a carry is reasoned-about rather than
measured. That is the first thing to do if the affix ever looks wrong.

## Next, in order

The drum is **done and kept** and did not do what it was expected to do: it halved the single
Expand Down
19 changes: 16 additions & 3 deletions .agent/tools/subset_font.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
#
# The upstream release is ~710 KB per weight, 6.1 MB for the nine — far too much to ship in a
# library. This view only ever draws a formatted number, so the subset keeps digits, the separators
# and signs that NumberFormat emits for Latin-script locales, and the "NaN"/"∞" it falls back to.
# That lands at ~15 KB per weight, ~130 KB for all nine.
# and signs that NumberFormat emits for Latin-script locales, the currency symbols and Latin
# letters a money format needs, and the "NaN"/"∞" it falls back to. That lands at ~33 KB per
# weight, ~300 KB for all nine.
#
# Locales whose digits are outside this set (ar-EG, hi-IN, …) are handled at runtime, not here:
# NumericTextView checks hasGlyph on the formatted string and falls back to the system typeface
Expand All @@ -27,7 +28,19 @@ UNICODES="$UNICODES,U+0030-0039"
UNICODES="$UNICODES,U+002B,U+002D,U+2212,U+2013"
UNICODES="$UNICODES,U+002C,U+002E,U+0027,U+2019,U+00B7,U+066B,U+066C,U+FF0C,U+FF0E"
UNICODES="$UNICODES,U+0025,U+2030,U+221E"
UNICODES="$UNICODES,U+0045,U+004E,U+0061"

# Money. Currency symbols, the whole currency-signs block (€ ₹ ₩ ₪ ₫ ₺ ₽ ₿ and the rest), the
# fullwidth and Arabic forms, and the brackets an accounting format wraps a negative amount in.
UNICODES="$UNICODES,U+0024,U+00A2-00A5,U+0192,U+058F,U+060B,U+07FE-07FF,U+09F2-09F3,U+09FB"
UNICODES="$UNICODES,U+0AF1,U+0BF9,U+0E3F,U+17DB,U+20A0-20C0,U+A838,U+FDFC,U+FE69,U+FF04"
UNICODES="$UNICODES,U+FFE0-FFE1,U+FFE5-FFE6"
UNICODES="$UNICODES,U+0028,U+0029"

# Letters, for `currencyDisplay: 'code'` (`USD 1,234.56`) and `'name'` (`1,234.56 US dollars`).
# They are the reason this subset is ~33 KB a weight rather than ~11 KB; without them the coverage
# check in NumericTextView falls the whole line back to the platform font, so a caller asking for
# a code or a name would silently lose the rounded face the library exists to provide.
UNICODES="$UNICODES,U+0041-005A,U+0061-007A"

WEIGHTS=(Thin ExtraLight Light Regular Medium SemiBold Bold ExtraBold Black)

Expand Down
143 changes: 127 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ The platform strategy is intentionally asymmetric:

The goal is not to pretend both platforms render text identically. The goal is to give the same React Native component the same class of polished numeric interaction on both platforms.

This project is independent and is not affiliated with Expo or Apple.
The formatting API is a second borrowed idea. [`number-flow`](https://github.com/barvian/number-flow) by Maxwell Barvian solves the same problem on the web, and its answer to "how should a component be told what shape a number takes" is one `format` object shaped like `Intl.NumberFormatOptions` rather than a growing row of flat props. That shape is taken from it directly, including the choice to pass a bound through untouched so `Intl`'s own defaulting rule decides the rest.

The two libraries reach it from opposite ends: `number-flow` renders real text nodes and lets the browser lay them out, so it inherits `Intl` for free; here the string has to be produced natively on each platform, by `NumberFormatter` and `android.icu`, because the renderers animate the structure of a formatted number rather than a string handed to them. The API is the same either way, which is the point of copying it.

This project is independent and is not affiliated with Expo, Apple, or `number-flow`.

## Why numeric text needs its own transition model

Expand Down Expand Up @@ -62,8 +66,11 @@ and it has to keep behaving correctly when updates arrive continuously rather th
- Stable interruption and rapid-retarget behaviour.
- Continuous increment/decrement updates without resetting the whole animation.
- Structural handling of integer digits, fractional digits, grouping separators, decimal separators, and signs.
- Locale-aware native number formatting.
- Configurable grouping and fractional precision.
- Locale-aware native number formatting through an `Intl.NumberFormat`-shaped `format` prop.
- Native currency display: symbol, ISO code, or name, with the accounting sign for negatives.
- Native percent display.
- Configurable grouping, integer padding, fractional precision, and significant digits.
- Identical rounding on iOS, Android, and web.
- Automatic, forced-up, and forced-down transition direction.
- System-aware reduced-motion support.
- Android 12+ hardware blur path.
Expand Down Expand Up @@ -121,14 +128,87 @@ Formatting is resolved before the transition is built; separators are not decora
<NumericText
value={1234.5}
locale="de-DE"
useGrouping
minimumFractionDigits={2}
maximumFractionDigits={2}
format={{ useGrouping: true, minimumFractionDigits: 2, maximumFractionDigits: 2 }}
/>
```

This keeps locale-specific punctuation structurally associated with the digits while the value changes.

The `format` prop is a subset of `Intl.NumberFormatOptions`, and each platform resolves it with its own formatter: `NumberFormatter` on iOS, `android.icu` on Android, `Intl` on web. The string is produced where it is drawn, because the renderer animates the structure of a formatted number rather than a string handed to it ready-made.

The shape of this prop is borrowed from [`number-flow`](https://github.com/barvian/number-flow); see [Origin](#origin).

`format` is an object, so `NumericText` will re-render whenever the parent does unless you keep it stable. Hoist it to module scope or wrap it in `useMemo` if you rely on the memo skipping renders.

### Currency

```tsx
<NumericText value={1234.5} currency="USD" /> // $1,234.50
<NumericText value={1234.5} currency="JPY" /> // ¥1,235
<NumericText value={1234.5} locale="de-DE" currency="EUR" /> // 1.234,50 €
```

`currency` is shorthand for `format={{ style: 'currency', currency }}`. The full form adds how the currency is written and how a negative amount is signed:

```tsx
<NumericText value={1234.5} format={{ style: 'currency', currency: 'USD', currencyDisplay: 'code' }} />
// USD 1,234.50

<NumericText value={1234.5} format={{ style: 'currency', currency: 'USD', currencyDisplay: 'name' }} />
// 1,234.50 US dollars

<NumericText value={-1234.5} format={{ style: 'currency', currency: 'USD', currencySign: 'accounting' }} />
// ($1,234.50)
```

The currency affix takes part in the transition rather than sitting on top of it. It is keyed by its distance from the digits, not by its offset in the string, so `$999` → `$1,000` slides one `$` left instead of destroying it and creating another one, and a trailing `1.234,50 €` keeps its symbol through the same change. Where a locale uses a different decimal mark for money than for plain numbers, the renderers key on the monetary one, so the decimal boundary still holds the fraction digits still.

Fraction digits follow the currency when you do not set them: two for `USD`, none for `JPY`, three for `BHD`.

`currencySign: 'accounting'` applies with `currencyDisplay: 'symbol'`, the one combination both platforms format natively; with `'code'` or `'name'` the standard sign is used. `Intl`'s `currencyDisplay: 'narrowSymbol'` is not offered, because neither platform's native formatter exposes it at the versions this library supports.

### Percent, padding, and significant digits

```tsx
<NumericText value={0.42} format={{ style: 'percent' }} /> // 42%
<NumericText value={9} format={{ minimumIntegerDigits: 2 }} /> // 09
<NumericText value={1234.5} format={{ maximumSignificantDigits: 3 }} /> // 1,230
```

`minimumIntegerDigits` is what holds a clock at `05:09` and stops a counter changing width as it crosses a power of ten. Significant digits take precedence over fraction digits when either bound is set, matching `Intl`.

### Fractions and rounding

A decimal mark is structural, not punctuation. Integer digits keep their identity from the left, fraction digits from the decimal mark, and the mark itself holds still, so `9.99` → `10.00` moves the digits it has to and leaves the rest alone.

Set `minimumFractionDigits` to hold a fixed number of decimals through a change that would otherwise drop one. Without it, `1.50` renders as `1.5` and the fraction columns restructure under a roll that should only have moved digits:

```tsx
<NumericText value={price} format={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }} />
```

### Amount fields, and the decimal you are still typing

`value` is a number, and a number cannot hold `7.`. Someone typing `7`, `.`, `5` produces the values 7, 7, 7.5, so between the second and third keystroke the mark they typed has nowhere to live. `trailingDecimalSeparator` gives it one:

```tsx
const [raw, setRaw] = useState('');

<NumericText
value={Number(raw) || 0}
currency="USD"
trailingDecimalSeparator={raw.endsWith('.')}
/>
```

The mark becomes a real column: the locale's own character, the same font, the same baseline, keyed as `DEC`, and already in place when the first fraction digit is born beside it. It is a no-op once a fraction digit arrives or when the format already prints a mark, so pairing it with `minimumFractionDigits` is safe. It goes after the last digit rather than at the end of the string, so `de-DE` gives `1.234, €` and not `1.234 €,`.

Do not reach for a sibling `<Text>` holding a `.` instead. It cannot be made to line up: this view reserves half an em of headroom for the transition's overspill and centres the number inside it, so the distance from the last digit to the right edge of the view is not fixed, and it moves with the value and with the font.

### Rounding

Rounding is half-away-from-zero on both platforms and on web: `2.5` at zero decimals reads as `3` everywhere. That is `Intl`'s default and neither platform's (`NumberFormatter` and ICU both round half-to-even left alone), so it is set explicitly rather than exposed as an option. Two renderers disagreeing about the number they draw is a bug, not a preference.

## Direction

By default, direction follows the numeric change:
Expand Down Expand Up @@ -162,26 +242,55 @@ During rapid updates, automatic direction is resolved against the value the rend
|---|---|---|---|
| `value` | `number` | required | Number to display. The first render does not animate. |
| `locale` | `string` | `'en-US'` | BCP-47 locale used for native number formatting. |
| `format` | `NumericTextFormat` | `{}` | How to shape the number. See below. |
| `currency` | `string` | none | Shorthand for `format={{ style: 'currency', currency }}`. `format` wins where the two overlap. |
| `trailingDecimalSeparator` | `boolean` | `false` | Draws the decimal mark after the last digit when no fraction digit follows it yet. For amount fields; see above. |
| `direction` | `'automatic' \| 'up' \| 'down'` | `'automatic'` | Direction of the numeric transition. |
| `animationDuration` | `number` | `80` | Android only. Nominal timing input used to scale the native transition; it is not a hard duration clamp. |
| `reduceMotion` | `'system' \| 'always' \| 'never'` | `'system'` | Accessibility behaviour for motion. |
| `useGrouping` | `boolean` | `true` | Enables grouping separators. |
| `minimumFractionDigits` | `number` | `0` | Minimum number of fractional digits. |
| `maximumFractionDigits` | `number` | `3` | Maximum number of fractional digits. |
| `style` | `StyleProp<TextStyle>` | | Text/view style. `fontSize`, `fontWeight`, `fontFamily`, and `color` are forwarded to the native renderer. |
| `testID` | `string` | | React Native test identifier. |
| `useGrouping` | `boolean` | `true` | Shorthand for the same field of `format`. |
| `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. |
| `testID` | `string` | none | React Native test identifier. |

When `fontSize` or `color` are omitted, the native defaults are `48` and black.

### `NumericTextFormat`

A subset of `Intl.NumberFormatOptions`. Every option is resolved by the platform's own formatter, and means the same thing on both.

| Option | Type | Default | Description |
|---|---|---|---|
| `style` | `'decimal' \| 'currency' \| 'percent'` | `'decimal'` | `'currency'` needs `currency` and falls back to `'decimal'` without it. `'percent'` multiplies by 100. |
| `currency` | `string` | none | ISO 4217 code. |
| `currencyDisplay` | `'symbol' \| 'code' \| 'name'` | `'symbol'` | `$1,234.56`, `USD 1,234.56`, `1,234.56 US dollars`. |
| `currencySign` | `'standard' \| 'accounting'` | `'standard'` | `'accounting'` brackets a negative amount. Applies with `currencyDisplay: 'symbol'`. |
| `useGrouping` | `boolean` | `true` | Enables grouping separators. |
| `minimumIntegerDigits` | `number` | none | Pads with leading zeros to at least this width. |
| `minimumFractionDigits` | `number` | style's own | `0` for a plain number, `0` for a percentage, the currency's count for money. |
| `maximumFractionDigits` | `number` | style's own | `3` for a plain number, `0` for a percentage, the currency's count for money. |
| `minimumSignificantDigits` | `number` | none | Takes precedence over the fraction bounds. |
| `maximumSignificantDigits` | `number` | none | Takes precedence over the fraction bounds. |

`Intl` options that are **not** supported, and why:

| Option | Reason |
|---|---|
| `notation: 'compact'` (`1.2K`) | Both platforms can produce it, but from different CLDR vintages, so they would disagree on the string for the same input. |
| `signDisplay` | Android's `NumberFormatter` is API 30; iOS's `NumberFormatter` has no equivalent. |
| `currencyDisplay: 'narrowSymbol'` | Same. |
| `unit`, `unitDisplay`, `roundingIncrement`, `roundingMode` | Same, except `roundingMode`, which is fixed at half-away-from-zero on purpose. |

## Platform behaviour

| Platform | Behaviour |
|---|---|
| iOS 17+ | Native SwiftUI `.contentTransition(.numericText())`. |
| iOS 17+ | Native SwiftUI `.contentTransition(.numericText())`, formatted by `NumberFormatter`. |
| Earlier supported iOS | Native formatting and rendering without the unavailable numeric content transition. |
| Android API 31+ | Native renderer using `RenderNode` and `RenderEffect` for the blur path. |
| Android API 31+ | Native renderer using `RenderNode` and `RenderEffect` for the blur path, formatted by `android.icu`. |
| Android API 24-30 | Same transition model with a temporary software-layer blur path while animating. |
| Web | Correctly formatted static `Text`; numeric transitions are not currently animated. |
| Web | Correctly formatted static `Text` via `Intl`; numeric transitions are not currently animated. |

The minimum Android SDK is **24**.

Expand All @@ -193,7 +302,7 @@ The renderer is built around a small set of invariants:

1. **Typeset the complete formatted line first.** Layout and glyph positions come from the full value before the line is partitioned into logical transition slots.
2. **Keep immutable value rasters.** Outgoing content keeps the pixels that belonged to its original formatted value while incoming content references the new target value.
3. **Use structural identity.** Integer digits are anchored from the left, fractional digits from the decimal boundary, and punctuation receives stable semantic identity.
3. **Use structural identity.** Integer digits are anchored from the left, fractional digits from the decimal boundary, and punctuation receives stable semantic identity. A currency affix, a percent sign and an accounting bracket are keyed by their distance from the digits, so they survive the number growing or losing one.
4. **Preserve history during retriggers.** A new target does not require the previous transition to finish first.
5. **Keep frame work bounded.** Expensive bitmap extraction and per-slot bitmap creation stay out of the normal render/update hot path.
6. **Use analytic motion evaluation.** Native transition state can be evaluated directly for the current frame instead of integrating a simulation with frame-rate-dependent state.
Expand Down Expand Up @@ -235,7 +344,9 @@ Android includes a subset of [Sunghyun Sans](https://github.com/anaclumos/sunghy
/>
```

The bundled Android subset contains Latin-script numeric-formatting glyphs. When a locale requires glyphs unavailable in the bundled face, the renderer falls back to the platform font rather than drawing missing-glyph boxes.
The bundled Android subset contains Latin-script numeric-formatting glyphs: digits, the separators and signs a locale formats with, the currency symbols, and the Latin letters an ISO code or a currency name needs. That is about 33 KB a weight, 300 KB for the nine.

Coverage is checked against the characters the current format will actually draw, not against the locale alone, so a currency symbol or a currency name is part of the question. When any of them is missing from the bundled face, the renderer falls back to the platform font rather than drawing missing-glyph boxes.

The full font license is included at `android/src/main/assets/fonts/OFL.txt`.

Expand Down
Binary file modified android/src/main/assets/fonts/SunghyunSans-Black.ttf
Binary file not shown.
Binary file modified android/src/main/assets/fonts/SunghyunSans-Bold.ttf
Binary file not shown.
Binary file modified android/src/main/assets/fonts/SunghyunSans-ExtraBold.ttf
Binary file not shown.
Binary file modified android/src/main/assets/fonts/SunghyunSans-ExtraLight.ttf
Binary file not shown.
Binary file modified android/src/main/assets/fonts/SunghyunSans-Light.ttf
Binary file not shown.
Binary file modified android/src/main/assets/fonts/SunghyunSans-Medium.ttf
Binary file not shown.
Binary file modified android/src/main/assets/fonts/SunghyunSans-Regular.ttf
Binary file not shown.
Binary file modified android/src/main/assets/fonts/SunghyunSans-SemiBold.ttf
Binary file not shown.
Binary file modified android/src/main/assets/fonts/SunghyunSans-Thin.ttf
Binary file not shown.
Loading