diff --git a/.agent/NEXT.md b/.agent/NEXT.md index acc47f7..9f17b0e 100644 --- a/.agent/NEXT.md +++ b/.agent/NEXT.md @@ -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 diff --git a/.agent/tools/subset_font.sh b/.agent/tools/subset_font.sh index 73b2850..ee516d9 100755 --- a/.agent/tools/subset_font.sh +++ b/.agent/tools/subset_font.sh @@ -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 @@ -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) diff --git a/README.md b/README.md index 6763434..acca0f9 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -121,14 +128,87 @@ Formatting is resolved before the transition is built; separators are not decora ``` 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 + // $1,234.50 + // ¥1,235 + // 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 + +// USD 1,234.50 + + +// 1,234.50 US dollars + + +// ($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 + // 42% + // 09 + // 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 + +``` + +### 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(''); + + +``` + +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 `` 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: @@ -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` | — | 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` | 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**. @@ -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. @@ -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`. diff --git a/android/src/main/assets/fonts/SunghyunSans-Black.ttf b/android/src/main/assets/fonts/SunghyunSans-Black.ttf index 1b36326..424db24 100644 Binary files a/android/src/main/assets/fonts/SunghyunSans-Black.ttf and b/android/src/main/assets/fonts/SunghyunSans-Black.ttf differ diff --git a/android/src/main/assets/fonts/SunghyunSans-Bold.ttf b/android/src/main/assets/fonts/SunghyunSans-Bold.ttf index 3ec3357..cae5c9a 100644 Binary files a/android/src/main/assets/fonts/SunghyunSans-Bold.ttf and b/android/src/main/assets/fonts/SunghyunSans-Bold.ttf differ diff --git a/android/src/main/assets/fonts/SunghyunSans-ExtraBold.ttf b/android/src/main/assets/fonts/SunghyunSans-ExtraBold.ttf index 5cf3656..b78b887 100644 Binary files a/android/src/main/assets/fonts/SunghyunSans-ExtraBold.ttf and b/android/src/main/assets/fonts/SunghyunSans-ExtraBold.ttf differ diff --git a/android/src/main/assets/fonts/SunghyunSans-ExtraLight.ttf b/android/src/main/assets/fonts/SunghyunSans-ExtraLight.ttf index 26bb0e4..a2a7015 100644 Binary files a/android/src/main/assets/fonts/SunghyunSans-ExtraLight.ttf and b/android/src/main/assets/fonts/SunghyunSans-ExtraLight.ttf differ diff --git a/android/src/main/assets/fonts/SunghyunSans-Light.ttf b/android/src/main/assets/fonts/SunghyunSans-Light.ttf index 62c3857..6010a5e 100644 Binary files a/android/src/main/assets/fonts/SunghyunSans-Light.ttf and b/android/src/main/assets/fonts/SunghyunSans-Light.ttf differ diff --git a/android/src/main/assets/fonts/SunghyunSans-Medium.ttf b/android/src/main/assets/fonts/SunghyunSans-Medium.ttf index e960092..9de169f 100644 Binary files a/android/src/main/assets/fonts/SunghyunSans-Medium.ttf and b/android/src/main/assets/fonts/SunghyunSans-Medium.ttf differ diff --git a/android/src/main/assets/fonts/SunghyunSans-Regular.ttf b/android/src/main/assets/fonts/SunghyunSans-Regular.ttf index 4c70990..189e28e 100644 Binary files a/android/src/main/assets/fonts/SunghyunSans-Regular.ttf and b/android/src/main/assets/fonts/SunghyunSans-Regular.ttf differ diff --git a/android/src/main/assets/fonts/SunghyunSans-SemiBold.ttf b/android/src/main/assets/fonts/SunghyunSans-SemiBold.ttf index 2a33b8f..ce7f517 100644 Binary files a/android/src/main/assets/fonts/SunghyunSans-SemiBold.ttf and b/android/src/main/assets/fonts/SunghyunSans-SemiBold.ttf differ diff --git a/android/src/main/assets/fonts/SunghyunSans-Thin.ttf b/android/src/main/assets/fonts/SunghyunSans-Thin.ttf index f2bb8b2..fc69e62 100644 Binary files a/android/src/main/assets/fonts/SunghyunSans-Thin.ttf and b/android/src/main/assets/fonts/SunghyunSans-Thin.ttf differ diff --git a/android/src/main/java/com/numerictext/NumericTextFormatter.kt b/android/src/main/java/com/numerictext/NumericTextFormatter.kt new file mode 100644 index 0000000..665ed8f --- /dev/null +++ b/android/src/main/java/com/numerictext/NumericTextFormatter.kt @@ -0,0 +1,237 @@ +package com.numerictext + +import android.icu.math.BigDecimal +import android.icu.text.DecimalFormat +import android.icu.text.DecimalFormatSymbols +import android.icu.text.NumberFormat +import android.icu.util.Currency +import java.util.Locale + +/** A digit bound the caller left out, so the format applies its own default. */ +const val DIGITS_UNSET = -1 + +/** + * The formatting props, exactly as `src/numberFormat.ts` resolved them. + * + * They arrive one at a time from the view manager, so the spec is a value the view can copy with + * one field changed and hand back to [NumericTextFormatter.of]. + */ +internal data class NumericFormatSpec( + val locale: String = "en-US", + val numberStyle: String = "decimal", + val currency: String = "", + val currencyDisplay: String = "symbol", + val currencySign: String = "standard", + val useGrouping: Boolean = true, + val minimumIntegerDigits: Int = DIGITS_UNSET, + val minimumFractionDigits: Int = DIGITS_UNSET, + val maximumFractionDigits: Int = DIGITS_UNSET, + val minimumSignificantDigits: Int = DIGITS_UNSET, + val maximumSignificantDigits: Int = DIGITS_UNSET, + val trailingDecimalSeparator: Boolean = false, +) + +/** + * Formats the number, and reports the marks the transition has to key on. + * + * `android.icu` rather than `java.text` throughout. The ISO-code, currency-name and accounting + * forms are ICU styles with no `java.text` equivalent at this library's minimum SDK, and mixing + * the two packages would leave the separators read from one formatter and the string produced by + * another. Android's `java.text` delegates here anyway, so the plain decimal case is unchanged. + * + * The separators matter as much as the string. `TransitionLogic` splits the formatted line on + * them, and a currency format may use the locale's *monetary* decimal mark rather than its + * ordinary one; keying on the wrong character turns the fraction digits into unkeyed punctuation + * and the decimal boundary stops holding still under a roll. + */ +internal class NumericTextFormatter private constructor( + private val format: NumberFormat, + private val trailing: Boolean, + val groupingSeparator: Char, + val decimalSeparator: Char, + val minusSign: Char, + /** + * Every character this formatter can put on screen, for the bundled font's coverage check. It + * is produced by formatting rather than by listing, so a currency symbol, an ISO code's letters + * and an accounting form's parentheses are all included without enumerating them. + */ + val glyphProbe: String, +) { + + /** + * The formatted number, with the decimal mark held after the last digit when the caller asked + * for it and the format produced none. + * + * After the last *digit*, not at the end of the string: `de-DE` writes `1.234 €`, and a mark + * appended blindly would land beyond the currency symbol. + */ + fun format(value: Double): String { + val text = format.format(value) + if (!trailing || text.indexOf(decimalSeparator) >= 0) return text + + var end = -1 + var i = 0 + while (i < text.length) { + val cp = text.codePointAt(i) + val width = Character.charCount(cp) + if (Character.isDigit(cp)) end = i + width + i += width + } + return if (end < 0) text + decimalSeparator + else text.substring(0, end) + decimalSeparator + text.substring(end) + } + + companion object { + fun of(spec: NumericFormatSpec): NumericTextFormatter { + val locale = localeOf(spec.locale) + val currency = currencyOf(spec.currency) + val money = spec.numberStyle == "currency" && currency != null + + val format = NumberFormat.getInstance(locale, styleOf(spec, money)) + if (money) format.currency = currency + format.isGroupingUsed = spec.useGrouping + + // Intl rounds halves away from zero; ICU and Foundation both default to half-even. Follow + // Intl, so `2.5` at zero decimals reads as `3` on Android, on iOS, and on the web fallback + // rather than as `3`, `2`, `2`. + format.roundingMode = BigDecimal.ROUND_HALF_UP + + if (spec.minimumIntegerDigits >= 0) { + format.minimumIntegerDigits = spec.minimumIntegerDigits + } + applyDigitBounds(format, spec, money, currency) + + val symbols = symbolsOf(format, locale) + return NumericTextFormatter( + format = format, + trailing = spec.trailingDecimalSeparator, + groupingSeparator = + if (money) symbols.monetaryGroupingSeparator else symbols.groupingSeparator, + decimalSeparator = + if (money) symbols.monetaryDecimalSeparator else symbols.decimalSeparator, + minusSign = symbols.minusSign, + glyphProbe = glyphProbeOf(format, symbols), + ) + } + + private fun styleOf(spec: NumericFormatSpec, money: Boolean): Int = when { + !money -> + if (spec.numberStyle == "percent") NumberFormat.PERCENTSTYLE + else NumberFormat.NUMBERSTYLE + spec.currencyDisplay == "code" -> NumberFormat.ISOCURRENCYSTYLE + spec.currencyDisplay == "name" -> NumberFormat.PLURALCURRENCYSTYLE + // Accounting is a distinct CLDR pattern, so it is a style rather than a modifier, and it + // only exists alongside the symbol. `code` and `name` above therefore win over it. + spec.currencySign == "accounting" -> NumberFormat.ACCOUNTINGCURRENCYSTYLE + else -> NumberFormat.CURRENCYSTYLE + } + + /** + * ECMA-402's rule for resolving digit bounds, so the three implementations of this component + * round the same number to the same string. + * + * Significant digits win over fraction digits when either bound is given. Otherwise a bound + * that was left out is filled from the style: 0 and 3 for a plain number, 0 and 0 for a + * percentage, and the currency's own count twice over for money, which is what makes `USD` + * show `1.50` and `JPY` show `150` without either being asked for. + */ + private fun applyDigitBounds( + format: NumberFormat, + spec: NumericFormatSpec, + money: Boolean, + currency: Currency?, + ) { + val decimal = format as? DecimalFormat + if ( + decimal != null && + (spec.minimumSignificantDigits >= 0 || spec.maximumSignificantDigits >= 0) + ) { + val (min, max) = + boundsOf(spec.minimumSignificantDigits, spec.maximumSignificantDigits, 1, 21) + decimal.setSignificantDigitsUsed(true) + decimal.maximumSignificantDigits = max + decimal.minimumSignificantDigits = min + return + } + + val defaultMax = when { + money -> (currency?.defaultFractionDigits ?: 2).coerceAtLeast(0) + spec.numberStyle == "percent" -> 0 + else -> 3 + } + // A plain number is the only style that may drop a trailing zero, so it is the only one + // whose minimum differs from its maximum. + val defaultMin = if (money || spec.numberStyle == "percent") defaultMax else 0 + + val (min, max) = + boundsOf( + spec.minimumFractionDigits, + spec.maximumFractionDigits, + defaultMin, + defaultMax, + ) + + // Maximum first: ICU pulls the minimum down to meet a lower maximum, and the pair here is + // already ordered, so setting it this way round never disturbs the other bound. + format.maximumFractionDigits = max + format.minimumFractionDigits = min + } + + /** + * A given bound with the other one filled in, never crossed. + * + * `Intl` throws when a caller gives a maximum below a minimum. This clamps instead: a number + * with one decimal more than was asked for beats a formatter that refuses to draw. + */ + private fun boundsOf( + min: Int, + max: Int, + defaultMin: Int, + defaultMax: Int, + ): Pair = when { + min >= 0 && max >= 0 -> min to maxOf(min, max) + min >= 0 -> min to maxOf(defaultMax, min) + max >= 0 -> minOf(defaultMin, max) to max + else -> defaultMin to defaultMax + } + + /** The formatter's own symbols where it has them, so the marks belong to the string drawn. */ + private fun symbolsOf(format: NumberFormat, locale: Locale): DecimalFormatSymbols = + (format as? DecimalFormat)?.decimalFormatSymbols + ?: DecimalFormatSymbols.getInstance(locale) + + private fun glyphProbeOf( + format: NumberFormat, + symbols: DecimalFormatSymbols, + ): String = buildString { + val zero = symbols.zeroDigit + for (offset in 0..9) append(zero + offset) + append(symbols.groupingSeparator) + append(symbols.decimalSeparator) + append(symbols.monetaryGroupingSeparator) + append(symbols.monetaryDecimalSeparator) + append(symbols.minusSign) + // A negative and a zero between them carry the sign, the affix and any accounting bracket. + append(runCatching { format.format(-1234.5) }.getOrDefault("")) + append(runCatching { format.format(0.0) }.getOrDefault("")) + } + + private fun currencyOf(code: String): Currency? { + if (code.isEmpty()) return null + return try { + Currency.getInstance(code) + } catch (_: IllegalArgumentException) { + null + } catch (_: NullPointerException) { + null + } + } + + private fun localeOf(tag: String): Locale = try { + Locale.forLanguageTag(tag.replace("_", "-")).takeIf { it.language.isNotEmpty() } + ?: Locale.US + } catch (_: Exception) { + Locale.US + } + } +} diff --git a/android/src/main/java/com/numerictext/NumericTextView.kt b/android/src/main/java/com/numerictext/NumericTextView.kt index f2392d6..f4a80ec 100644 --- a/android/src/main/java/com/numerictext/NumericTextView.kt +++ b/android/src/main/java/com/numerictext/NumericTextView.kt @@ -21,9 +21,6 @@ import android.text.TextPaint import android.view.Choreographer import android.view.View import com.facebook.react.common.assets.ReactFontManager -import java.text.DecimalFormatSymbols -import java.text.NumberFormat -import java.util.Locale import kotlin.math.ceil import kotlin.math.floor import kotlin.math.max @@ -51,12 +48,11 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall private var targetText: String = "0" private var hasSettledOnce = false - var numericLocale: String = "en-US"; private set + /** Everything about the shape of the number, as `src/numberFormat.ts` resolved it. */ + private var formatSpec = NumericFormatSpec() + var numericDirection: String = "automatic"; private set var animationDurationMs: Long = 320L; private set - var numericUseGrouping: Boolean = true; private set - var numericMinFractionDigits: Int = 0; private set - var numericMaxFractionDigits: Int = 3; private set var numericReduceMotion: String = "system"; private set var numericFontSize: Float = 48f; private set var numericFontWeight: String = "normal"; private set @@ -86,6 +82,17 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall val minus: Char, ) + /** + * Two formats can key the same characters differently: a comma is a grouping mark in `en-US` + * and a decimal mark in `de-DE`. The marks in force are therefore part of the cache key. + */ + private fun preparedKeyFor(text: String) = PreparedKey( + text, + formatter.groupingSeparator, + formatter.decimalSeparator, + formatter.minusSign, + ) + private data class PreparedText( val layout: List, val raster: NumericTextRaster, @@ -103,16 +110,11 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall private val gaussianEffectCache = HashMap(24) // Formatter - private var formatter: NumberFormat? = null - private var currentFormatterLocale: Locale? = null - private var currentGroupSep: Char = ',' - private var currentDecimalSep: Char = '.' - private var currentMinusSign: Char = '-' + private var formatter: NumericTextFormatter = NumericTextFormatter.of(formatSpec) init { clipToOutline = true recalcTextPaint() - recalcFormatter() } private fun hHeadroom(): Float = textHeightPx * 0.36f + 4f @@ -144,7 +146,6 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall override fun onAttachedToWindow() { super.onAttachedToWindow() NumericTextFrameRecorder.configure(this) - recalcFormatter() if (engine.isRunning) { beginAnimationRenderPath() postFrame() @@ -519,38 +520,31 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall } } + /** + * Rebuilds the formatter after a formatting prop changed, and re-checks the typeface with it. + * + * The second half is what a currency needs. The bundled face is a subset, and which glyphs the + * view is about to need depends on the format as much as on the locale: `USD` in `en-US` wants + * `$`, `currencyDisplay: 'name'` wants a run of letters, and the accounting form wants brackets. + * Re-resolving the typeface here keeps the fallback to the system font a decision about the + * characters that will actually be drawn. It is compared before it is applied because + * `recalcTextPaint` throws away every cached raster, which is far too expensive to do on a prop + * update that did not change the answer. + */ private fun recalcFormatter() { - formatter = null - currentFormatterLocale = null - } - - private fun formatNumber(value: Double): String { - val locale = resolveLocale() - if (formatter == null || currentFormatterLocale != locale) { - formatter = NumberFormat.getNumberInstance(locale) - currentFormatterLocale = locale - val symbols = DecimalFormatSymbols.getInstance(locale) - currentGroupSep = symbols.groupingSeparator - currentDecimalSep = symbols.decimalSeparator - currentMinusSign = symbols.minusSign - } - formatter?.let { - it.isGroupingUsed = numericUseGrouping - it.minimumFractionDigits = numericMinFractionDigits - it.maximumFractionDigits = numericMaxFractionDigits - } - return formatter?.format(value) ?: value.toString() + formatter = NumericTextFormatter.of(formatSpec) + if (textPaint.typeface != resolveTypeface()) recalcTextPaint() } - private fun resolveLocale(): Locale = try { - Locale.forLanguageTag(numericLocale.replace("_", "-")) - } catch (_: Exception) { - val parts = numericLocale.split("-", "_") - when (parts.size) { - 1 -> Locale(parts[0]) - 2 -> Locale(parts[0], parts[1]) - else -> Locale.US - } + private fun formatNumber(value: Double): String = formatter.format(value) + + /** Applies [change] to the formatting props, and reformats if it changed anything. */ + private fun updateFormat(change: (NumericFormatSpec) -> NumericFormatSpec) { + val next = change(formatSpec) + if (next == formatSpec) return + formatSpec = next + recalcFormatter() + reformatAtRest() } private fun recalcTextPaint() { @@ -578,15 +572,15 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall } private fun preparedTextOf(text: String): PreparedText { - val key = PreparedKey(text, currentGroupSep, currentDecimalSep, currentMinusSign) + val key = preparedKeyFor(text) preparedByKey[key]?.let { return it } val line = lineGeometryOf(text) val layout = TransitionLogic.layoutKeyedSlots( text, - currentGroupSep, - currentDecimalSep, - currentMinusSign, + formatter.groupingSeparator, + formatter.decimalSeparator, + formatter.minusSign, line, ) val raster = NumericTextRasterizer.rasterize( @@ -655,22 +649,7 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall } val bundled = NumericTextFonts.bundled(context.assets, weight) ?: return system val probe = TextPaint(textPaint).apply { typeface = bundled } - return if (NumericTextFonts.canRender(probe, localeGlyphProbe())) bundled else system - } - - private fun localeGlyphProbe(): String { - val symbols = try { - DecimalFormatSymbols.getInstance(resolveLocale()) - } catch (_: Exception) { - return "0123456789,.-" - } - val zero = symbols.zeroDigit - return buildString { - for (i in 0..9) append(zero + i) - append(symbols.groupingSeparator) - append(symbols.decimalSeparator) - append(symbols.minusSign) - } + return if (NumericTextFonts.canRender(probe, formatter.glyphProbe)) bundled else system } private fun baselineY(centerY: Float): Float = centerY + textHeightPx / 2f - fmDescent @@ -698,33 +677,30 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall fun setAnimationDuration(value: Double) { animationDurationMs = value.toLong().coerceAtLeast(80L) } fun setReduceMotion(value: String) { numericReduceMotion = value } - fun setLocale(value: String) { - if (value == numericLocale) return - numericLocale = value - recalcFormatter() - reformatAtRest() - } + fun setLocale(value: String) = updateFormat { it.copy(locale = value) } + fun setNumberStyle(value: String) = updateFormat { it.copy(numberStyle = value) } + fun setCurrency(value: String) = updateFormat { it.copy(currency = value) } + fun setCurrencyDisplay(value: String) = updateFormat { it.copy(currencyDisplay = value) } + fun setCurrencySign(value: String) = updateFormat { it.copy(currencySign = value) } + fun setUseGrouping(value: Boolean) = updateFormat { it.copy(useGrouping = value) } - fun setUseGrouping(value: Boolean) { - if (value == numericUseGrouping) return - numericUseGrouping = value - recalcFormatter() - reformatAtRest() - } + fun setTrailingDecimalSeparator(value: Boolean) = + updateFormat { it.copy(trailingDecimalSeparator = value) } - fun setMinimumFractionDigits(value: Int) { - if (value == numericMinFractionDigits) return - numericMinFractionDigits = value - recalcFormatter() - reformatAtRest() - } + fun setMinimumIntegerDigits(value: Int) = + updateFormat { it.copy(minimumIntegerDigits = value) } - fun setMaximumFractionDigits(value: Int) { - if (value == numericMaxFractionDigits) return - numericMaxFractionDigits = value - recalcFormatter() - reformatAtRest() - } + fun setMinimumFractionDigits(value: Int) = + updateFormat { it.copy(minimumFractionDigits = value) } + + fun setMaximumFractionDigits(value: Int) = + updateFormat { it.copy(maximumFractionDigits = value) } + + fun setMinimumSignificantDigits(value: Int) = + updateFormat { it.copy(minimumSignificantDigits = value) } + + fun setMaximumSignificantDigits(value: Int) = + updateFormat { it.copy(maximumSignificantDigits = value) } private fun reformatAtRest() { if (engine.isRunning) { diff --git a/android/src/main/java/com/numerictext/NumericTextViewManager.kt b/android/src/main/java/com/numerictext/NumericTextViewManager.kt index a1850b6..3ae6b7f 100644 --- a/android/src/main/java/com/numerictext/NumericTextViewManager.kt +++ b/android/src/main/java/com/numerictext/NumericTextViewManager.kt @@ -45,11 +45,46 @@ class NumericTextViewManager : SimpleViewManager(), view?.setAnimationDuration(value) } + @ReactProp(name = "reduceMotion") + override fun setReduceMotion(view: NumericTextView?, mode: String?) { + view?.setReduceMotion(mode ?: "system") + } + + @ReactProp(name = "numberStyle") + override fun setNumberStyle(view: NumericTextView?, value: String?) { + view?.setNumberStyle(value ?: "decimal") + } + + @ReactProp(name = "currency") + override fun setCurrency(view: NumericTextView?, value: String?) { + view?.setCurrency(value ?: "") + } + + @ReactProp(name = "currencyDisplay") + override fun setCurrencyDisplay(view: NumericTextView?, value: String?) { + view?.setCurrencyDisplay(value ?: "symbol") + } + + @ReactProp(name = "currencySign") + override fun setCurrencySign(view: NumericTextView?, value: String?) { + view?.setCurrencySign(value ?: "standard") + } + @ReactProp(name = "useGrouping") override fun setUseGrouping(view: NumericTextView?, value: Boolean) { view?.setUseGrouping(value) } + @ReactProp(name = "trailingDecimalSeparator") + override fun setTrailingDecimalSeparator(view: NumericTextView?, value: Boolean) { + view?.setTrailingDecimalSeparator(value) + } + + @ReactProp(name = "minimumIntegerDigits") + override fun setMinimumIntegerDigits(view: NumericTextView?, value: Int) { + view?.setMinimumIntegerDigits(value) + } + @ReactProp(name = "minimumFractionDigits") override fun setMinimumFractionDigits(view: NumericTextView?, value: Int) { view?.setMinimumFractionDigits(value) @@ -60,9 +95,14 @@ class NumericTextViewManager : SimpleViewManager(), view?.setMaximumFractionDigits(value) } - @ReactProp(name = "reduceMotion") - override fun setReduceMotion(view: NumericTextView?, mode: String?) { - view?.setReduceMotion(mode ?: "system") + @ReactProp(name = "minimumSignificantDigits") + override fun setMinimumSignificantDigits(view: NumericTextView?, value: Int) { + view?.setMinimumSignificantDigits(value) + } + + @ReactProp(name = "maximumSignificantDigits") + override fun setMaximumSignificantDigits(view: NumericTextView?, value: Int) { + view?.setMaximumSignificantDigits(value) } @ReactProp(name = "fontSize") diff --git a/android/src/main/java/com/numerictext/TransitionLogic.kt b/android/src/main/java/com/numerictext/TransitionLogic.kt index 42f6082..1ad9432 100644 --- a/android/src/main/java/com/numerictext/TransitionLogic.kt +++ b/android/src/main/java/com/numerictext/TransitionLogic.kt @@ -28,7 +28,15 @@ object TransitionLogic { val utf16End: Int, ) - /** Integer digits keep visual identity from the left; fractions from the decimal point. */ + /** + * Integer digits keep visual identity from the left; fractions from the decimal point; anything + * outside the number keeps it from whichever end of the number it sits against. + * + * That last rule is what a currency needs. A symbol, an ISO code, a percent sign or an + * accounting bracket is keyed by its distance from the digits rather than by its offset in the + * string, so `$999` -> `$1,000` moves one `$` sideways instead of killing it and being born + * again a digit-width to the left, and `999 €` -> `1.000 €` does the same for a suffix. + */ fun layoutKeyedSlots( formatted: String, groupSep: Char, @@ -41,6 +49,8 @@ object TransitionLogic { val tokens = tokenize(formatted, groupSep, decimalSep, minusSign) val decimalIndex = tokens.indexOfFirst { it.kind == TokenKind.DECIMAL_SEPARATOR } val integerEnd = if (decimalIndex >= 0) decimalIndex else tokens.size + val firstDigit = tokens.indexOfFirst { it.kind == TokenKind.DIGIT } + val lastDigit = tokens.indexOfLast { it.kind == TokenKind.DIGIT } val integerDigitsToRight = IntArray(tokens.size) var digitsToRight = 0 @@ -70,7 +80,7 @@ object TransitionLogic { TokenKind.GROUP_SEPARATOR -> "G${integerDigitsToRight[i]}" TokenKind.DECIMAL_SEPARATOR -> "DEC" TokenKind.SIGN -> "S" - TokenKind.OTHER -> "O$i" + TokenKind.OTHER -> affixKey(i, firstDigit, lastDigit) } result.add( @@ -91,6 +101,20 @@ object TransitionLogic { return result } + /** + * A key for a token outside the digits, counted outwards from the nearest end of the number. + * + * `P0` is the character immediately before the first digit and `X0` the one immediately after + * the last, so `$1.00` and `($1.00)` agree that `$` is `P0` and disagree only about the bracket + * that `($1.00)` also has. A token with no digits to sit against, which a formatter should never + * produce, falls back to its position in the string. + */ + private fun affixKey(index: Int, firstDigit: Int, lastDigit: Int): String = when { + firstDigit >= 0 && index < firstDigit -> "P${firstDigit - index - 1}" + lastDigit >= 0 && index > lastDigit -> "X${index - lastDigit - 1}" + else -> "O$index" + } + private fun tokenize( text: String, groupSep: Char, diff --git a/android/src/test/java/com/numerictext/TransitionLogicTest.kt b/android/src/test/java/com/numerictext/TransitionLogicTest.kt index 57f906a..ac9aa8f 100644 --- a/android/src/test/java/com/numerictext/TransitionLogicTest.kt +++ b/android/src/test/java/com/numerictext/TransitionLogicTest.kt @@ -69,6 +69,58 @@ class TransitionLogicTest { assertEquals("1", keyMap("-1")["I0"]) } + @Test + fun currencySymbol_keepsItsKeyWhenTheNumberGrowsADigit() { + // The whole point of keying an affix from the digits rather than from the string: `$` has to + // stay one column that slides left, not die at offset 0 and be reborn at offset 0. + assertEquals("$", keyMap("\$999")["P0"]) + assertEquals("$", keyMap("\$1,000")["P0"]) + } + + @Test + fun currencySymbol_keepsItsKeyAcrossASignChange() { + assertEquals("$", keyMap("\$1.00")["P0"]) + assertEquals("$", keyMap("-\$1.00")["P0"]) + assertEquals("-", keyMap("-\$1.00")["S"]) + } + + @Test + fun accountingBrackets_sitOutsideTheSymbol() { + val accounting = keyMap("(\$1.00)") + assertEquals("$", accounting["P0"]) + assertEquals("(", accounting["P1"]) + assertEquals(")", accounting["X0"]) + } + + @Test + fun trailingSymbol_isKeyedFromTheEndOfTheNumber() { + // de-DE writes the symbol after the number, with a no-break space between. + val small = TransitionLogic.layoutKeyedSlots("999,00\u00A0€", '.', ',', '-', line("999,00\u00A0€")) + .associate { it.key to it.char } + val large = + TransitionLogic.layoutKeyedSlots("1.000,00\u00A0€", '.', ',', '-', line("1.000,00\u00A0€")) + .associate { it.key to it.char } + + assertEquals("€", small["X1"]) + assertEquals("€", large["X1"]) + assertEquals("\u00A0", small["X0"]) + assertEquals("\u00A0", large["X0"]) + } + + @Test + fun percentSign_isKeyedFromTheEndOfTheNumber() { + assertEquals("%", keyMap("9%")["X0"]) + assertEquals("%", keyMap("99%")["X0"]) + } + + @Test + fun currencyName_keysEachLetterOutwardFromTheNumber() { + val letters = keyMap("1.00 US dollars") + assertEquals(" ", letters["X0"]) + assertEquals("U", letters["X1"]) + assertEquals("s", letters["X10"]) + } + @Test fun tokenBoundsComeFromTheFullLineGeometry() { val slots = keyed("1,000") diff --git a/ios/NumericTextSwiftUIHost.swift b/ios/NumericTextSwiftUIHost.swift index 02b8803..fb429c3 100644 --- a/ios/NumericTextSwiftUIHost.swift +++ b/ios/NumericTextSwiftUIHost.swift @@ -28,6 +28,13 @@ public final class NumericTextSwiftUIHost: UIView { /// number moved. `nil` until the first render, which must not animate. private var lastValue: Double? + /// The formatting props, exactly as `src/numberFormat.ts` resolved them, and the formatter they + /// build. Kept together so the formatter is rebuilt only when something about it changed: + /// `updateProps:` forwards every prop on every value change, and a `NumberFormatter` per frame + /// of a press-and-hold is real work for no result. + private var formatSpec = FormatSpec() + private lazy var formatter = Self.makeFormatter(formatSpec) + @objc public override init(frame: CGRect) { super.init(frame: frame) @@ -53,27 +60,62 @@ public final class NumericTextSwiftUIHost: UIView { fatalError("NumericTextSwiftUIHost is created in code only") } + /** + * The formatting props. Applied before the value, because the value is drawn through them. + * + * Separate from `apply` so neither selector grows past reading: between them they carry + * everything the component exposes, and Objective-C spells every argument out. + */ // swiftlint:disable:next function_parameter_count - @objc(applyValue:locale:direction:reduceMotion:useGrouping:minimumFractionDigits:maximumFractionDigits:fontSize:fontWeight:fontFamily:textColor:) - public func apply( - value: Double, + @objc(applyFormatWithLocale:numberStyle:currency:currencyDisplay:currencySign:useGrouping:minimumIntegerDigits:minimumFractionDigits:maximumFractionDigits:minimumSignificantDigits:maximumSignificantDigits:trailingDecimalSeparator:) + public func applyFormat( locale: String, - direction: String, - reduceMotion: String, + numberStyle: String, + currency: String, + currencyDisplay: String, + currencySign: String, useGrouping: Bool, + minimumIntegerDigits: Int, minimumFractionDigits: Int, maximumFractionDigits: Int, + minimumSignificantDigits: Int, + maximumSignificantDigits: Int, + trailingDecimalSeparator: Bool + ) { + let next = FormatSpec( + locale: locale, + numberStyle: numberStyle, + currency: currency, + currencyDisplay: currencyDisplay, + currencySign: currencySign, + useGrouping: useGrouping, + minimumIntegerDigits: minimumIntegerDigits, + minimumFractionDigits: minimumFractionDigits, + maximumFractionDigits: maximumFractionDigits, + minimumSignificantDigits: minimumSignificantDigits, + maximumSignificantDigits: maximumSignificantDigits, + trailingDecimalSeparator: trailingDecimalSeparator + ) + guard next != formatSpec else { return } + formatSpec = next + formatter = Self.makeFormatter(next) + } + + // swiftlint:disable:next function_parameter_count + @objc(applyValue:direction:reduceMotion:fontSize:fontWeight:fontFamily:textColor:) + public func apply( + value: Double, + direction: String, + reduceMotion: String, fontSize: CGFloat, fontWeight: String, fontFamily: String?, textColor: UIColor? ) { - model.text = Self.format( + model.text = Self.text( value, - locale: locale, - useGrouping: useGrouping, - minimumFractionDigits: minimumFractionDigits, - maximumFractionDigits: maximumFractionDigits + formatter: formatter, + trailingDecimalSeparator: formatSpec.trailingDecimalSeparator ) model.fontSize = fontSize > 0 ? fontSize : 48 model.weight = Self.weight(from: fontWeight) @@ -151,23 +193,143 @@ public final class NumericTextSwiftUIHost: UIView { } } - private static func format( + // MARK: - Formatting + + /// The formatting props, as `src/numberFormat.ts` resolved them. A digit bound of -1 means the + /// caller left it out and the style should supply its own. + private struct FormatSpec: Equatable { + var locale: String = "en-US" + var numberStyle: String = "decimal" + var currency: String = "" + var currencyDisplay: String = "symbol" + var currencySign: String = "standard" + var useGrouping: Bool = true + var minimumIntegerDigits: Int = -1 + var minimumFractionDigits: Int = -1 + var maximumFractionDigits: Int = -1 + var minimumSignificantDigits: Int = -1 + var maximumSignificantDigits: Int = -1 + var trailingDecimalSeparator: Bool = false + } + + /** + * The formatted number, with the decimal mark held after the last digit when the caller asked + * for it and the format produced none. + * + * After the last *digit*, not at the end of the string: `de-DE` writes `1.234 €`, and a mark + * appended blindly would land beyond the currency symbol. + */ + private static func text( _ value: Double, - locale: String, - useGrouping: Bool, - minimumFractionDigits: Int, - maximumFractionDigits: Int + formatter: NumberFormatter, + trailingDecimalSeparator: Bool ) -> String { + let formatted = formatter.string(from: NSNumber(value: value)) ?? String(value) + guard trailingDecimalSeparator else { return formatted } + + // A currency format may use a different mark from a plain number in the same locale, so ask + // the formatter for the one belonging to the style it is actually in. + let money = formatter.numberStyle != .decimal && formatter.numberStyle != .percent + let mark = + (money ? formatter.currencyDecimalSeparator : formatter.decimalSeparator) ?? "." + guard !formatted.contains(mark) else { return formatted } + + guard let lastDigit = formatted.lastIndex(where: { $0.isNumber }) else { + return formatted + mark + } + let after = formatted.index(after: lastDigit) + return String(formatted[.. NumberFormatter { let formatter = NumberFormatter() - formatter.locale = Locale(identifier: locale.isEmpty ? "en-US" : locale) - formatter.numberStyle = .decimal - formatter.usesGroupingSeparator = useGrouping - formatter.minimumFractionDigits = max(0, minimumFractionDigits) - formatter.maximumFractionDigits = max( - max(0, minimumFractionDigits), - maximumFractionDigits + formatter.locale = Locale(identifier: spec.locale.isEmpty ? "en-US" : spec.locale) + + let money = spec.numberStyle == "currency" && !spec.currency.isEmpty + formatter.numberStyle = style(spec, money: money) + if money { formatter.currencyCode = spec.currency } + formatter.usesGroupingSeparator = spec.useGrouping + + // Intl rounds halves away from zero; Foundation and ICU both default to half-even. Follow + // Intl, so `2.5` at zero decimals reads as `3` on iOS, on Android, and on the web fallback + // rather than as `3`, `2`, `2`. + formatter.roundingMode = .halfUp + + if spec.minimumIntegerDigits >= 0 { + formatter.minimumIntegerDigits = spec.minimumIntegerDigits + } + + if spec.minimumSignificantDigits >= 0 || spec.maximumSignificantDigits >= 0 { + let (low, high) = bounds( + spec.minimumSignificantDigits, + spec.maximumSignificantDigits, + defaultMinimum: 1, + defaultMaximum: 21 + ) + formatter.usesSignificantDigits = true + formatter.maximumSignificantDigits = high + formatter.minimumSignificantDigits = low + return formatter + } + + // Read before it is written: with the style and the code set, the formatter is already + // carrying the currency's own fraction digits (2 for USD, 0 for JPY, 3 for BHD), and that is + // exactly the default Intl would have applied. + let percent = !money && spec.numberStyle == "percent" + let defaultMaximum: Int + if money { + defaultMaximum = max(0, formatter.maximumFractionDigits) + } else if percent { + defaultMaximum = 0 + } else { + defaultMaximum = 3 + } + // A plain number is the only style that may drop a trailing zero, so it is the only one whose + // minimum differs from its maximum. + let defaultMinimum = (money || percent) ? defaultMaximum : 0 + + let (low, high) = bounds( + spec.minimumFractionDigits, + spec.maximumFractionDigits, + defaultMinimum: defaultMinimum, + defaultMaximum: defaultMaximum ) - return formatter.string(from: NSNumber(value: value)) ?? String(value) + formatter.maximumFractionDigits = high + formatter.minimumFractionDigits = low + return formatter + } + + private static func style(_ spec: FormatSpec, money: Bool) -> NumberFormatter.Style { + guard money else { + return spec.numberStyle == "percent" ? .percent : .decimal + } + switch spec.currencyDisplay { + case "code": return .currencyISOCode + case "name": return .currencyPlural + // Accounting is a distinct CLDR pattern, so it is a style rather than a modifier, and it only + // exists alongside the symbol. `code` and `name` above therefore win over it. + default: return spec.currencySign == "accounting" ? .currencyAccounting : .currency + } + } + + /** + * ECMA-402's rule for resolving digit bounds, so the three implementations of this component + * round the same number to the same string. + * + * A bound that was left out is filled from the style, and a maximum below its minimum is + * clamped rather than rejected. `Intl` throws on that pair; a formatter that refuses to draw is + * worse than a number carrying one more decimal than was asked for. + */ + private static func bounds( + _ minimum: Int, + _ maximum: Int, + defaultMinimum: Int, + defaultMaximum: Int + ) -> (Int, Int) { + if minimum >= 0 && maximum >= 0 { return (minimum, max(minimum, maximum)) } + if minimum >= 0 { return (minimum, max(defaultMaximum, minimum)) } + if maximum >= 0 { return (min(defaultMinimum, maximum), maximum) } + return (defaultMinimum, defaultMaximum) } } diff --git a/ios/NumericTextView.mm b/ios/NumericTextView.mm index 4b6d53e..35a35f6 100644 --- a/ios/NumericTextView.mm +++ b/ios/NumericTextView.mm @@ -54,17 +54,29 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & // Every prop is forwarded on every update rather than diffed here. The SwiftUI side ignores an // unchanged value on its own — its animation is scoped to `value` — and a partial forward is how // a number ends up still drawn in the previous font after a style change. + // + // Formatting first: the value is drawn through it, so a change to both in one commit has to + // reach the formatter before it reaches the text. + [_host applyFormatWithLocale:RCTNSStringFromString(next.locale) + numberStyle:RCTNSStringFromString(next.numberStyle) + currency:RCTNSStringFromString(next.currency) + currencyDisplay:RCTNSStringFromString(next.currencyDisplay) + currencySign:RCTNSStringFromString(next.currencySign) + useGrouping:next.useGrouping + minimumIntegerDigits:next.minimumIntegerDigits + minimumFractionDigits:next.minimumFractionDigits + maximumFractionDigits:next.maximumFractionDigits + minimumSignificantDigits:next.minimumSignificantDigits + maximumSignificantDigits:next.maximumSignificantDigits + trailingDecimalSeparator:next.trailingDecimalSeparator]; + [_host applyValue:next.value - locale:RCTNSStringFromString(next.locale) - direction:RCTNSStringFromString(next.direction) - reduceMotion:RCTNSStringFromString(next.reduceMotion) - useGrouping:next.useGrouping - minimumFractionDigits:next.minimumFractionDigits - maximumFractionDigits:next.maximumFractionDigits - fontSize:next.fontSize - fontWeight:RCTNSStringFromString(next.fontWeight) - fontFamily:RCTNSStringFromString(next.fontFamily) - textColor:RCTUIColorFromSharedColor(next.textColor)]; + direction:RCTNSStringFromString(next.direction) + reduceMotion:RCTNSStringFromString(next.reduceMotion) + fontSize:next.fontSize + fontWeight:RCTNSStringFromString(next.fontWeight) + fontFamily:RCTNSStringFromString(next.fontFamily) + textColor:RCTUIColorFromSharedColor(next.textColor)]; [super updateProps:props oldProps:oldProps]; } diff --git a/src/NumericTextFallback.tsx b/src/NumericTextFallback.tsx index 2fdbf1f..d788096 100644 --- a/src/NumericTextFallback.tsx +++ b/src/NumericTextFallback.tsx @@ -1,5 +1,6 @@ import { memo } from 'react'; import { Text } from 'react-native'; +import { DEFAULT_LOCALE, formatNumber, resolveFormat } from './numberFormat'; import type { NumericTextProps } from './types'; /** @@ -9,20 +10,21 @@ import type { NumericTextProps } from './types'; * native path so snapshots and web renders keep the same number formatting; only the transition is * missing. */ -function NumericTextFallbackImpl({ - value, - locale = 'en-US', - minimumFractionDigits = 0, - maximumFractionDigits = 3, - useGrouping = true, - style, - testID, -}: NumericTextProps) { - const formatted = value.toLocaleString(locale, { - minimumFractionDigits, - maximumFractionDigits, - useGrouping, - }); +function NumericTextFallbackImpl(props: NumericTextProps) { + const { + value, + locale = DEFAULT_LOCALE, + trailingDecimalSeparator = false, + style, + testID, + } = props; + const formatted = formatNumber( + value, + locale, + resolveFormat(props), + trailingDecimalSeparator + ); + return ( {formatted} diff --git a/src/NumericTextView.native.tsx b/src/NumericTextView.native.tsx index c6beb2c..33aeae0 100644 --- a/src/NumericTextView.native.tsx +++ b/src/NumericTextView.native.tsx @@ -1,6 +1,12 @@ import { memo, useEffect, useRef, useState } from 'react'; import NumericTextViewNativeComponent from './NumericTextViewNativeComponent'; import { measureBox, widest, type Box } from './measureBox'; +import { + DEFAULT_LOCALE, + formatNumber, + nativeFormatProps, + resolveFormat, +} from './numberFormat'; import { resolveTextStyle } from './resolveTextStyle'; import type { NumericTextProps } from './types'; @@ -16,26 +22,33 @@ import type { NumericTextProps } from './types'; * `animationDuration` reaches Android only; SwiftUI's numeric transition is a spring with no * duration to set. Both sides otherwise read the same props, including the text properties pulled * out of `style` — each renderer draws its own glyphs, so it needs them as props. + * + * Formatting is props too, not a finished string. The renderers animate the structure of a + * formatted number: which digit is which, where the decimal mark sits, which side the currency + * symbol is on. Each renderer therefore formats it natively, and JS only reproduces the result to + * size the box. */ -function NumericTextViewImpl({ - value, - locale = 'en-US', - direction = 'automatic', - animationDuration = 80, - reduceMotion = 'system', - minimumFractionDigits = 0, - maximumFractionDigits = 3, - useGrouping = true, - style, - testID, -}: NumericTextProps) { +function NumericTextViewImpl(props: NumericTextProps) { + const { + value, + locale = DEFAULT_LOCALE, + direction = 'automatic', + animationDuration = 80, + reduceMotion = 'system', + trailingDecimalSeparator = false, + style, + testID, + } = props; + const text = resolveTextStyle(style); + const format = resolveFormat(props); - const formatted = value.toLocaleString(locale, { - minimumFractionDigits, - maximumFractionDigits, - useGrouping, - }); + const formatted = formatNumber( + value, + locale, + format, + trailingDecimalSeparator + ); const box = useShrinkHeldBox( measureBox(formatted, text.fontSize), Math.max(animationDuration, 500) + 400 @@ -47,10 +60,9 @@ function NumericTextViewImpl({ direction={direction} locale={locale} animationDuration={animationDuration} - useGrouping={useGrouping} - minimumFractionDigits={minimumFractionDigits} - maximumFractionDigits={maximumFractionDigits} reduceMotion={reduceMotion} + {...nativeFormatProps(format)} + trailingDecimalSeparator={trailingDecimalSeparator} fontSize={text.fontSize} fontWeight={text.fontWeight} fontFamily={text.fontFamily} diff --git a/src/NumericTextViewNativeComponent.ts b/src/NumericTextViewNativeComponent.ts index 64cb8c3..5d39458 100644 --- a/src/NumericTextViewNativeComponent.ts +++ b/src/NumericTextViewNativeComponent.ts @@ -10,10 +10,23 @@ interface NativeProps extends ViewProps { readonly direction?: string; readonly locale?: string; readonly animationDuration?: Double; + readonly reduceMotion?: string; + + // Formatting. Flat scalars rather than one object: this is the shape codegen carries well, and + // `src/numberFormat.ts` is where the `Intl`-shaped prop is resolved down to them. + readonly numberStyle?: string; + readonly currency?: string; + readonly currencyDisplay?: string; + readonly currencySign?: string; readonly useGrouping?: boolean; + readonly trailingDecimalSeparator?: boolean; + /** A digit count, or -1 for "the format's own default"; see `DIGITS_UNSET`. */ + readonly minimumIntegerDigits?: Int32; readonly minimumFractionDigits?: Int32; readonly maximumFractionDigits?: Int32; - readonly reduceMotion?: string; + readonly minimumSignificantDigits?: Int32; + readonly maximumSignificantDigits?: Int32; + readonly fontSize?: Float; readonly fontWeight?: string; readonly fontFamily?: string; diff --git a/src/__tests__/measureBox.test.ts b/src/__tests__/measureBox.test.ts index 86e8e67..52211a1 100644 --- a/src/__tests__/measureBox.test.ts +++ b/src/__tests__/measureBox.test.ts @@ -24,6 +24,27 @@ describe('widthInEm', () => { expect(widthInEm('-1')).toBeGreaterThan(widthInEm('1')); expect(widthInEm('−1')).toBe(widthInEm('-1')); }); + + it('charges a currency symbol as a glyph rather than as punctuation', () => { + // `$` measures 0.6064 em in the bundled Regular and a comma 0.2541. Charging the first at the + // second's width under-reserves the box by most of a digit, which the headroom cannot absorb + // once a format carries several of them. + for (const symbol of ['$', '€', '£', '¥', '₹', '%']) { + expect(widthInEm(symbol)).toBeGreaterThan(widthInEm(',')); + } + }); + + it('charges the letters of a code or a name', () => { + expect(widthInEm('US dollars')).toBeGreaterThan(9 * widthInEm(',')); + }); + + it('keeps the spaces a locale groups with narrow', () => { + // fr-FR groups with a narrow no-break space and puts a no-break space before the symbol. + // Charging either as a glyph would reserve most of a digit for a gap. + for (const space of ['\u00a0', '\u2007', '\u2008', '\u2009', '\u202f']) { + expect(widthInEm(space)).toBe(widthInEm(',')); + } + }); }); describe('measureBox', () => { diff --git a/src/__tests__/numberFormat.test.ts b/src/__tests__/numberFormat.test.ts new file mode 100644 index 0000000..4b51f1f --- /dev/null +++ b/src/__tests__/numberFormat.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from '@jest/globals'; +import { + DIGITS_UNSET, + formatNumber, + intlOptions, + nativeFormatProps, + resolveFormat, +} from '../numberFormat'; + +describe('resolveFormat', () => { + it('reads the currency shorthand as a currency style', () => { + expect(resolveFormat({ currency: 'USD' })).toEqual({ + style: 'currency', + currency: 'USD', + }); + }); + + it('lets format override the shorthands where they overlap', () => { + expect( + resolveFormat({ + currency: 'USD', + useGrouping: true, + format: { currency: 'EUR', useGrouping: false }, + }) + ).toEqual({ + style: 'currency', + currency: 'EUR', + useGrouping: false, + }); + }); + + it('leaves an absent prop absent rather than defaulting it', () => { + // The renderers need to tell "0 fraction digits" from "the currency's own count", so a prop + // nobody set must not arrive as a number. + expect(resolveFormat({})).toEqual({}); + }); +}); + +describe('formatNumber', () => { + it('formats a plain number to at most three decimals', () => { + expect(formatNumber(1234.5678, 'en-US', {})).toBe('1,234.568'); + }); + + it('takes the currencys own fraction digits when none were given', () => { + expect( + formatNumber(1234.5, 'en-US', resolveFormat({ currency: 'USD' })) + ).toBe('$1,234.50'); + expect( + formatNumber(1234.5, 'en-US', resolveFormat({ currency: 'JPY' })) + ).toBe('¥1,235'); + }); + + it('puts the symbol where the locale puts it', () => { + // Written escaped because the space before the symbol is a no-break one, which is exactly the + // kind of character the transition has to key on and a reader cannot see. + expect( + formatNumber(1234.5, 'de-DE', resolveFormat({ currency: 'EUR' })) + ).toBe('1.234,50\u00a0€'); + }); + + it('writes the currency as a code or a name on request', () => { + const currency = 'USD'; + expect( + formatNumber(1234.5, 'en-US', { + style: 'currency', + currency, + currencyDisplay: 'code', + }) + ).toBe('USD\u00a01,234.50'); + expect( + formatNumber(1234.5, 'en-US', { + style: 'currency', + currency, + currencyDisplay: 'name', + }) + ).toBe('1,234.50 US dollars'); + }); + + it('brackets a negative amount in the accounting sign', () => { + expect( + formatNumber(-1234.5, 'en-US', { + style: 'currency', + currency: 'USD', + currencySign: 'accounting', + }) + ).toBe('($1,234.50)'); + }); + + it('multiplies a percentage by a hundred and drops its decimals', () => { + expect(formatNumber(0.425, 'en-US', { style: 'percent' })).toBe('43%'); + }); + + it('pads to a minimum integer width', () => { + expect(formatNumber(9, 'en-US', { minimumIntegerDigits: 2 })).toBe('09'); + }); + + it('rounds a half away from zero, which is Intls rule and neither platforms', () => { + // Both native formatters are told to do the same. The three implementations disagreeing about + // whether 2.5 reads as 2 or 3 would be a bug, so it is not left to each platform's default. + expect(formatNumber(2.5, 'en-US', { maximumFractionDigits: 0 })).toBe('3'); + expect(formatNumber(-2.5, 'en-US', { maximumFractionDigits: 0 })).toBe( + '-3' + ); + }); + + it('falls back to a plain number rather than throwing on an unknown currency', () => { + expect( + formatNumber(12, 'en-US', { style: 'currency', currency: 'NOPE' }) + ).toBe('12'); + }); + + it('treats a currency style with no code as a plain number', () => { + // Intl throws on this pair. The renderers cannot, so all three agree to ignore it. + expect(formatNumber(1234.5, 'en-US', { style: 'currency' })).toBe( + '1,234.5' + ); + }); +}); + +describe('formatNumber, trailing decimal separator', () => { + // `value` is a number and a number cannot hold `7.`, so typing 7 . 5 produces 7, 7, 7.5. The + // flag is what gives the mark somewhere to live between the second and third keystroke. + it('holds the mark after the last digit when nothing follows it yet', () => { + expect(formatNumber(7, 'en-US', {}, true)).toBe('7.'); + }); + + it('is a no-op once a fraction digit arrives', () => { + expect(formatNumber(7.5, 'en-US', {}, true)).toBe('7.5'); + }); + + it('is a no-op when the format already prints a mark', () => { + const fixed = { minimumFractionDigits: 2, maximumFractionDigits: 2 }; + expect(formatNumber(7, 'en-US', fixed, true)).toBe('7.00'); + }); + + it('uses the locale mark, not a full stop', () => { + expect(formatNumber(1234, 'de-DE', {}, true)).toBe('1.234,'); + }); + + it('goes after the last digit rather than at the end of the string', () => { + // de-DE writes the symbol last. Appending blindly would put the mark beyond the euro sign. + const euro = resolveFormat({ currency: 'EUR' }); + const zeroDecimals = { + ...euro, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }; + expect(formatNumber(1234, 'de-DE', zeroDecimals, true)).toBe('1.234, €'); + }); + + it('keeps a leading currency symbol outside the mark', () => { + const usd = { + ...resolveFormat({ currency: 'USD' }), + maximumFractionDigits: 0, + }; + expect(formatNumber(7, 'en-US', usd, true)).toBe('$7.'); + }); + + it('does nothing when the flag is off, which is the default', () => { + expect(formatNumber(7, 'en-US', {})).toBe('7'); + }); +}); + +describe('intlOptions', () => { + it('never asks for a maximum below its minimum', () => { + // Intl throws on that pair; the native formatters clamp. Clamping here keeps a bad prop from + // taking a render down. + const options = intlOptions({ + minimumFractionDigits: 4, + maximumFractionDigits: 1, + }); + expect(options.maximumFractionDigits).toBe(4); + expect(() => (1).toLocaleString('en-US', options)).not.toThrow(); + }); + + it('passes a bound through untouched so Intl applies its own default to the other', () => { + expect(intlOptions({ maximumFractionDigits: 1 })).not.toHaveProperty( + 'minimumFractionDigits' + ); + }); +}); + +describe('nativeFormatProps', () => { + it('marks an absent bound so the renderer can apply the same default', () => { + expect(nativeFormatProps({})).toEqual({ + numberStyle: 'decimal', + currency: '', + currencyDisplay: 'symbol', + currencySign: 'standard', + useGrouping: true, + minimumIntegerDigits: DIGITS_UNSET, + minimumFractionDigits: DIGITS_UNSET, + maximumFractionDigits: DIGITS_UNSET, + minimumSignificantDigits: DIGITS_UNSET, + maximumSignificantDigits: DIGITS_UNSET, + }); + }); + + it('carries a currency down as flat scalars', () => { + const props = nativeFormatProps(resolveFormat({ currency: 'EUR' })); + expect(props.numberStyle).toBe('currency'); + expect(props.currency).toBe('EUR'); + }); + + it('drops a currency style that has no code, as the formatters do', () => { + const props = nativeFormatProps({ style: 'currency' }); + expect(props.numberStyle).toBe('decimal'); + expect(props.currency).toBe(''); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 2c8e758..cd89bb1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,4 @@ export { NumericTextView } from './NumericTextView'; /** Preferred name. `NumericTextView` is the same component, kept for the original import path. */ export { NumericTextView as NumericText } from './NumericTextView'; -export type { NumericTextProps } from './types'; +export type { NumericTextFormat, NumericTextProps } from './types'; diff --git a/src/measureBox.ts b/src/measureBox.ts index e6e494e..d1488b2 100644 --- a/src/measureBox.ts +++ b/src/measureBox.ts @@ -8,7 +8,9 @@ * * The estimate is per character rather than a flat average because the bundled font's tabular * digits and its separators differ by more than a factor of two, and a four-digit grouped number - * is a quarter separator by character count. + * is a quarter separator by character count. Once a number can carry a currency (`$1,234.56`, + * `1.234,56 €`, `1,234.56 US dollars`) the spread is wider still, so symbols and letters are + * charged separately from punctuation rather than being lumped in with the comma. */ /** Advance widths as a fraction of font size, from the bundled Sunghyun Sans with `tnum` on. */ @@ -16,6 +18,16 @@ const DIGIT_EM = 0.6167; const SEPARATOR_EM = 0.2541; const SIGN_EM = 0.36; +/** + * Currency symbols, the percent sign, and the letters of an ISO code or a currency name. + * + * One estimate covers all of them because they land close together in this face and because the + * box is a minimum with headroom on top: `$` measures near a digit, `%` slightly wider, and a + * lower-case letter narrower. Over-charging a letter costs a few pixels of reserved width; + * under-charging one clips `US dollars`. + */ +const GLYPH_EM = 0.62; + /** Room for the transition's own overspill: a dying glyph drifts outward and carries a blur halo. */ const HEADROOM_EM = 0.5; @@ -36,11 +48,44 @@ export function widthInEm(formatted: string): number { for (const ch of formatted) { if (ch >= '0' && ch <= '9') em += DIGIT_EM; else if (ch === '-' || ch === '−' || ch === '+') em += SIGN_EM; - else em += SEPARATOR_EM; + else if (isNarrow(ch)) em += SEPARATOR_EM; + else em += GLYPH_EM; } return em; } +/** + * Whether [ch] is punctuation or a space rather than something with ink to spare. + * + * The separators a formatter emits are few and known: the comma, the stop, the apostrophe forms, + * the middle dot, the Arabic marks, and the several fixed-width spaces a French or Swiss locale + * groups with. They are therefore listed rather than derived. Anything else in a formatted number is a + * digit, a sign, or a symbol or letter that carries a currency, and those are charged as glyphs. + */ +function isNarrow(ch: string): boolean { + return NARROW.has(ch); +} + +const NARROW = new Set([ + ',', + '.', + "'", + '’', // right single quote, the Swiss grouping separator + '·', // middle dot + '٫', // Arabic decimal separator + '٬', // Arabic thousands separator + ',', // fullwidth comma + '.', // fullwidth stop + '(', + ')', + ' ', + ' ', // no-break space + ' ', // figure space + ' ', // punctuation space + ' ', // thin space + ' ', // narrow no-break space +]); + export function measureBox( formatted: string, fontSize: number | undefined diff --git a/src/numberFormat.ts b/src/numberFormat.ts new file mode 100644 index 0000000..5c0657b --- /dev/null +++ b/src/numberFormat.ts @@ -0,0 +1,247 @@ +import type { NumericTextFormat, NumericTextProps } from './types'; + +/** + * One description of "what this number should read as", shared by the three places that have to + * agree on it: the web fallback, the JS width estimate, and the props handed to the native + * renderers. + * + * The native renderers do the real formatting, because the string that is drawn has to be + * produced where it is drawn: the transition animates the structure of a formatted number, not a + * string handed over ready-made. But the layout box is estimated in JS from a string JS formats + * itself, so the two must resolve the same options from the same props. Everything that decides + * the shape of the number therefore lives here rather than being spelled out again per platform. + */ + +/** + * The value a digit bound carries to native when the caller did not give one. + * + * A bound is a digit count, so it is never negative and -1 cannot be mistaken for a real one. The + * renderer needs the difference: "0 fraction digits" and "however many this currency uses" are + * different instructions, and an absent prop means the second. + */ +export const DIGITS_UNSET = -1; + +export const DEFAULT_LOCALE = 'en-US'; + +/** The props that describe the number rather than the motion or the typography. */ +type FormatProps = Pick< + NumericTextProps, + | 'format' + | 'currency' + | 'useGrouping' + | 'minimumFractionDigits' + | 'maximumFractionDigits' +>; + +/** The flat scalars the native prop surface carries. `Intl` shapes are not codegen shapes. */ +export type NativeFormatProps = { + numberStyle: string; + currency: string; + currencyDisplay: string; + currencySign: string; + useGrouping: boolean; + minimumIntegerDigits: number; + minimumFractionDigits: number; + maximumFractionDigits: number; + minimumSignificantDigits: number; + maximumSignificantDigits: number; +}; + +/** + * The single format the whole component works from. + * + * The top-level shorthands are folded in first and [FormatProps.format] is laid over them, so a + * component can say `currency="USD"` for the common case and still reach for the full options + * object without the two contradicting each other. + */ +export function resolveFormat(props: FormatProps): NumericTextFormat { + const { + format, + currency, + useGrouping, + minimumFractionDigits, + maximumFractionDigits, + } = props; + + const shorthand: NumericTextFormat = {}; + if (currency !== undefined) { + shorthand.style = 'currency'; + shorthand.currency = currency; + } + if (useGrouping !== undefined) shorthand.useGrouping = useGrouping; + if (minimumFractionDigits !== undefined) { + shorthand.minimumFractionDigits = minimumFractionDigits; + } + if (maximumFractionDigits !== undefined) { + shorthand.maximumFractionDigits = maximumFractionDigits; + } + + return format ? { ...shorthand, ...format } : shorthand; +} + +/** + * Whether [format] asks for money. + * + * `style: 'currency'` without a code is not money, it is a mistake. `Intl` throws on it; the + * renderers cannot, so both they and this treat it as a plain number. + */ +function isCurrency(format: NumericTextFormat): boolean { + return format.style === 'currency' && !!format.currency; +} + +/** + * [format] as `Intl.NumberFormat` options. + * + * A digit bound is passed through rather than defaulted, so `Intl` applies its own rule: 0..3 + * digits for a plain number, 0 for a percentage, and the currency's own count for money. Both + * native renderers implement that same rule, which is what keeps a JS-measured box the size of a + * natively drawn number. + * + * The one thing not passed through is a maximum below its minimum. `Intl` throws on that pair; + * the native formatters silently clamp, and a formatter that throws inside a render is worse than + * a number carrying one more decimal than was asked for. + */ +export function intlOptions( + format: NumericTextFormat +): Intl.NumberFormatOptions { + const options: Intl.NumberFormatOptions = { + useGrouping: format.useGrouping ?? true, + }; + + if (isCurrency(format)) { + options.style = 'currency'; + options.currency = format.currency; + options.currencyDisplay = format.currencyDisplay ?? 'symbol'; + options.currencySign = format.currencySign ?? 'standard'; + } else if (format.style === 'percent') { + options.style = 'percent'; + } + + if (format.minimumIntegerDigits !== undefined) { + options.minimumIntegerDigits = format.minimumIntegerDigits; + } + + assignBounds( + options, + 'minimumSignificantDigits', + 'maximumSignificantDigits', + format.minimumSignificantDigits, + format.maximumSignificantDigits + ); + assignBounds( + options, + 'minimumFractionDigits', + 'maximumFractionDigits', + format.minimumFractionDigits, + format.maximumFractionDigits + ); + + return options; +} + +function assignBounds( + options: Intl.NumberFormatOptions, + minKey: 'minimumFractionDigits' | 'minimumSignificantDigits', + maxKey: 'maximumFractionDigits' | 'maximumSignificantDigits', + min: number | undefined, + max: number | undefined +): void { + if (min !== undefined) options[minKey] = min; + if (max !== undefined) { + options[maxKey] = min === undefined ? max : Math.max(min, max); + } +} + +/** + * [format] as the flat props the native renderers read. Absent bounds become [DIGITS_UNSET] so + * each platform can apply the same default rule rather than being handed a guess made in JS. + */ +export function nativeFormatProps( + format: NumericTextFormat +): NativeFormatProps { + const currency = isCurrency(format); + return { + numberStyle: currency + ? 'currency' + : format.style === 'percent' + ? 'percent' + : 'decimal', + currency: currency ? format.currency! : '', + currencyDisplay: format.currencyDisplay ?? 'symbol', + currencySign: format.currencySign ?? 'standard', + useGrouping: format.useGrouping ?? true, + minimumIntegerDigits: format.minimumIntegerDigits ?? DIGITS_UNSET, + minimumFractionDigits: format.minimumFractionDigits ?? DIGITS_UNSET, + maximumFractionDigits: format.maximumFractionDigits ?? DIGITS_UNSET, + minimumSignificantDigits: format.minimumSignificantDigits ?? DIGITS_UNSET, + maximumSignificantDigits: format.maximumSignificantDigits ?? DIGITS_UNSET, + }; +} + +/** + * [value] as the number the renderer will draw. + * + * Falls back to plain formatting when the options are rejected, which happens for an unknown + * currency code and on a JS engine built without the full `Intl` data. Neither is worth throwing + * out of a render: the native side formats the number that is actually shown, and this string + * only has to be close enough to size the box. + */ +export function formatNumber( + value: number, + locale: string, + format: NumericTextFormat, + trailingDecimalSeparator = false +): string { + let text: string; + try { + text = value.toLocaleString(locale, intlOptions(format)); + } catch { + text = value.toLocaleString(locale); + } + return trailingDecimalSeparator + ? withTrailingSeparator(text, decimalSeparatorFor(locale, format)) + : text; +} + +/** + * The decimal mark this locale and format would use. + * + * Read from the formatter rather than from a table, because a currency format may use a different + * mark from a plain number in the same locale. A probe value with one forced fraction digit is the + * only way to make `formatToParts` emit the mark when the format itself asks for none, which is + * exactly the case this is needed for. + */ +function decimalSeparatorFor( + locale: string, + format: NumericTextFormat +): string { + try { + const probe: Intl.NumberFormatOptions = { ...intlOptions(format) }; + delete probe.minimumSignificantDigits; + delete probe.maximumSignificantDigits; + probe.minimumFractionDigits = 1; + probe.maximumFractionDigits = 1; + + const parts = new Intl.NumberFormat(locale, probe).formatToParts(1.1); + return parts.find((part) => part.type === 'decimal')?.value ?? '.'; + } catch { + return '.'; + } +} + +/** + * [text] with [mark] after its last digit, unless it already carries one. + * + * After the last *digit*, not at the end of the string: `de-DE` writes `1.234 €`, and a mark + * appended blindly would land beyond the currency symbol. `\p{Nd}` rather than `0-9` because a + * locale may format in Arabic-Indic or Devanagari digits. + */ +function withTrailingSeparator(text: string, mark: string): string { + if (text.includes(mark)) return text; + + let end = -1; + for (const match of text.matchAll(/\p{Nd}/gu)) { + end = match.index + match[0].length; + } + return end < 0 ? text + mark : text.slice(0, end) + mark + text.slice(end); +} diff --git a/src/types.ts b/src/types.ts index 87ca465..59d59a2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,87 @@ import type { StyleProp, TextStyle } from 'react-native'; +/** + * How to turn the value into the number that is drawn: a subset of `Intl.NumberFormatOptions`, + * resolved natively by `NumberFormatter` on iOS and `android.icu` on Android. + * + * The shape is taken from `number-flow` (https://github.com/barvian/number-flow), which answers + * the same question on the web with one `format` object rather than a growing row of flat props. + * Borrowed with it: passing a bound through untouched so `Intl`'s own defaulting rule decides the + * rest, instead of guessing a default here and handing each platform a number it did not ask for. + * + * It is a subset because every option here has to mean the same thing on both platforms at the + * versions this library supports, and has to survive being animated: the renderers transition the + * *structure* of a formatted number, so the string has to be produced where it is drawn rather + * than passed in ready-made. + * + * Left out on purpose, and why: + * + * - `notation: 'compact'` (`1.2K`). Both platforms can produce it, but from different CLDR + * vintages, so the two would disagree on the string for the same input. + * - `signDisplay`, `roundingIncrement`, `unit`. Neither platform exposes them below its floor + * (Android's `NumberFormatter` is API 30, and `NumberFormatter` on iOS has no equivalent). + * - `currencyDisplay: 'narrowSymbol'`. Same reason. + * + * Rounding is fixed at half-away-from-zero on both platforms, which is `Intl`'s own default and + * neither platform's. It is not an option because the two renderers disagreeing about whether + * `2.5` reads as `2` or `3` is a bug, not a preference. + */ +export type NumericTextFormat = { + /** + * `'currency'` needs [currency] set and falls back to `'decimal'` without it. `'percent'` + * multiplies by 100 and appends the locale's percent sign, so `0.42` reads as `42%`. + */ + style?: 'decimal' | 'currency' | 'percent'; + + /** + * ISO 4217 code (`'USD'`, `'EUR'`, `'JPY'`). The symbol, which side of the number it sits on, + * the space around it, and the currency's own fraction digits all come from the platform's + * locale data: `'en-US'` gives `$1,234.56` and `'de-DE'` gives `1.234,56 €`. + */ + currency?: string; + + /** + * How to write the currency: its symbol (`$1,234.56`), its ISO code (`USD 1,234.56`), or its + * name in the locale (`1,234.56 US dollars`). + */ + currencyDisplay?: 'symbol' | 'code' | 'name'; + + /** + * How to write a negative amount. `'accounting'` uses the locale's accounting form, which in + * most locales means parentheses: `($1,234.56)` rather than `-$1,234.56`. Applied only with + * `currencyDisplay: 'symbol'`, the one combination both platforms format natively. + */ + currencySign?: 'standard' | 'accounting'; + + /** Group separators (`1,000` vs `1000`). */ + useGrouping?: boolean; + + /** + * Pads with leading zeros to at least this many integer digits, which is what holds a clock at + * `05:09` and stops a counter changing width as it crosses a power of ten. + */ + minimumIntegerDigits?: number; + + /** + * How many digits to keep after the decimal mark. Left out, a plain number rounds to between 0 + * and 3, a percentage to 0, and an amount of money takes the currency's own count: 2 for + * `'USD'`, 0 for `'JPY'`, 3 for `'BHD'`. + * + * Set [minimumFractionDigits] to hold a fixed number of decimals through a change that would + * otherwise drop one. That is what keeps `1.50` from reading as `1.5`, and what stops the + * fraction columns restructuring under a roll that should only have moved digits. + */ + minimumFractionDigits?: number; + maximumFractionDigits?: number; + + /** + * Round to a number of significant digits instead of a number of decimals. Setting either bound + * takes precedence over the fraction bounds, matching `Intl`. + */ + minimumSignificantDigits?: number; + maximumSignificantDigits?: number; +}; + /** * A number that animates between values the way SwiftUI's * `.contentTransition(.numericText())` does: each digit column rolls on its own spring, digits @@ -17,6 +99,42 @@ export type NumericTextProps = { */ locale?: string; + /** + * Everything about the shape of the number: currency, percent, grouping, digit bounds. See + * [NumericTextFormat]. + */ + format?: NumericTextFormat; + + /** + * Shorthand for `format={{ style: 'currency', currency }}`, which is the common case: + * ``. [format] overrides it where the two overlap. + */ + currency?: string; + + /** + * Draws the decimal mark after the last digit even though no fraction digit follows it yet. + * + * This exists for one case, and it is the case every amount field hits: `value` is a number, and + * a number cannot hold `7.`. Someone typing `7`, `.`, `5` produces the values 7, 7, 7.5, so the + * mark they typed has nowhere to live until the digit after it arrives, and the field either + * swallows the keystroke or grows a second `.` of its own beside the component. A sibling `Text` + * 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 gap to the right of the last digit is not a + * fixed distance, and it moves with the value and the font. + * + * Set this from the raw input instead, and the mark becomes a real column: same font, same + * baseline, keyed as `DEC`, and already in place when the first fraction digit is born beside it. + * + * ```tsx + * + * ``` + * + * It is a no-op whenever the number already has a decimal mark, so pairing it with + * `minimumFractionDigits` is safe. The mark is the locale's own, and it is placed after the last + * digit rather than at the end of the string, so a trailing currency symbol stays outside it. + */ + trailingDecimalSeparator?: boolean; + /** * Which way digits roll. `'automatic'` rolls up when the value grows and down when it shrinks, * which is what SwiftUI does; the other two force a direction regardless of the value. @@ -36,10 +154,11 @@ export type NumericTextProps = { */ reduceMotion?: 'system' | 'always' | 'never'; + /** Shorthand for the same field of [format]. */ minimumFractionDigits?: number; + /** Shorthand for the same field of [format]. */ maximumFractionDigits?: number; - - /** Group separators (`1,000` vs `1000`). */ + /** Shorthand for the same field of [format]. */ useGrouping?: boolean; /**