From 1e4b4a62068e6c88e7ab1fe497ae6c4ff502fa71 Mon Sep 17 00:00:00 2001 From: Ronald Goedeke Date: Mon, 21 Sep 2026 22:23:59 -0300 Subject: [PATCH 1/3] Rolling number: one value limit, dark mode, no per-frame allocations The engine clamped the scaled magnitude at 10^15 for a jump and 10^17 for a roll, so `jumpTo` and `animateTo` showed different digits above that; both now share one limit, the stagger's longest delay is stored once per roll, and the unused `revealFraction` is gone. Both views follow a light/dark switch when no `color` is set: iOS rasterizes the glyphs with the trait-resolved label color and rebuilds them on a trait or display-scale change (the scale now comes from the view's traits, not `UIScreen.main`), Android rebuilds its paints on a configuration change. Per frame, iOS reuses its wheel and element buffers, keys the glyph caches by a value type and compares layers in place; Android pools its layout elements, splits the affixes once per format and slides one cached shimmer gradient. Both format the accessibility text only when a screen reader asks for it, and Android reads the animator scale once behind a settings observer. The wrapper keeps one `ref` handle per mount and tests the right-to-left resolution; `lib/` ships ES module, CommonJS and declaration builds; the Android Gradle plugin is pinned only for a standalone build, Java 17. Co-Authored-By: Claude Fable 5.1 --- .../CHANGELOG.md | 37 +++- .../README.md | 4 +- .../android/CMakeLists.txt | 2 +- .../android/build.gradle | 14 +- .../android/gradle.properties | 1 - .../android/src/main/cpp/JRollingEngine.cpp | 21 -- .../android/src/main/cpp/JRollingEngine.hpp | 8 +- .../nitro/nitrorollingnumber/RollingEngine.kt | 8 +- .../nitrorollingnumber/RollingNumberView.kt | 203 ++++++++++++++---- .../cpp/RollingEngine.cpp | 27 +-- .../cpp/RollingEngine.hpp | 8 +- .../cpp/__tests__/RollingEngineTest.cpp | 50 +++++ .../ios/RollingNumberView.swift | 200 +++++++++++------ .../package.json | 16 +- .../src/RollingNumber.tsx | 19 +- .../src/__tests__/RollingNumber.test.tsx | 30 ++- .../src/__tests__/SharedHelpers.test.tsx | 34 +++ .../src/specs/RollingNumber.nitro.ts | 4 + .../tsconfig.build.cjs.json | 10 + .../tsconfig.build.json | 3 + 20 files changed, 525 insertions(+), 174 deletions(-) create mode 100644 packages/react-native-nitro-rolling-number/src/__tests__/SharedHelpers.test.tsx create mode 100644 packages/react-native-nitro-rolling-number/tsconfig.build.cjs.json diff --git a/packages/react-native-nitro-rolling-number/CHANGELOG.md b/packages/react-native-nitro-rolling-number/CHANGELOG.md index b1241a6e..00aa79b1 100644 --- a/packages/react-native-nitro-rolling-number/CHANGELOG.md +++ b/packages/react-native-nitro-rolling-number/CHANGELOG.md @@ -1,14 +1,45 @@ # Changelog -## Unreleased +## 0.1.0 (unreleased) + +- `jumpTo` and `animateTo` now agree on large figures: `jumpTo` clamped the + scaled magnitude at 10^15 while a roll clamped at 10^17, so with 9 fraction + digits any value above 10^6 jumped to "1,000,000" but rolled to the right + digits. Both paths share one limit (10^17, the 18 wheels); a double has no + exact integers past 2^53 either way. +- The digits follow a light/dark switch again when no `color` is set. iOS + rasterizes the glyphs with the resolved label color and re-rasterizes on a + trait change (also when the view moves to a display with another scale, and + the pixel density now comes from the view's traits instead of the deprecated + `UIScreen.main`); Android rebuilds its paints on a configuration change. +- Less work per frame: iOS reuses its wheel and element buffers, keys the + glyph caches by a value type and compares layers in place instead of + building arrays to compare; Android pools its layout elements, splits the + affixes once per format and slides one cached shimmer gradient instead of + allocating one per frame. Both platforms format the accessibility text only + when VoiceOver / TalkBack asks for it, instead of on every value update. +- Android reads the animator duration scale once (refreshed by a settings + observer) instead of querying the settings provider on every `animateTo` + below API 33. +- The `ref` handle is created once per mount; `getValue()` reads the latest + prop through a ref instead of the handle being rebuilt on every value change. +- Packaging: `lib/` now ships an ES module build (`lib/module`), a CommonJS + build (`lib/commonjs`) and the declarations (`lib/typescript`), with + `import` / `require` conditions in `exports`; the previous output was ES + modules only, which a CommonJS consumer could not load. `@types/node` and + `@types/react-test-renderer` are declared instead of relying on hoisting. +- Android build: the Android Gradle plugin is only pinned when the library + builds on its own (an app's root project already provides it), Java 17 + source/target, an unused `kotlinVersion` property removed, and + `cmake_minimum_required` before `project()`. +- Removed dead code: the engine's unused `revealFraction`, the allocating + `frame()` JNI method (the view uses `frameInto`). - The native prop behind `direction` is now `rollDirection`. The public prop is unchanged — the wrapper maps it — but `direction` is Yoga's layout property, and because a Hybrid View's props derive from `ViewProps` React Native parsed ours too, logging `Could not parse yoga::Direction: up` on every update. -## 0.1.0 (2026-09-18) - - Initial release: native rolling number view (iOS + Android) built with Nitro Modules. - `value`-driven digit roll with easing/spring, `stagger` and `direction`. - Formatting: fraction digits, grouping and decimal separators, prefix/suffix with independent font sizes and `top` / `bottom` / `baseline` / `center` pinning, zero padding, negatives. diff --git a/packages/react-native-nitro-rolling-number/README.md b/packages/react-native-nitro-rolling-number/README.md index c421ac7d..48d6ab3d 100644 --- a/packages/react-native-nitro-rolling-number/README.md +++ b/packages/react-native-nitro-rolling-number/README.md @@ -26,6 +26,8 @@ bun add react-native-nitro-rolling-number react-native-nitro-modules cd ios && pod install ``` +> **Android and Nitro 0.37:** Nitro never hands a Hybrid View its `backgroundColor`, `border*`, `opacity`, `transform`, `testID` or accessibility props on Android with React Native 0.86+. Fixed upstream in [margelo/nitro#1655](https://github.com/margelo/nitro/pull/1655); until it ships, apply the [patch from this repository](https://github.com/ronickg/react-native-nitro-rolling-number/blob/main/patches/react-native-nitro-modules@0.37.1.patch) with `patchedDependencies` (Bun) or patch-package. + ## Usage ```tsx @@ -137,7 +139,7 @@ scroll or drag handler drives the number. | Prop | Type | Default | Description | | --- | --- | --- | --- | -| `value` | `number` | – | The number to display. | +| `value` | `number` | – | The number to display. Shown with at most 18 digits: `|value| × 10^fractionDigits` is clamped at 10^17, and a JS number carries exact integers only up to 2^53. | | `fractionDigits` | `number` | `0` | Digits after the decimal separator. | | `minimumIntegerDigits` | `number` | `1` | Zero-pads the integer part. | | `groupingSeparator` | `string` | `''` | Inserted every three integer digits. | diff --git a/packages/react-native-nitro-rolling-number/android/CMakeLists.txt b/packages/react-native-nitro-rolling-number/android/CMakeLists.txt index c6185b45..9b4182c1 100644 --- a/packages/react-native-nitro-rolling-number/android/CMakeLists.txt +++ b/packages/react-native-nitro-rolling-number/android/CMakeLists.txt @@ -1,5 +1,5 @@ +cmake_minimum_required(VERSION 3.22.1) project(NitroRollingNumber) -cmake_minimum_required(VERSION 3.9.0) set (PACKAGE_NAME NitroRollingNumber) set (CMAKE_VERBOSE_MAKEFILE ON) diff --git a/packages/react-native-nitro-rolling-number/android/build.gradle b/packages/react-native-nitro-rolling-number/android/build.gradle index 10c95105..8e91b3da 100644 --- a/packages/react-native-nitro-rolling-number/android/build.gradle +++ b/packages/react-native-nitro-rolling-number/android/build.gradle @@ -4,8 +4,14 @@ buildscript { mavenCentral() } - dependencies { - classpath "com.android.tools.build:gradle:9.3.1" + // Inside a React Native app the root project already has the Android Gradle + // plugin on its classpath and every subproject inherits it; pinning a second + // version here would only fight the app's. The pin is for building this + // library on its own. + if (project == rootProject) { + dependencies { + classpath "com.android.tools.build:gradle:9.3.1" + } } } @@ -108,8 +114,8 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } sourceSets { diff --git a/packages/react-native-nitro-rolling-number/android/gradle.properties b/packages/react-native-nitro-rolling-number/android/gradle.properties index 0deaa902..c4a79501 100644 --- a/packages/react-native-nitro-rolling-number/android/gradle.properties +++ b/packages/react-native-nitro-rolling-number/android/gradle.properties @@ -1,4 +1,3 @@ -NitroRollingNumber_kotlinVersion=2.1.21 NitroRollingNumber_minSdkVersion=23 NitroRollingNumber_targetSdkVersion=36 NitroRollingNumber_compileSdkVersion=36 diff --git a/packages/react-native-nitro-rolling-number/android/src/main/cpp/JRollingEngine.cpp b/packages/react-native-nitro-rolling-number/android/src/main/cpp/JRollingEngine.cpp index 423fb28d..bc54c8ab 100644 --- a/packages/react-native-nitro-rolling-number/android/src/main/cpp/JRollingEngine.cpp +++ b/packages/react-native-nitro-rolling-number/android/src/main/cpp/JRollingEngine.cpp @@ -34,7 +34,6 @@ void JRollingEngine::registerNatives() { makeNativeMethod("setRevealMilestoneHold", JRollingEngine::setRevealMilestoneHold), makeNativeMethod("revealMilestonesReached", JRollingEngine::revealMilestonesReached), makeNativeMethod("revealMilestoneValue", JRollingEngine::revealMilestoneValue), - makeNativeMethod("frame", JRollingEngine::frame), makeNativeMethod("frameInto", JRollingEngine::frameInto), makeNativeMethod("shimmerPhase", JRollingEngine::shimmerPhase), makeNativeMethod("targetValue", JRollingEngine::targetValue), @@ -125,26 +124,6 @@ double JRollingEngine::revealMilestoneValue(int index) { return engine_.revealMilestoneValue(index); } -jni::local_ref JRollingEngine::frame() { - const auto& wheels = engine_.wheels(); - const size_t count = wheels.size(); - std::vector data; - data.reserve(4 + count * 4); - data.push_back(engine_.signFactor()); - data.push_back(engine_.loadingProgress()); - data.push_back(engine_.revealScale()); - data.push_back(static_cast(count)); - for (const auto& w : wheels) { - data.push_back(w.position); - data.push_back(w.width); - data.push_back(w.linear ? 1.0 : 0.0); - data.push_back(w.blankZero ? 1.0 : 0.0); - } - auto array = jni::JArrayDouble::newArray(data.size()); - array->setRegion(0, data.size(), data.data()); - return array; -} - int JRollingEngine::frameInto(jni::alias_ref out) { const auto& wheels = engine_.wheels(); const size_t count = wheels.size(); diff --git a/packages/react-native-nitro-rolling-number/android/src/main/cpp/JRollingEngine.hpp b/packages/react-native-nitro-rolling-number/android/src/main/cpp/JRollingEngine.hpp index 7ef295bd..a6480f30 100644 --- a/packages/react-native-nitro-rolling-number/android/src/main/cpp/JRollingEngine.hpp +++ b/packages/react-native-nitro-rolling-number/android/src/main/cpp/JRollingEngine.hpp @@ -42,10 +42,10 @@ class JRollingEngine final : public jni::HybridClass { void setRevealMilestoneHold(double holdSeconds); int revealMilestonesReached(); double revealMilestoneValue(int index); - /// [signFactor, loadingProgress, revealScale, wheelCount, then (position, width, linear, blankZero) per wheel] - jni::local_ref frame(); - /// Writes the render state into `out` (same layout as `frame()`) without - /// allocating; returns the number of doubles written, or -1 if `out` is too small. + /// Writes the render state into `out` without allocating: + /// `[signFactor, loadingProgress, revealScale, wheelCount, then (position, + /// width, linear, blankZero) per wheel]`. Returns the number of doubles + /// written, or -1 if `out` is too small. int frameInto(jni::alias_ref out); double shimmerPhase(double now, double periodSeconds); double targetValue(); diff --git a/packages/react-native-nitro-rolling-number/android/src/main/java/com/margelo/nitro/nitrorollingnumber/RollingEngine.kt b/packages/react-native-nitro-rolling-number/android/src/main/java/com/margelo/nitro/nitrorollingnumber/RollingEngine.kt index 4a491323..4dc8ab86 100644 --- a/packages/react-native-nitro-rolling-number/android/src/main/java/com/margelo/nitro/nitrorollingnumber/RollingEngine.kt +++ b/packages/react-native-nitro-rolling-number/android/src/main/java/com/margelo/nitro/nitrorollingnumber/RollingEngine.kt @@ -37,9 +37,11 @@ class RollingEngine { external fun setRevealMilestoneHold(holdSeconds: Double) external fun revealMilestonesReached(): Int external fun revealMilestoneValue(index: Int): Double - /** `[signFactor, loadingProgress, revealScale, wheelCount, (position, width, linear, blankZero)…]` */ - external fun frame(): DoubleArray - /** Allocation-free [frame]: fills [out] and returns the number of doubles written, or -1 if it is too small. */ + /** + * Fills [out] with the render state without allocating: + * `[signFactor, loadingProgress, revealScale, wheelCount, (position, width, linear, blankZero)…]`. + * Returns the number of doubles written, or -1 if [out] is too small. + */ external fun frameInto(out: DoubleArray): Int external fun shimmerPhase(now: Double, periodSeconds: Double): Double external fun targetValue(): Double diff --git a/packages/react-native-nitro-rolling-number/android/src/main/java/com/margelo/nitro/nitrorollingnumber/RollingNumberView.kt b/packages/react-native-nitro-rolling-number/android/src/main/java/com/margelo/nitro/nitrorollingnumber/RollingNumberView.kt index 056c5eaf..d569277a 100644 --- a/packages/react-native-nitro-rolling-number/android/src/main/java/com/margelo/nitro/nitrorollingnumber/RollingNumberView.kt +++ b/packages/react-native-nitro-rolling-number/android/src/main/java/com/margelo/nitro/nitrorollingnumber/RollingNumberView.kt @@ -2,9 +2,12 @@ package com.margelo.nitro.nitrorollingnumber import android.animation.ValueAnimator import android.content.Context +import android.content.res.Configuration +import android.database.ContentObserver import android.graphics.Canvas import android.graphics.Color import android.graphics.LinearGradient +import android.graphics.Matrix import android.graphics.Paint import android.graphics.PorterDuff import android.graphics.PorterDuffXfermode @@ -12,11 +15,15 @@ import android.graphics.Rect import android.graphics.Shader import android.graphics.Typeface import android.os.Build +import android.os.Handler +import android.os.Looper import android.os.SystemClock +import android.provider.Settings import android.text.TextPaint import android.util.TypedValue import android.view.Choreographer import android.view.View +import android.view.accessibility.AccessibilityEvent import com.facebook.react.common.assets.ReactFontManager import kotlin.math.abs import kotlin.math.ceil @@ -104,6 +111,7 @@ class RollingNumberView(context: Context) : View(context) { set(value) { if (field == value) return field = value + affixBlocksDirty = true engine.setFormat(value.fractionDigits, value.minimumIntegerDigits) if (engine.hasShownValue()) reportIntrinsicSize() invalidate() @@ -148,7 +156,7 @@ class RollingNumberView(context: Context) : View(context) { field = value engine.setReduceMotion(animationsDisabled()) engine.setLoading(value, now()) - updateAccessibility() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) stateDescription = if (value) "Loading" else null scheduleFrameIfNeeded() invalidate() } @@ -294,6 +302,17 @@ class RollingNumberView(context: Context) : View(context) { private class Wheel(var position: Double = 0.0, var width: Double = 1.0, var linear: Boolean = false, var blankZero: Boolean = false) private val engine = RollingEngine() + /** Reused every frame so the draw path stops allocating once warm. */ + private val frameElements = ElementList() + private val settledElements = ElementList() + private val settledWheels = ArrayList() + /** The affixes split from the space they keep against the digits (RTL only). */ + private var affixBlocksDirty = true + private var affixRtl = false + private var prefixInk = "" + private var prefixGap = "" + private var suffixInk = "" + private var suffixGap = "" private val density = context.resources.displayMetrics.density private var fonts: FontSet = FontSet(Typography()) private var fontScale = 1f @@ -327,6 +346,12 @@ class RollingNumberView(context: Context) : View(context) { private val shimmerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP) } + /** The glint's gradient, rebuilt only when its colors or the content width change; a frame just slides it. */ + private var shimmerGradient: LinearGradient? = null + private var shimmerGradientWidth = -1f + private var shimmerGradientBase = 0 + private var shimmerGradientHighlight = 0 + private val shimmerMatrix = Matrix() init { importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES @@ -366,7 +391,6 @@ class RollingNumberView(context: Context) : View(context) { // onIntrinsicSizeChange, onRevealEnd and onRevealMilestone are the hybrid's // wiring, not the element's props: they stay across recycling (the hybrid // clears its own callback props). - contentDescription = null invalidate() } @@ -427,14 +451,30 @@ class RollingNumberView(context: Context) : View(context) { override fun onDetachedFromWindow() { stopAnimation() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + context.contentResolver.unregisterContentObserver(animatorScaleObserver) + } super.onDetachedFromWindow() } override fun onAttachedToWindow() { super.onAttachedToWindow() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + animatorScale = -1f + context.contentResolver.registerContentObserver( + Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE), false, animatorScaleObserver, + ) + } scheduleFrameIfNeeded() } + override fun onConfigurationChanged(newConfig: Configuration?) { + super.onConfigurationChanged(newConfig) + // The paints resolve the theme's text color and the system font scale when + // they are built; a light/dark switch or a text-size change rebuilds them. + rebuildFonts() + } + // endregion // region Frame loop @@ -448,12 +488,27 @@ class RollingNumberView(context: Context) : View(context) { } } + /** + * The animator duration scale on API < 33, read once and refreshed by + * [animatorScaleObserver] (a settings query per `animateTo` was a binder + * call on every value update). -1 = not read yet. + */ + private var animatorScale = -1f + private val animatorScaleObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + animatorScale = -1f + } + } + /** True when the user removed animations (Android's animator scale is 0), the equivalent of Reduce Motion. */ private fun animationsDisabled(): Boolean { val scale = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ValueAnimator.getDurationScale() } else { - android.provider.Settings.Global.getFloat(context.contentResolver, android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + if (animatorScale < 0f) { + animatorScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + } + animatorScale } return scale <= 0f } @@ -509,23 +564,54 @@ class RollingNumberView(context: Context) : View(context) { // region Layout - private class Element( - val wheelIndex: Int, // -1 for glyph elements - val text: String?, - val role: GlyphRole, - val width: Float, - val fullWidth: Float, - val factor: Double, - ) + private class Element { + var wheelIndex = -1 // -1 for glyph elements + var text: String? = null + var role = GlyphRole.DIGIT + var width = 0f + var fullWidth = 0f + var factor = 0.0 + } + + /** A list of pooled [Element]s: filling it allocates nothing once it has grown to the run's length. */ + private class ElementList { + private val items = ArrayList() + var size = 0 + private set + + fun clear() { + size = 0 + } + + fun next(): Element { + if (size == items.size) items.add(Element()) + return items[size++] + } + + operator fun get(index: Int): Element = items[index] - private fun buildElements(fonts: FontSet, wheels: List, signFactor: Double): List { - val elements = ArrayList(wheels.size * 2 + 4) + fun totalWidth(): Float { + var total = 0f + for (i in 0 until size) total += items[i].width + return total + } + } + + /** Lays the run out into [out] (emptied first). */ + private fun buildElements(fonts: FontSet, wheels: List, signFactor: Double, out: ElementList) { + out.clear() val fd = format.fractionDigits fun addGlyph(text: String, role: GlyphRole, factor: Double) { if (text.isEmpty() || factor <= 0.0) return val width = fonts.width(text, role) - elements.add(Element(-1, text, role, (width * factor).toFloat(), width, factor)) + val e = out.next() + e.wheelIndex = -1 + e.text = text + e.role = role + e.width = (width * factor).toFloat() + e.fullWidth = width + e.factor = factor } // Under a right-to-left layout the prefix belongs at the start edge - the @@ -535,32 +621,47 @@ class RollingNumberView(context: Context) : View(context) { // space an affix keeps against the digits stays against them: " USD" after // the number is "USD " before it. val rtl = isRtl - val prefix = if (rtl) splitAffix(format.prefix, spaceAtEnd = true) else Pair(format.prefix, "") - val suffix = if (rtl) splitAffix(format.suffix, spaceAtEnd = false) else Pair(format.suffix, "") + if (affixBlocksDirty || affixRtl != rtl) updateAffixBlocks(rtl) if (rtl) { - addGlyph(suffix.first, GlyphRole.SUFFIX, 1.0) - addGlyph(suffix.second, GlyphRole.SUFFIX, 1.0) + addGlyph(suffixInk, GlyphRole.SUFFIX, 1.0) + addGlyph(suffixGap, GlyphRole.SUFFIX, 1.0) } else { // Sign first, then the currency prefix: "-$1,234.50". addGlyph("-", GlyphRole.DIGIT, signFactor) - addGlyph(prefix.first, GlyphRole.PREFIX, 1.0) + addGlyph(prefixInk, GlyphRole.PREFIX, 1.0) } for (power in wheels.indices.reversed()) { val wheel = wheels[power] if (wheel.width > 0.0) { - elements.add(Element(power, null, GlyphRole.DIGIT, (fonts.digitWidth * wheel.width).toFloat(), fonts.digitWidth, wheel.width)) + val e = out.next() + e.wheelIndex = power + e.text = null + e.role = GlyphRole.DIGIT + e.width = (fonts.digitWidth * wheel.width).toFloat() + e.fullWidth = fonts.digitWidth + e.factor = wheel.width } if (power > fd && (power - fd) % 3 == 0) addGlyph(format.groupingSeparator, GlyphRole.DIGIT, wheel.width) if (fd > 0 && power == fd) addGlyph(format.decimalSeparator, GlyphRole.DIGIT, 1.0) } if (rtl) { - addGlyph(prefix.second, GlyphRole.PREFIX, 1.0) - addGlyph(prefix.first, GlyphRole.PREFIX, 1.0) + addGlyph(prefixGap, GlyphRole.PREFIX, 1.0) + addGlyph(prefixInk, GlyphRole.PREFIX, 1.0) addGlyph("-", GlyphRole.DIGIT, signFactor) } else { - addGlyph(suffix.first, GlyphRole.SUFFIX, 1.0) + addGlyph(suffixInk, GlyphRole.SUFFIX, 1.0) } - return elements + } + + private fun updateAffixBlocks(rtl: Boolean) { + val prefix = if (rtl) splitAffix(format.prefix, spaceAtEnd = true) else Pair(format.prefix, "") + val suffix = if (rtl) splitAffix(format.suffix, spaceAtEnd = false) else Pair(format.suffix, "") + prefixInk = prefix.first + prefixGap = prefix.second + suffixInk = suffix.first + suffixGap = suffix.second + affixRtl = rtl + affixBlocksDirty = false } /** Whether this view is laid out right-to-left. The hybrid sets it from the `rightToLeft` prop. */ @@ -590,10 +691,11 @@ class RollingNumberView(context: Context) : View(context) { } private fun settledWidth(): Float { - val settled = List(engine.settledPowerCount()) { Wheel() } - var width = 0f - for (element in buildElements(fonts, settled, if (engine.settledNegative()) 1.0 else 0.0)) width += element.width - return width + val count = engine.settledPowerCount() + while (settledWheels.size < count) settledWheels.add(Wheel()) + while (settledWheels.size > count) settledWheels.removeAt(settledWheels.size - 1) + buildElements(fonts, settledWheels, if (engine.settledNegative()) 1.0 else 0.0, settledElements) + return settledElements.totalWidth() } /** Formats the target the way it is displayed, for TalkBack. */ @@ -611,18 +713,19 @@ class RollingNumberView(context: Context) : View(context) { return sb.toString() } - private fun updateAccessibility() { - contentDescription = if (loading) accessibleText() + ", loading" else accessibleText() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - stateDescription = if (loading) "Loading" else null - } + // TalkBack reads the settled figure when it asks for it, so a value update + // (which can come every frame) formats nothing. + override fun getContentDescription(): CharSequence? { + if (!engine.hasShownValue()) return null + return if (loading) accessibleText() + ", loading" else accessibleText() } /** A narrower settled size waiting for the current roll to finish before it is reported. */ private var pendingSizeReport = false private fun reportIntrinsicSize() { - updateAccessibility() + // A no-op unless an accessibility service is on; then a focused figure re-announces. + sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) // The reported size is always the full-size one: with shrink-to-fit the view // keeps its height and the scaled number is centred inside it when drawing. val widthDp = ceil(settledWidth() / density) @@ -655,9 +758,9 @@ class RollingNumberView(context: Context) : View(context) { if (!engine.hasShownValue()) return syncFromEngine() val fonts = this.fonts - val elements = buildElements(fonts, wheels, signFactor) - var total = 0f - for (element in elements) total += element.width + val elements = frameElements + buildElements(fonts, wheels, signFactor, elements) + val total = elements.totalWidth() updateFontScale(total) val fit = fontScale @@ -683,11 +786,13 @@ class RollingNumberView(context: Context) : View(context) { // Everything drawn in this layer is what the sweep gets composited onto. val layer = if (dim > 0f) canvas.saveLayer(-1f, -1f, total + 1f, fonts.lineHeight + 1f, null) else -1 var x = 0f - for (element in elements) { + for (i in 0 until elements.size) { + val element = elements[i] + val text = element.text if (element.wheelIndex >= 0) { drawWheel(canvas, fonts, wheels[element.wheelIndex], x, element.width) - } else if (element.text != null) { - drawGlyph(canvas, fonts, element.text, element.role, x, element.width, element.fullWidth, element.factor) + } else if (text != null) { + drawGlyph(canvas, fonts, text, element.role, x, element.width, element.fullWidth, element.factor) } x += element.width } @@ -711,10 +816,20 @@ class RollingNumberView(context: Context) : View(context) { val progress = SHIMMER_SEED + (1f - SHIMMER_SEED) * phase // Core at width * (2p - 0.5): enters at the left edge, exits past the right. val startX = contentWidth * (2f * progress - 1f) - shimmerPaint.shader = LinearGradient( - startX, 0f, startX + contentWidth, SHIMMER_SLANT * contentWidth, - intArrayOf(base, highlight, base), floatArrayOf(0.1f, 0.5f, 0.9f), Shader.TileMode.CLAMP, - ) + var gradient = shimmerGradient + if (gradient == null || shimmerGradientWidth != contentWidth || shimmerGradientBase != base || shimmerGradientHighlight != highlight) { + gradient = LinearGradient( + 0f, 0f, contentWidth, SHIMMER_SLANT * contentWidth, + intArrayOf(base, highlight, base), floatArrayOf(0.1f, 0.5f, 0.9f), Shader.TileMode.CLAMP, + ) + shimmerGradient = gradient + shimmerGradientWidth = contentWidth + shimmerGradientBase = base + shimmerGradientHighlight = highlight + shimmerPaint.shader = gradient + } + shimmerMatrix.setTranslate(startX, 0f) + gradient.setLocalMatrix(shimmerMatrix) shimmerPaint.alpha = (dim * 255f).toInt().coerceIn(0, 255) canvas.drawRect(-1f, -1f, contentWidth + 1f, fonts.lineHeight + 1f, shimmerPaint) } diff --git a/packages/react-native-nitro-rolling-number/cpp/RollingEngine.cpp b/packages/react-native-nitro-rolling-number/cpp/RollingEngine.cpp index 318b5ba9..d0d1258c 100644 --- a/packages/react-native-nitro-rolling-number/cpp/RollingEngine.cpp +++ b/packages/react-native-nitro-rolling-number/cpp/RollingEngine.cpp @@ -13,6 +13,11 @@ namespace margelo::nitro::nitrorollingnumber { namespace { constexpr int kMaxPowerCount = 18; +/// Upper bound of |value| × 10^fractionDigits, shared by `setValue` and +/// `animateTo` so a jump and a roll to the same figure show the same digits. +/// 10^17 still fits the 18 wheels; beyond 2^53 a double has no exact integers +/// anyway. +constexpr double kMaxMagnitude = 1e17; constexpr double kLoadingFadeSeconds = 0.25; constexpr double kPi = 3.14159265358979323846; @@ -111,7 +116,7 @@ RollingEngine::Target RollingEngine::makeTarget(double value) const { if (!std::isfinite(scaled)) { scaled = 0; } - uint64_t magnitude = static_cast(std::min(scaled, 1e17)); + uint64_t magnitude = static_cast(std::min(scaled, kMaxMagnitude)); uint64_t integerPart = magnitude / kPow10[fractionDigits_]; int intDigits = std::max(minimumIntegerDigits_, digitCount(integerPart)); return Target{magnitude, value < 0 && magnitude > 0, std::min(kMaxPowerCount, intDigits + fractionDigits_)}; @@ -167,7 +172,7 @@ void RollingEngine::setValue(double value) { if (!std::isfinite(scaled)) { scaled = 0; } - scaled = std::min(scaled, 1e15); + scaled = std::min(scaled, kMaxMagnitude); const double whole = std::floor(scaled); const uint64_t integerPart = static_cast(whole) / kPow10[fd]; const int needed = std::min(kMaxPowerCount, std::max(minimumIntegerDigits_, digitCount(integerPart)) + fd); @@ -278,6 +283,7 @@ void RollingEngine::animateTo(double value, double now) { delay = std::min(delay, pending); } next.delays.push_back(delay); + next.maxDelay = std::max(next.maxDelay, delay); } next.signFrom = signFactor_; next.signTo = target.negative ? 1.0 : 0.0; @@ -506,10 +512,6 @@ double RollingEngine::tally(double t, double rampIn, double rampOut) { return distance / area; } -double RollingEngine::revealFraction(double t, double /* magnitude */) { - return tally(t, kTallyRampIn, kTallyFinalRampOut); -} - void RollingEngine::applyReveal(double elapsed) { const bool spin = revealStyle_ == 1 && !reveal_.holding; // The scale: the figure's size as the count grows, times the punches. A @@ -638,12 +640,7 @@ void RollingEngine::applyRevealSpin(double elapsed) { void RollingEngine::apply(double elapsed) { const Transition& tr = transition_; - if (wheels_.size() != tr.wheels.size()) { - wheels_.resize(tr.wheels.size()); - for (size_t i = 0; i < tr.wheels.size(); i++) { - wheels_[i] = tr.wheels[i].from; - } - } + wheels_.resize(tr.wheels.size()); for (size_t i = 0; i < tr.wheels.size(); i++) { const WheelTransition& wt = tr.wheels[i]; const double delay = i < tr.delays.size() ? tr.delays[i] : 0; @@ -669,11 +666,7 @@ void RollingEngine::finish() { bool RollingEngine::tick(double now) { if (transition_.active) { const double elapsed = now - transition_.start; - double maxDelay = 0; - for (double d : transition_.delays) { - maxDelay = std::max(maxDelay, d); - } - if (elapsed >= transition_.duration + maxDelay) { + if (elapsed >= transition_.duration + transition_.maxDelay) { finish(); } else { apply(elapsed); diff --git a/packages/react-native-nitro-rolling-number/cpp/RollingEngine.hpp b/packages/react-native-nitro-rolling-number/cpp/RollingEngine.hpp index 1837ee3a..0d999a64 100644 --- a/packages/react-native-nitro-rolling-number/cpp/RollingEngine.hpp +++ b/packages/react-native-nitro-rolling-number/cpp/RollingEngine.hpp @@ -52,6 +52,10 @@ class RollingEngine final { /// Shows `value` immediately with continuously positioned wheels (odometer /// carry rule). Cancels any running roll. + /// + /// Both this and `animateTo` work on |value| × 10^fractionDigits, clamped to + /// `kMaxMagnitude` (10^17, 18 wheels at most); a double only carries exact + /// integers up to 2^53, so figures beyond that lose their low digits either way. void setValue(double value); /// Rolls every wheel to `value` (shortest path in the roll direction, /// blank↔digit for appearing/disappearing wheels). Snaps when nothing has @@ -164,6 +168,8 @@ class RollingEngine final { double start = 0; double duration = 0; std::vector delays; // per wheel, least significant first + /// The largest entry of `delays`, so a tick needn't scan them. + double maxDelay = 0; std::vector wheels; double signFrom = 0; double signTo = 0; @@ -215,8 +221,6 @@ class RollingEngine final { void applyRevealSpin(double elapsed); /// A punch `tau` seconds after its kick: one overshoot peaking at `overshoot`, then settling. static double punch(double tau, double overshoot); - /// The reveal's count as a fraction of the target at clock fraction `t`. - static double revealFraction(double t, double magnitude); /// One tally run: constant-rate count with a ramp in and a brake out. static double tally(double t, double rampIn, double rampOut); double ease(double t) const; diff --git a/packages/react-native-nitro-rolling-number/cpp/__tests__/RollingEngineTest.cpp b/packages/react-native-nitro-rolling-number/cpp/__tests__/RollingEngineTest.cpp index 7b1478f7..1ae0fbe4 100644 --- a/packages/react-native-nitro-rolling-number/cpp/__tests__/RollingEngineTest.cpp +++ b/packages/react-native-nitro-rolling-number/cpp/__tests__/RollingEngineTest.cpp @@ -474,6 +474,54 @@ static void jackpotRevealEdgeCases() { CHECK(!z.isRevealing() && near(z.revealScale(), 1)); } +// `setValue` used to clamp the scaled magnitude at 10^15 while `animateTo` +// clamped at 10^17, so a jump and a roll to one figure showed different digits +// (with 9 fraction digits, anything above 10^6 collapsed to "1,000,000"). +static void jumpAndRollAgreeOnLargeValues() { + RollingEngine jump; + jump.setFormat(9, 1); + jump.setValue(123456.5); + RollingEngine roll; + roll.setFormat(9, 1); + roll.animateTo(123456.5, 0); + CHECK(jump.wheelCount() == roll.wheelCount()); + CHECK(jump.wheelCount() == 15); + for (int i = 0; i < jump.wheelCount(); i++) { + CHECK(near(jump.wheelAt(i).position, roll.wheelAt(i).position)); + } + CHECK(near(jump.wheelAt(14).position, 1) && near(jump.wheelAt(9).position, 6) && near(jump.wheelAt(8).position, 5)); + + RollingEngine big; + big.setFormat(0, 1); + big.setValue(2e15); // exactly representable, above the old 10^15 clamp + CHECK(big.wheelCount() == 16 && near(big.wheelAt(15).position, 2)); + big.animateTo(2e15, 0); + CHECK(big.wheelCount() == 16 && near(big.wheelAt(15).position, 2)); + + RollingEngine over; + over.setFormat(9, 1); + over.setValue(1e12); // 10^21 scaled: clamped to 10^17 on both paths, never more than 18 wheels + RollingEngine overRoll; + overRoll.setFormat(9, 1); + overRoll.animateTo(1e12, 0); + CHECK(over.wheelCount() == overRoll.wheelCount() && over.wheelCount() <= 18); +} + +// The stagger's longest delay is fixed when the roll is built, so a tick +// finishes exactly when the last wheel's roll ends. +static void staggeredRollFinishesAfterLastWheel() { + RollingEngine e; + e.setFormat(0, 1); + e.setTiming(0.5, 0, 0.15, /* stagger */ 0.1, 0); + e.animateTo(0, 0); + e.animateTo(999, 0); // hundreds wheel starts at 0.2, ends at 0.7 + e.tick(0.69); + CHECK(e.needsFrames()); + e.tick(0.7); + CHECK(!e.needsFrames()); + CHECK(near(e.wheelAt(2).position, 9)); +} + int main() { odometerPositions(); tickerRollsShortestPathInDirection(); @@ -486,6 +534,8 @@ int main() { jackpotRevealSpinsReelsAndLocksLeftToRight(); jackpotRevealMilestonesPunchAndHold(); jackpotRevealEdgeCases(); + jumpAndRollAgreeOnLargeValues(); + staggeredRollFinishesAfterLastWheel(); if (failures == 0) { std::printf("RollingEngine: all checks passed\n"); return 0; diff --git a/packages/react-native-nitro-rolling-number/ios/RollingNumberView.swift b/packages/react-native-nitro-rolling-number/ios/RollingNumberView.swift index 44c02c3e..67a3e568 100644 --- a/packages/react-native-nitro-rolling-number/ios/RollingNumberView.swift +++ b/packages/react-native-nitro-rolling-number/ios/RollingNumberView.swift @@ -151,7 +151,6 @@ final class RollingNumberView: UIView { guard loading != oldValue else { return } engine.setReduceMotion(UIAccessibility.isReduceMotionEnabled) engine.setLoading(loading, CACurrentMediaTime()) - updateAccessibility() updateDisplayLinkNeed() render() } @@ -191,11 +190,18 @@ final class RollingNumberView: UIView { case digit, prefix, suffix } + private struct GlyphKey: Hashable { + let text: String + let role: GlyphRole + } + /// Digit / prefix / suffix fonts with per-glyph caches. private final class FontSet { let digit: UIFont let prefix: UIFont let suffix: UIFont + /// The text color resolved for the view's traits: the glyph images bake it + /// in, and `.label` is dynamic (see `traitCollectionDidChange`). let color: UIColor let prefixAlign: AffixAlign let suffixAlign: AffixAlign @@ -203,22 +209,26 @@ final class RollingNumberView: UIView { let lineHeight: CGFloat /// Width of the widest digit glyph. private(set) var digitWidth: CGFloat = 0 - private var glyphCache: [String: NSAttributedString] = [:] - private var widthCache: [String: CGFloat] = [:] - private var imageCache: [String: UIImage] = [:] - private var inkDescentCache: [String: CGFloat] = [:] + // Keyed by a value type rather than a concatenated string: a lookup on the + // frame path must not allocate. + private var glyphCache: [GlyphKey: NSAttributedString] = [:] + private var widthCache: [GlyphKey: CGFloat] = [:] + private var imageCache: [GlyphKey: UIImage] = [:] + private var stripCache: [Bool: UIImage] = [:] + private var inkDescentCache: [GlyphKey: CGFloat] = [:] /// Pixel density the glyph images are rendered at. let renderScale: CGFloat /// Slots in a wheel strip: index -1 (blank) through 10 (the 0 after 9). static let stripSlots = 12 - init(_ t: Typography) { - renderScale = max(1, UIScreen.main.scale) + init(_ t: Typography, traits: UITraitCollection) { + // The view's own display, not the main screen's (deprecated, and absent on visionOS). + renderScale = max(1, traits.displayScale) let scale = RollingNumberView.systemFontMultiplier(t) digit = RollingNumberView.makeFont(size: t.fontSize * scale, weight: t.fontWeight, family: t.fontFamily) prefix = RollingNumberView.makeFont(size: (t.prefixFontSize ?? t.fontSize) * scale, weight: t.fontWeight, family: t.fontFamily) suffix = RollingNumberView.makeFont(size: (t.suffixFontSize ?? t.fontSize) * scale, weight: t.fontWeight, family: t.fontFamily) - color = t.color + color = t.color.resolvedColor(with: traits) prefixAlign = t.prefixAlign suffixAlign = t.suffixAlign lineHeight = ceil(digit.lineHeight) @@ -234,7 +244,7 @@ final class RollingNumberView: UIView { } func attributed(_ text: String, role: GlyphRole) -> NSAttributedString { - let key = cacheKey(text, role) + let key = GlyphKey(text: text, role: role) if let cached = glyphCache[key] { return cached } let string = NSAttributedString(string: text, attributes: [ .font: font(for: role), @@ -245,7 +255,7 @@ final class RollingNumberView: UIView { } func width(of text: String, role: GlyphRole) -> CGFloat { - let key = cacheKey(text, role) + let key = GlyphKey(text: text, role: role) if let cached = widthCache[key] { return cached } let width = attributed(text, role: role).size().width widthCache[key] = width @@ -257,7 +267,7 @@ final class RollingNumberView: UIView { /// rasterization pass per glyph. Profiling 24 rolling views showed the /// per-glyph `NSAttributedString.draw` dominating the main thread. func image(_ text: String, role: GlyphRole) -> UIImage? { - let key = cacheKey(text, role) + let key = GlyphKey(text: text, role: role) if let cached = imageCache[key] { return cached } let string = attributed(text, role: role) let size = string.size() @@ -278,8 +288,7 @@ final class RollingNumberView: UIView { /// digit centred in `digitWidth`. A wheel layer shows one slot-high window /// of it and just moves the strip, so a roll is a position change. func strip(blankZero: Bool) -> UIImage? { - let key = blankZero ? "strip|blank0" : "strip" - if let cached = imageCache[key] { return cached } + if let cached = stripCache[blankZero] { return cached } guard digitWidth > 0, lineHeight > 0 else { return nil } let format = UIGraphicsImageRendererFormat() format.scale = renderScale @@ -294,13 +303,13 @@ final class RollingNumberView: UIView { attributed(text, role: .digit).draw(at: CGPoint(x: (digitWidth - w) / 2, y: CGFloat(slot) * lineHeight)) } } - imageCache[key] = image + stripCache[blankZero] = image return image } /// How far `text`'s ink hangs below the baseline (0 for digits and capitals). func inkDescent(_ text: String, role: GlyphRole) -> CGFloat { - let key = "ink|" + cacheKey(text, role) + let key = GlyphKey(text: text, role: role) if let cached = inkDescentCache[key] { return cached } let line = CTLineCreateWithAttributedString(attributed(text, role: role)) let bounds = CTLineGetImageBounds(line, nil) @@ -328,21 +337,21 @@ final class RollingNumberView: UIView { return affixBaseline - f.ascender } } - - private func cacheKey(_ text: String, _ role: GlyphRole) -> String { - switch role { - case .digit: return "d|" + text - case .prefix: return "p|" + text - case .suffix: return "s|" + text - } - } } // MARK: - State private var engine = Engine() - private var fonts: FontSet + private lazy var fonts = FontSet(Typography(), traits: traitCollection) private var fontScale: CGFloat = 1 + /// Reused every frame so the render path stops allocating once warm. + private var wheelBuffer: [Engine.Wheel] = [] + private var elementBuffer: [Element] = [] + private var settledWheels: [Engine.Wheel] = [] + private var settledBuffer: [Element] = [] + /// The affixes split from the space they keep against the digits (RTL only), + /// recomputed when the format or the direction changes. + private var affixBlocks = AffixBlocks(prefix: "", suffix: "", rtl: false) private var displayLink: CADisplayLink? private var lastReportedSize: CGSize = .zero @@ -363,12 +372,35 @@ final class RollingNumberView: UIView { var layer: CALayer /// Wheels only: which strip variant the layer currently shows. var blankZero: Bool + /// Glyphs only: the image's top within the line box, fixed for the font set. + var glyphTop: CGFloat + } + + private struct AffixBlocks { + let prefix: String + let suffix: String + let rtl: Bool + let prefixInk: String + let prefixGap: String + let suffixInk: String + let suffixGap: String + + init(prefix: String, suffix: String, rtl: Bool) { + self.prefix = prefix + self.suffix = suffix + self.rtl = rtl + let p = rtl ? RollingNumberView.splitAffix(prefix, spaceAtEnd: true) : (ink: prefix, gap: "") + let s = rtl ? RollingNumberView.splitAffix(suffix, spaceAtEnd: false) : (ink: suffix, gap: "") + prefixInk = p.ink + prefixGap = p.gap + suffixInk = s.ink + suffixGap = s.gap + } } // MARK: - Lifecycle override init(frame: CGRect) { - fonts = FontSet(Typography()) super.init(frame: frame) isOpaque = false backgroundColor = .clear @@ -404,6 +436,17 @@ final class RollingNumberView: UIView { render() } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + // The glyph images bake the resolved text color and the pixel density in: + // a light/dark switch (`.label` is dynamic) or a move to another display + // re-rasterizes them. + let resolved = typography.color.resolvedColor(with: traitCollection) + if resolved != fonts.color || max(1, traitCollection.displayScale) != fonts.renderScale { + rebuildFonts() + } + } + @objc private func contentSizeCategoryDidChange() { guard typography.allowFontScaling else { return } rebuildFonts() @@ -430,8 +473,6 @@ final class RollingNumberView: UIView { // `onIntrinsicSizeChange`, `onRevealEnd` and `onRevealMilestone` are the // hybrid's wiring, not the element's props: they stay across recycling // (the hybrid clears its own callback props). - accessibilityLabel = nil - accessibilityValue = nil clearSlots() layer.contents = nil usesBitmap = false @@ -546,7 +587,8 @@ final class RollingNumberView: UIView { // MARK: - Typography private func rebuildFonts() { - fonts = FontSet(typography) + fonts = FontSet(typography, traits: traitCollection) + contentLayer.contentsScale = fonts.renderScale fontScale = 1 // Every cached glyph image belongs to the old font set. clearSlots() @@ -631,13 +673,19 @@ final class RollingNumberView: UIView { var factor: Double } - private func currentWheels() -> [Engine.Wheel] { + /// Pulls the engine's wheels into `wheelBuffer`. + private func syncWheels() { + wheelBuffer.removeAll(keepingCapacity: true) let count = Int(engine.wheelCount()) - return (0.. [Element] { - var elements: [Element] = [] + /// Lays the run out into `elements` (emptied first, capacity kept). + private func buildElements(into elements: inout [Element], wheels: [Engine.Wheel], signFactor: Double) { + elements.removeAll(keepingCapacity: true) + let fonts = self.fonts let fd = format.fractionDigits func addGlyph(_ text: String, role: GlyphRole, factor: Double) { @@ -653,15 +701,17 @@ final class RollingNumberView: UIView { // space an affix keeps against the digits stays against them: " USD" after // the number is "USD " before it. let rtl = isRTL - let prefix = rtl ? Self.splitAffix(format.prefix, spaceAtEnd: true) : (ink: format.prefix, gap: "") - let suffix = rtl ? Self.splitAffix(format.suffix, spaceAtEnd: false) : (ink: format.suffix, gap: "") + if affixBlocks.rtl != rtl || affixBlocks.prefix != format.prefix || affixBlocks.suffix != format.suffix { + affixBlocks = AffixBlocks(prefix: format.prefix, suffix: format.suffix, rtl: rtl) + } + let affixes = affixBlocks if rtl { - addGlyph(suffix.ink, role: .suffix, factor: 1) - addGlyph(suffix.gap, role: .suffix, factor: 1) + addGlyph(affixes.suffixInk, role: .suffix, factor: 1) + addGlyph(affixes.suffixGap, role: .suffix, factor: 1) } else { // Sign first, then the currency prefix: "-$1,234.50". addGlyph("-", role: .digit, factor: signFactor) - addGlyph(prefix.ink, role: .prefix, factor: 1) + addGlyph(affixes.prefixInk, role: .prefix, factor: 1) } var power = wheels.count - 1 while power >= 0 { @@ -678,13 +728,12 @@ final class RollingNumberView: UIView { power -= 1 } if rtl { - addGlyph(prefix.gap, role: .prefix, factor: 1) - addGlyph(prefix.ink, role: .prefix, factor: 1) + addGlyph(affixes.prefixGap, role: .prefix, factor: 1) + addGlyph(affixes.prefixInk, role: .prefix, factor: 1) addGlyph("-", role: .digit, factor: signFactor) } else { - addGlyph(suffix.ink, role: .suffix, factor: 1) + addGlyph(affixes.suffixInk, role: .suffix, factor: 1) } - return elements } /// The layout direction this view is in. The hybrid sets @@ -717,8 +766,12 @@ final class RollingNumberView: UIView { private func settledWidth() -> CGFloat { let count = Int(engine.settledPowerCount()) - let settled = (0.. Bool { + guard elements.count == slots.count else { return false } + for index in elements.indices { + switch (elements[index].kind, slots[index].kind) { + case (.wheel, .wheel): + continue + case (.glyph(let text, let role), .glyph(let slotText, let slotRole)) where text == slotText && role == slotRole: + continue + default: + return false } } + return true } private static func wrap10(_ position: Double) -> Double { @@ -925,8 +997,10 @@ final class RollingNumberView: UIView { override func draw(_ rect: CGRect) { guard usesBitmap, engine.hasShownValue(), let ctx = UIGraphicsGetCurrentContext() else { return } let fonts = self.fonts - let wheels = currentWheels() - let elements = buildElements(wheels: wheels, signFactor: engine.signFactor()) + syncWheels() + buildElements(into: &elementBuffer, wheels: wheelBuffer, signFactor: engine.signFactor()) + let wheels = wheelBuffer + let elements = elementBuffer let total = elements.reduce(CGFloat(0)) { $0 + $1.width } updateFontScale(contentWidth: total) let placement = contentPlacement(total: total, lineHeight: fonts.lineHeight) diff --git a/packages/react-native-nitro-rolling-number/package.json b/packages/react-native-nitro-rolling-number/package.json index 7747bcb9..a78e43ce 100644 --- a/packages/react-native-nitro-rolling-number/package.json +++ b/packages/react-native-nitro-rolling-number/package.json @@ -2,9 +2,9 @@ "name": "react-native-nitro-rolling-number", "version": "0.0.0", "description": "Native rolling number (odometer / ticker) view for React Native, built with Nitro. Digits roll natively when the value changes; supports currency prefix/suffix, shrink-to-fit and a loading shimmer.", - "main": "lib/index.js", - "module": "lib/index.js", - "types": "lib/index.d.ts", + "main": "lib/commonjs/src/index.js", + "module": "lib/module/src/index.js", + "types": "lib/typescript/src/index.d.ts", "react-native": "src/index.ts", "source": "src/index.ts", "files": [ @@ -34,7 +34,7 @@ ], "scripts": { "typecheck": "tsc --noEmit", - "build": "rm -rf lib && tsc -p tsconfig.build.json", + "build": "rm -rf lib && tsc -p tsconfig.build.json && tsc -p tsconfig.build.cjs.json && echo '{\"type\":\"module\"}' > lib/module/package.json", "clean": "rm -rf android/build lib", "specs": "nitrogen --logLevel=\"debug\"", "prepack": "bun run build", @@ -69,7 +69,9 @@ "@react-native/babel-preset": "0.87.1", "@react-native/jest-preset": "0.87.1", "@types/jest": "^29.5.13", + "@types/node": "^26.6.1", "@types/react": "^19.2.0", + "@types/react-test-renderer": "^19.3.0", "jest": "^29.7.0", "nitrogen": "0.37.1", "react": "19.2.3", @@ -94,9 +96,11 @@ }, "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typescript/src/index.d.ts", "react-native": "./src/index.ts", - "default": "./lib/index.js" + "import": "./lib/module/src/index.js", + "require": "./lib/commonjs/src/index.js", + "default": "./lib/commonjs/src/index.js" }, "./package.json": "./package.json" }, diff --git a/packages/react-native-nitro-rolling-number/src/RollingNumber.tsx b/packages/react-native-nitro-rolling-number/src/RollingNumber.tsx index 60e64f0d..f378f4ee 100644 --- a/packages/react-native-nitro-rolling-number/src/RollingNumber.tsx +++ b/packages/react-native-nitro-rolling-number/src/RollingNumber.tsx @@ -48,6 +48,8 @@ export interface RollingNumberProps extends Omit { /** * The number to display. Every change rolls each digit natively to its new * glyph, in the direction of the change. The first value is shown instantly. + * At most 18 digits are shown: `|value| × 10^fractionDigits` is clamped at + * 10^17, and a JS number carries exact integers only up to 2^53. */ value: number /** Digits shown after the decimal separator. Default: `0`. */ @@ -198,6 +200,9 @@ export interface RollingNumberHandle { readonly native: RollingNumberRef | null } +// shared-helpers:start +// Kept byte-for-byte identical with packages/react-native-nitro-input/src/NitroInput.tsx +// (a test compares the two blocks); change both together. const FONT_WEIGHTS: Record = { normal: 400, regular: 400, @@ -226,6 +231,7 @@ function toProcessedColor(color: ColorValue | undefined): number | undefined { const processed = processColor(color) return typeof processed === 'number' ? processed : undefined } +// shared-helpers:end interface Size { width: number @@ -296,6 +302,8 @@ export const RollingNumber = forwardRef latestOnRevealEnd.current = onRevealEnd const latestOnRevealMilestone = useRef(onRevealMilestone) latestOnRevealMilestone.current = onRevealMilestone + const latestValue = useRef(value) + latestValue.current = value const [size, setSize] = useState(null) @@ -349,18 +357,20 @@ export const RollingNumber = forwardRef [milestonesKey] ) + // One handle for the component's lifetime: `getValue` reads the latest + // prop through a ref, so a value change doesn't hand the parent a new object. useImperativeHandle( ref, () => ({ jumpTo: (next) => nativeRef.current?.jumpTo(next), animateTo: (next) => nativeRef.current?.animateTo(next), revealTo: (next) => nativeRef.current?.revealTo(next), - getValue: () => nativeRef.current?.value ?? value, + getValue: () => nativeRef.current?.value ?? latestValue.current, get native() { return nativeRef.current }, }), - [value] + [] ) // Every native prop is sent with an explicit value: an optional prop that @@ -387,7 +397,10 @@ export const RollingNumber = forwardRef // The layout direction, resolved the way React Native resolves it for its // own views: the view's `style.direction` if it says, else the app's. Fabric // does not hand a Hybrid View its resolved direction, so native is told. - const layoutDirection = (StyleSheet.flatten(style) as { direction?: unknown } | undefined)?.direction + const layoutDirection = useMemo( + () => (StyleSheet.flatten(style) as { direction?: unknown } | undefined)?.direction, + [style] + ) const rightToLeft = layoutDirection === 'rtl' || (layoutDirection !== 'ltr' && I18nManager.isRTL) return ( diff --git a/packages/react-native-nitro-rolling-number/src/__tests__/RollingNumber.test.tsx b/packages/react-native-nitro-rolling-number/src/__tests__/RollingNumber.test.tsx index b641ffd2..61e30bbf 100644 --- a/packages/react-native-nitro-rolling-number/src/__tests__/RollingNumber.test.tsx +++ b/packages/react-native-nitro-rolling-number/src/__tests__/RollingNumber.test.tsx @@ -1,6 +1,6 @@ import React, { createRef } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { processColor } from 'react-native' +import { I18nManager, processColor } from 'react-native' import { RollingNumber, type RollingNumberHandle } from '../RollingNumber' function nativeProps(renderer: ReactTestRenderer) { @@ -184,6 +184,34 @@ describe('RollingNumber', () => { expect(nativeProps(render()).revealMilestones).toEqual([]) }) + it('keeps one handle for the component\'s lifetime and reads the latest value through it', () => { + const ref = createRef() + const renderer = render() + const handle = ref.current + expect(handle?.getValue()).toBe(1) + act(() => { + renderer.update() + }) + expect(ref.current).toBe(handle) + expect(handle?.getValue()).toBe(2) + }) + + it('resolves the layout direction the way React Native does and aligns to the start edge by default', () => { + expect(nativeProps(render()).textAlign).toBe('auto') + expect(nativeProps(render()).rightToLeft).toBe(false) + // The view's own `direction` wins over the app's. + expect(nativeProps(render()).rightToLeft).toBe(true) + expect(nativeProps(render()).rightToLeft).toBe(true) + + const restore = jest.replaceProperty(I18nManager, 'isRTL', true) + try { + expect(nativeProps(render()).rightToLeft).toBe(true) + expect(nativeProps(render()).rightToLeft).toBe(false) + } finally { + restore.restore() + } + }) + it('exposes revealTo on the handle', () => { const ref = createRef() const renderer = render() diff --git a/packages/react-native-nitro-rolling-number/src/__tests__/SharedHelpers.test.tsx b/packages/react-native-nitro-rolling-number/src/__tests__/SharedHelpers.test.tsx new file mode 100644 index 00000000..f237c70a --- /dev/null +++ b/packages/react-native-nitro-rolling-number/src/__tests__/SharedHelpers.test.tsx @@ -0,0 +1,34 @@ +/** + * `RollingNumber.tsx` and the input's `NitroInput.tsx` carry the same small + * helpers (font-weight mapping, color processing). The two packages publish + * independently, so they cannot share a module without a third package; this + * keeps the copies from drifting instead. Both blocks sit between + * `// shared-helpers:start` and `// shared-helpers:end`. + */ +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' + +function sharedBlock(path: string): string { + const source = readFileSync(path, 'utf8') + const start = source.indexOf('// shared-helpers:start') + const end = source.indexOf('// shared-helpers:end') + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + // The line after the marker names the other file; skip it so the two blocks compare. + return source + .slice(start, end) + .split('\n') + .filter((line) => !line.startsWith('// Kept byte-for-byte') && !line.startsWith('// (a test compares')) + .join('\n') +} + +const here = join(__dirname, '..', 'RollingNumber.tsx') +const sibling = join(__dirname, '..', '..', '..', 'react-native-nitro-input', 'src', 'NitroInput.tsx') + +const describeIfMonorepo = existsSync(sibling) ? describe : describe.skip + +describeIfMonorepo('shared helpers', () => { + it('stay identical between RollingNumber.tsx and NitroInput.tsx', () => { + expect(sharedBlock(here)).toBe(sharedBlock(sibling)) + }) +}) diff --git a/packages/react-native-nitro-rolling-number/src/specs/RollingNumber.nitro.ts b/packages/react-native-nitro-rolling-number/src/specs/RollingNumber.nitro.ts index 6d35a422..237a6cc5 100644 --- a/packages/react-native-nitro-rolling-number/src/specs/RollingNumber.nitro.ts +++ b/packages/react-native-nitro-rolling-number/src/specs/RollingNumber.nitro.ts @@ -48,6 +48,10 @@ export interface RollingNumberProps extends HybridViewProps { * * Reading it on the hybrid ref returns the value the view is currently * showing or rolling towards (also after `jumpTo` / `animateTo`). + * + * At most 18 digits are shown: `|value| × 10^fractionDigits` is clamped at + * 10^17 (the same limit for a jump and a roll), and a JS number carries exact + * integers only up to 2^53. */ value: number /** Digits shown after the decimal separator. Default: `0`. */ diff --git a/packages/react-native-nitro-rolling-number/tsconfig.build.cjs.json b/packages/react-native-nitro-rolling-number/tsconfig.build.cjs.json new file mode 100644 index 00000000..2741a690 --- /dev/null +++ b/packages/react-native-nitro-rolling-number/tsconfig.build.cjs.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "module": "commonjs", + "verbatimModuleSyntax": false, + "declaration": false, + "declarationDir": null, + "outDir": "lib/commonjs" + } +} diff --git a/packages/react-native-nitro-rolling-number/tsconfig.build.json b/packages/react-native-nitro-rolling-number/tsconfig.build.json index 93291705..02eb7177 100644 --- a/packages/react-native-nitro-rolling-number/tsconfig.build.json +++ b/packages/react-native-nitro-rolling-number/tsconfig.build.json @@ -10,6 +10,9 @@ "composite": false, "incremental": false, "declaration": true, + "declarationDir": "lib/typescript", + "rootDir": ".", + "outDir": "lib/module", "types": [], "noEmit": false } From 54de102f1dac20d1a6dac03915c302f70847de33 Mon Sep 17 00:00:00 2001 From: Ronald Goedeke Date: Mon, 21 Sep 2026 22:23:59 -0300 Subject: [PATCH 2/3] Input: worklet ids on Android, dark mode on iOS, submitBehavior on multiline Android never marked five worklet-id props dirty, so a changed handler kept calling a dead id; they flush like the other three now. The view also re-feeds after `secureTextEntry` or `morph` toggles, applies `maxLength` only in text mode, sets tabular figures in every mode, honours `showSoftInputOnFocus`, resolves its theme colours once and again on a configuration change, and caches the animator scale. iOS re-rasterizes glyphs and re-resolves frame, label and caret colours on a trait or scale change (no more `UIScreen.main`), the Fabric focus/blur commands find multiline fields, `maxIntegerDigits` allows 30 as on Android, a `morph` toggled back on re-feeds, and a frame no longer allocates per glyph. `submitBehavior` gains `'newline'`, resolved as `TextInput` resolves it, and both platforms honour `submit` / `blurAndSubmit` on a multiline field. The wrapper no longer re-renders on every keystroke of an uncontrolled field, memoises `maskNotations` and the colours, exports the missing types, drops affixes on multiline with a warning, and gains 43 tests; the README props table is complete and guarded by a test. The native `affixAlign` prop is gone (the wrapper folds it), dead engine, mask and worklet-bridge methods are removed, the outline geometry writes into a reused buffer, and packaging mirrors the rolling number's dual build with `react-native-worklets` as an optional peer. Co-Authored-By: Claude Fable 5.1 --- .../react-native-nitro-input/CHANGELOG.md | 22 ++ packages/react-native-nitro-input/README.md | 30 +- .../TEXTINPUT-PARITY.md | 33 +- .../android/CMakeLists.txt | 2 +- .../android/build.gradle | 14 +- .../android/gradle.properties | 1 - .../android/src/main/cpp/JMaskEngine.cpp | 10 - .../android/src/main/cpp/JMaskEngine.hpp | 2 - .../src/main/cpp/JNitroInputWorklets.cpp | 5 - .../src/main/cpp/JNitroInputWorklets.hpp | 1 - .../android/src/main/cpp/JOutlineGeometry.cpp | 27 +- .../android/src/main/cpp/JOutlineGeometry.hpp | 11 +- .../nitro/nitroinput/HybridNitroInputView.kt | 16 +- .../margelo/nitro/nitroinput/MaskEngine.kt | 3 - .../nitro/nitroinput/NitroInputView.kt | 230 +++++++++++--- .../nitro/nitroinput/NitroInputWorklets.kt | 2 - .../cpp/MaskEngine.cpp | 45 --- .../cpp/MaskEngine.hpp | 11 - .../cpp/MorphEngine.cpp | 28 +- .../cpp/MorphEngine.hpp | 20 +- .../cpp/OutlineGeometry.cpp | 80 +++-- .../cpp/OutlineGeometry.hpp | 12 + .../cpp/__tests__/MaskEngineTest.cpp | 8 +- .../cpp/__tests__/OutlineGeometryTest.cpp | 44 +++ .../cpp/worklets/NitroInputWorkletsBridge.cpp | 15 - .../cpp/worklets/NitroInputWorkletsBridge.hpp | 6 - .../ios/HybridMorphInputView.swift | 15 +- .../ios/NitroInputCommands.mm | 40 ++- .../ios/NitroInputView.swift | 296 ++++++++++++------ .../react-native-nitro-input/jest.config.js | 2 +- .../android/c++/JHybridNitroInputViewSpec.cpp | 17 +- .../android/c++/JHybridNitroInputViewSpec.hpp | 2 - .../android/c++/JNitroInputSubmitBehavior.hpp | 3 + .../JHybridNitroInputViewStateUpdater.cpp | 5 - .../nitroinput/HybridNitroInputViewSpec.kt | 6 - .../nitroinput/NitroInputSubmitBehavior.kt | 3 +- .../ios/c++/HybridNitroInputViewSpecSwift.hpp | 13 +- .../views/HybridNitroInputViewComponent.mm | 6 - .../ios/swift/HybridNitroInputViewSpec.swift | 1 - .../swift/HybridNitroInputViewSpec_cxx.swift | 11 - .../ios/swift/NitroInputSubmitBehavior.swift | 4 + .../shared/c++/HybridNitroInputViewSpec.cpp | 2 - .../shared/c++/HybridNitroInputViewSpec.hpp | 8 +- .../shared/c++/NitroInputSubmitBehavior.hpp | 4 + .../views/HybridNitroInputViewComponent.cpp | 2 - .../views/HybridNitroInputViewComponent.hpp | 5 +- .../shared/json/NitroInputViewConfig.json | 1 - .../react-native-nitro-input/package.json | 26 +- .../src/NitroInput.tsx | 209 +++++++++---- .../src/__tests__/MorphInput.test.tsx | 3 +- .../src/__tests__/NitroInput.test.tsx | 237 ++++++++++++++ .../src/__tests__/ReadmeProps.test.ts | 51 +++ .../src/__tests__/SharedHelpers.test.ts | 35 +++ .../src/__tests__/TextInputParity.test.tsx | 108 ++++++- .../src/__tests__/Worklets.test.tsx | 231 ++++++++++++++ .../src/__tests__/useNitroInputState.test.tsx | 82 +++++ .../react-native-nitro-input/src/index.ts | 4 + .../src/specs/NitroInput.nitro.ts | 18 +- .../tsconfig.build.cjs.json | 10 + .../tsconfig.build.json | 3 + 60 files changed, 1621 insertions(+), 520 deletions(-) create mode 100644 packages/react-native-nitro-input/src/__tests__/NitroInput.test.tsx create mode 100644 packages/react-native-nitro-input/src/__tests__/ReadmeProps.test.ts create mode 100644 packages/react-native-nitro-input/src/__tests__/SharedHelpers.test.ts create mode 100644 packages/react-native-nitro-input/src/__tests__/Worklets.test.tsx create mode 100644 packages/react-native-nitro-input/src/__tests__/useNitroInputState.test.tsx create mode 100644 packages/react-native-nitro-input/tsconfig.build.cjs.json diff --git a/packages/react-native-nitro-input/CHANGELOG.md b/packages/react-native-nitro-input/CHANGELOG.md index 8c42d32e..e08b4b9a 100644 --- a/packages/react-native-nitro-input/CHANGELOG.md +++ b/packages/react-native-nitro-input/CHANGELOG.md @@ -14,4 +14,26 @@ - `autoWidth` takes `'auto'` as well as a boolean. `NitroInput` defaults to `false`, so flexbox stretches it to its parent the way a `TextInput` is stretched; `MorphInput` uses `'auto'`, so an amount still grows as digits arrive. - Fixed: negative amounts lost their sign; number formatting followed `LC_NUMERIC`, so a comma-decimal locale broke parsing; the worklets bridge held the UI runtime by `shared_ptr`. - Fixed: the frame props were originally `outline*`, which collide with the CSS outline properties React Native 0.76 added to every view — a Hybrid View's props derive from `ViewProps`, so both parsers ran and RN drew a second square outline. A test now fails the build if any declared prop name collides with one React Native parses. +- `TextInput` parity, checked side by side in `src/__tests__/TextInputParity.test.tsx` against React Native's real component and its own `TextInput.d.ts`: every `TextInput` prop is either supported, inherited from `ViewProps`, or listed with a reason. Added `submitBehavior` (with the `blurOnSubmit` alias), `secureTextEntry`, `keyboardAppearance`, `textContentType` / `autoComplete`, `enablesReturnKeyAutomatically`, `showSoftInputOnFocus`, `selectTextOnFocus`, `clearTextOnFocus`, `contextMenuHidden`, `spellCheck`, `readOnly`, `selection`, the HTML-style aliases `inputMode` / `enterKeyHint` / `id` / `aria-label` / `rows` (resolved with React Native's own tables and precedence, since a Nitro view is handed the raw props), and the callbacks `onChange`, `onEndEditing`, `onSelectionChange` and `onKeyPress`. +- The field registers in React Native's text-input registry and routes the registry's `focusTextInput` / `blurTextInput` to its own native focus and blur, so `Keyboard.dismiss()`, `TextInput.State.currentlyFocusedInput()`, a ScrollView's `keyboardShouldPersistTaps` and `react-native-keyboard-controller` treat it as the text input it is. `testID` and `accessibilityLabel` are forwarded to the system field (the element VoiceOver, TalkBack and e2e tools interact with), and the `placeholder` is its accessibility value while it is empty. `TextInput.State.focusTextInput` / `blurTextInput` copy their references when `TextInput.js` loads and so bypass the routing: iOS answers the view command they dispatch, Android drops it. +- Every event is a superset of `TextInput`'s: `nativeEvent` carries React Native's fields under React Native's names, so a handler written against a `TextInput` works unchanged, and the same fields are repeated at the top level (`onSubmitEditing={({ text }) => …}`). `onFocus` / `onBlur` carry `text`, `eventCount` and `target`. +- `multiline`, with `numberOfLines` / `rows`, `textAlignVertical` and `scrollEnabled`. A wrapping field is drawn by the system view (on iOS a `UITextView` built the first time it is asked for), grows with its content unless given a line count, and is always `mode="text"`: `morph`, a non-text `mode` and the `prefix` / `suffix` affixes are ignored alongside it, with one warning each in development. Its return key inserts a line break by default (`submitBehavior="newline"`), as a `TextInput`'s does. `lineHeight` on both platforms, with the CSS meaning (the total height a line occupies), honoured tighter than the font too — the case React Native's own correction skips. +- Every event callback can be a worklet: mark `onChangeText`, `onChangeValue`, `onFocus`, `onBlur`, `onSelectionChange`, `onSubmitEditing`, `onEndEditing` or `onKeyPress` with `'worklet'` and it runs on the UI runtime inside the native edit, and is not also called on the JS thread. `useNitroInputState(useSharedValue)` builds on that: the field's `text`, `value`, `focused` and `selection` as shared values, plus the handlers that keep them current. +- `signPlacement`: where a negative amount's sign sits relative to the `prefix`, `'beforeAffix'` (`-$1,234.56`, the default) or `'afterAffix'` (`$-1,234.56`). Only the morph honours it; a plain field's affixes are accessory views outside the text, so it always reads `$-1,234.56`. Fixed: the morph drew `$-1,234.56` unconditionally. +- Right-to-left. `textAlign` gains `'auto'` and **defaults to it** (it was `'left'`): the start edge of the layout direction, as `TextInput` with no `textAlign`. `'left'` and `'right'` are absolute on both platforms (Android used to resolve `'left'` as start). The direction is resolved on the JS side, from `style.direction` else `I18nManager.isRTL`, and handed to native, because Fabric does not give a Hybrid View its layout direction. A `prefix` sits at the start edge and a `suffix` at the end, plain or morphed, with the digits still reading left to right; the frame's label and its notch sit at the start edge. +- Fixed: from React Native 0.86 a Hybrid View on Android received none of its base view props (`backgroundColor`, `border*`, `transform`, `opacity`, `testID`, accessibility): `react-native-nitro-modules` 0.37.1's `cloneProps` never fills `Props::rawProps`. The monorepo patches it (`patches/react-native-nitro-modules@0.37.1.patch`, filed as margelo/nitro#1656) and `src/__tests__/NitroViewProps.test.tsx` fails if the patch stops being applied. +- Fixed: a framed multiline field grew taller with its `cornerRadius` (the label's side inset was being reused as vertical padding); its label rested at the box's centre rather than on the first line; on Android the frame's padding was computed and then left out of the reported height. +- An uncontrolled or worklet-driven field no longer re-renders on every keystroke: the native edit count is kept in a ref, and only a controlled field (`value` given) takes the state update that forces a render, which is the one case native needs the count for. A field made controlled later still sends the fresh count. +- `submitBehavior` gains `'newline'`, and the defaults are resolved the way `TextInput` resolves them: `'blurAndSubmit'` on a single-line field, `'newline'` on a multiline one; `blurOnSubmit={false}` means `'submit'` on one line, and `blurOnSubmit` means `'blurAndSubmit'` on many. +- `maskNotations` is keyed on its contents, so an inline array literal no longer re-sets the native prop every render; the frame and caret colours, the flattened `style` and the imperative handle are memoised, and the handle is one object for the field's lifetime. +- Exported types: `NitroInputSignPlacement`, `NitroInputTextAlignVertical`, `InputMode` and `EnterKeyHint`; `textAlignVertical` is typed by the spec's `NitroInputTextAlignVertical`, and `NitroInputSelection` is declared once. +- Packaging: a dual build (`lib/module` for `import`, `lib/commonjs` for `require`, `lib/typescript` for the types) with matching `exports`; `react-native-worklets` is declared as an optional peer dependency, since it is loaded at runtime when present. +- Fixed on Android: the `onChangeValue`, `onFocus` / `onBlur`, `onSelectionChange`, `onSubmitEditing` and `onEndEditing` worklets kept running a stale handler after the callback changed (their ids never reached the view). +- `submitBehavior` is honoured natively on a multiline field on both platforms: `'newline'` inserts a line break, `'submit'` fires `onSubmitEditing` and keeps focus, `'blurAndSubmit'` fires it and dismisses the keyboard; on a single-line field `'newline'` does nothing, as with `TextInput`. A wrapping field that submits shows the IME's action key on Android. +- The field follows a light/dark switch without a remount: iOS re-rasterizes its glyphs and re-resolves the frame, label and caret colours on a trait change (and on a move to a display with another scale; the pixel density now comes from the view's traits instead of the deprecated `UIScreen.main`, which visionOS lacks), Android re-reads its theme colours on a configuration change. +- iOS: `focus()` / `blur()`, `Keyboard.dismiss()` and a ScrollView's auto-blur now reach a multiline field (the Fabric command only looked for a `UITextField`). `maxIntegerDigits` accepts up to 30, as on Android. Turning `morph` back on after plain mode no longer leaves the field blank until the next edit. +- Android: toggling `secureTextEntry` at runtime re-feeds the glyphs (the real characters used to stay on screen until the next edit); `maxLength` no longer clips a mask's own punctuation (it applies to `mode="text"` only, as on iOS); `focus()` and `autoFocus` respect `showSoftInputOnFocus={false}`; a `lineHeight` removed at runtime resets the spacing; tabular figures in text mode too, matching iOS. +- Less work per frame and per keystroke: iOS keeps one layer entry per glyph and re-uses it (no per-glyph allocation per frame), positions the caret once per step and caches the right-to-left caret blocks when a frame is published; Android resolves its theme colours once instead of per draw, reads the animator duration scale once (refreshed by a settings observer) instead of querying the settings provider per keystroke below API 33, and the outlined frame's geometry is written into a reused buffer instead of two vectors per draw. +- Removed dead code: `MaskEngine::format` / `totalTextLength` / `totalValueLength` / `placeholder`, the worklets bridge's `uninstallRuntime` and `isReady`, `MorphEngine::rightToLeft`, the unused JNI bindings behind them, and an unreachable pre-iOS-15 branch. +- Android build: the Android Gradle plugin is only pinned when the library builds on its own, Java 17 source/target, an unused `kotlinVersion` property removed, `cmake_minimum_required` before `project()`. - Docs page with a live demo running the same engine and formatter compiled to WebAssembly. diff --git a/packages/react-native-nitro-input/README.md b/packages/react-native-nitro-input/README.md index d5d7ecc8..5cf61421 100644 --- a/packages/react-native-nitro-input/README.md +++ b/packages/react-native-nitro-input/README.md @@ -157,8 +157,11 @@ that many lines tall and scrolls past it; without it the field grows with its content, and `scrollEnabled={false}` hands the height to the content entirely. `textAlignVertical` says where the text sits in a taller box. A multiline field is always drawn by the system view and is always `mode="text"`: the glyph -engine lays one run out on one baseline, so `morph`, `mode="number"` and -`mode="mask"` are ignored alongside it, with one warning in development. +engine lays one run out on one baseline, and an amount, a mask and their +affixes are single-line ideas, so `morph`, `mode="number"`, `mode="mask"`, +`prefix` and `suffix` are ignored alongside it, with one warning each in +development. Its return key inserts a line break, as a `TextInput`'s does; +`submitBehavior="blurAndSubmit"` (or `blurOnSubmit`) makes it submit instead. ### An amount field (the morph) @@ -411,7 +414,7 @@ rather than renumbering the columns. | `color` | `ColorValue` | label color | Text color. | | `textAlign` | `'auto' \| 'left' \| 'center' \| 'right'` | `'auto'` | Alignment inside a wider frame. `'auto'` is the start edge of the layout direction, as `TextInput`; `'left'` and `'right'` are absolute. | | `lineHeight` | `number` | the font's own | The CSS meaning: the total height a line occupies. Honoured in both directions, including tighter than the font. | -| `multiline` | `boolean` | `false` | Wraps. Always drawn by the system view and always `'text'` mode; `morph`, `'number'` and `'mask'` are ignored with it. | +| `multiline` | `boolean` | `false` | Wraps. Always drawn by the system view and always `'text'` mode; `morph`, `'number'`, `'mask'` and the affixes are ignored with it, and the return key inserts a line break unless `submitBehavior` says otherwise. | | `numberOfLines` / `rows` | `number` | `0` | `multiline`: lines tall before it scrolls; `0` grows with the content. | | `textAlignVertical` | `'auto' \| 'top' \| 'center' \| 'bottom'` | `'auto'` | `multiline`: where the text sits in a taller box. | | `scrollEnabled` | `boolean` | `true` | `multiline`: scroll once the text outgrows the field; `false` lets a growing field drive its own height. | @@ -423,24 +426,41 @@ rather than renumbering the columns. | `allowFontScaling` | `boolean` | `false` | Follow the system text size like `Text`. | | `maxFontSizeMultiplier` | `number` | `0` | Cap for `allowFontScaling`; `0` = none. | | `keyboardType` | see below | mode-dependent | `number` mode defaults to `decimal-pad` (`number-pad` when `fractionDigits` is `0`). | +| `inputMode` | `'none' \| 'text' \| 'decimal' \| 'numeric' \| 'tel' \| 'search' \| 'email' \| 'url'` | – | React Native's HTML-style alias for `keyboardType`, mapped with its table; `keyboardType` wins. `'none'` focuses without a keyboard. `'search'` falls back to the default keyboard. | | `returnKeyType` | `'default' \| 'done' \| 'go' \| 'next' \| 'search' \| 'send'` | `'default'` | | +| `enterKeyHint` | `'enter' \| 'done' \| 'go' \| 'next' \| 'previous' \| 'search' \| 'send'` | – | HTML-style alias for `returnKeyType`; `returnKeyType` wins. `'previous'` falls back to the default key. | +| `submitBehavior` | `'blurAndSubmit' \| 'submit' \| 'newline'` | `'blurAndSubmit'`, `'newline'` when `multiline` | What the return key does: fire `onSubmitEditing` and dismiss the keyboard, fire it and keep focus (a form moving to its next field), or insert a line break (`multiline` only). Defaults as `TextInput`'s. | +| `blurOnSubmit` | `boolean` | – | Deprecated alias, resolved like `TextInput`'s: `false` means `'submit'` on one line, `true` means `'blurAndSubmit'` on many. `submitBehavior` wins. | +| `enablesReturnKeyAutomatically` | `boolean` | `false` | Disables the return key until the field has text. | | `autoCapitalize` | `'none' \| 'sentences' \| 'words' \| 'characters'` | `'sentences'` | `text` mode. | | `autoCorrect` | `boolean` | `true` | `text` mode. | +| `spellCheck` | `boolean` | `autoCorrect` | `text` mode. | +| `secureTextEntry` | `boolean` | `false` | Draws bullets and turns off autocorrect; the field keeps the real text for autofill. | +| `keyboardAppearance` | `'default' \| 'light' \| 'dark'` | `'default'` | iOS: a light or dark keyboard. | +| `textContentType` / `autoComplete` | `string` | `''` | Autofill: iOS content types and Android hints by their React Native names (`'username'`, `'password'`, `'oneTimeCode'`, `'telephoneNumber'`, …). `textContentType` wins. | +| `showSoftInputOnFocus` | `boolean` | `true` | `false` focuses, with the caret, but shows no keyboard. | +| `selectTextOnFocus` | `boolean` | `false` | Select everything when the field gains focus. | +| `clearTextOnFocus` | `boolean` | `false` | Empty the field when it gains focus. | +| `contextMenuHidden` | `boolean` | `false` | Hides the Cut / Copy / Paste menu. | | `editable` | `boolean` | `true` | | +| `readOnly` | `boolean` | `false` | Alias of `editable={false}`, as on `TextInput`. | | `autoFocus` | `boolean` | `false` | | +| `selection` | `{ start, end? }` | – | The caret or selection to apply, in code points into the (formatted) text. | | `maxLength` | `number` | unlimited | `text` mode. | | `transform` | `NitroInputTransform` | – | A `'worklet'` that rewrites text and selection after every edit, synchronously on the UI thread (needs `react-native-worklets`). | | `onChangeText` | `(text) => void` | – | After every edit, the formatted text. A `'worklet'` runs on the UI thread. | +| `onChange` | `(event) => void` | – | Fired alongside `onChangeText` with the same text; `nativeEvent.eventCount` is the native edit counter, as on `TextInput`. | | `onChangeValue` | `(value) => void` | – | `number` mode: the numeric value, `NaN` while empty. A `'worklet'` runs on the UI thread. | | `onChangeMask` | `(formatted, extracted, tail, complete) => void` | – | `mask` mode: the formatted text, the characters the user contributed, what is still missing, and whether every mandatory slot is filled. | | `signPlacement` | `'beforeAffix' \| 'afterAffix'` | `'beforeAffix'` | Where a negative amount's sign sits relative to `prefix`: `-$1,234.56` or `$-1,234.56`. Morph only — a plain field's affixes are accessory views outside the text. | | `onFocus` / `onBlur` | `(event) => void` | – | Carries `text`, `eventCount` and `target`. | -| `onSubmitEditing` | `(event) => void` | – | Return key pressed (the field then blurs). | +| `onSubmitEditing` | `(event) => void` | – | Return key pressed; what happens next is `submitBehavior`. | | `onEndEditing` | `(event) => void` | – | Editing finished. | | `onSelectionChange` | `(event) => void` | – | The caret or selection moved, in code points. | | `onKeyPress` | `(event) => void` | – | Before the text changes: the character, `'Backspace'` or `'Enter'`. | | `onNativeRef` | `(ref) => void` | – | Receives the Nitro object on mount. | -| `style`, `testID`, … | `ViewProps` | – | Regular view props. Give the field a `width` (or `flex`) in `style`; without one it sizes itself to its text. | +| `id` / `aria-label` | `string` | – | React Native's HTML-style aliases for `nativeID` / `accessibilityLabel`, resolved here because a Nitro view is handed the raw props; each wins over the older spelling. | +| `style`, `testID`, … | `ViewProps` | – | Regular view props. `testID` and the accessibility label are forwarded to the system field, the element VoiceOver, TalkBack and e2e tools interact with. The field takes its width from its parent like a `TextInput`; see `autoWidth`. | `keyboardType`: `'default' | 'number-pad' | 'decimal-pad' | 'numeric' | 'email-address' | 'phone-pad' | 'url' | 'ascii-capable' | 'numbers-and-punctuation'`. diff --git a/packages/react-native-nitro-input/TEXTINPUT-PARITY.md b/packages/react-native-nitro-input/TEXTINPUT-PARITY.md index 61c05826..21829eb5 100644 --- a/packages/react-native-nitro-input/TEXTINPUT-PARITY.md +++ b/packages/react-native-nitro-input/TEXTINPUT-PARITY.md @@ -33,7 +33,7 @@ formatting on top. The differences that remain are listed at the end. | `autoFocus` inside `presentation: 'formSheet'` | ✅ (sheet lifts, caret visible) | ✅ | | `TextInput.State.currentlyFocusedInput()` | ✅ | ✅ | | `Keyboard.dismiss()` | ✅ | ✅ | -| `TextInput.State.blurTextInput()` | ✅ | ✅ | +| `TextInput.State.blurTextInput()` / `focusTextInput()` | ✅ iOS (via the Fabric view command) · ✗ Android — see difference 6 | ✅ | | ScrollView `keyboardShouldPersistTaps` auto-blur | ✅ | ✅ | | testID / a11y label / placeholder on the real input element | ✅ | ✅ | | `secureTextEntry` | ✅ (bullets drawn; real text kept for autofill) | ✅ | @@ -221,7 +221,7 @@ separately in an earlier session and is not re-measured here. | `currentlyFocusedInput()` returned `null`; `Keyboard.dismiss()` was a no-op, so was `keyboardShouldPersistTaps` | `NitroInput` registers in RN's text-input registry and routes the registry's `focusTextInput`/`blurTextInput` to its own native focus/blur (`src/NitroInput.tsx`). RN reaches an input through a codegen **view command**, which a Nitro view does not implement — on iOS a category now handles it (`ios/NitroInputCommands.mm`), and the JS routing covers both platforms. | | testID never reached the field; an empty field had no accessibility element at all | `testID` and `accessibilityLabel` are forwarded as explicit props (`fieldTestID`, `fieldAccessibilityLabel`) onto the hidden field, and the `placeholder` is exposed as its accessibility value. The label is no longer left on the host view as well, so there is one element, not two. | | keyboard-controller reported `target 0` | The react tag is copied onto the view KC reads (`firstResponder.superview.tag`). | -| Return always dismissed the keyboard | `submitBehavior` (`'submit'` / `'blurAndSubmit'`), plus the `blurOnSubmit` alias. | +| Return always dismissed the keyboard | `submitBehavior` (`'submit'` / `'blurAndSubmit'` / `'newline'`), with the `blurOnSubmit` alias resolved the way `TextInput` resolves it: `'blurAndSubmit'` on a single-line field, `'newline'` on a multiline one. | | No `onEndEditing`, `onSelectionChange`, `onKeyPress` | Added on both platforms. | | Android fired a spurious `onKeyPress`/`onSelectionChange` on mount | `onKeyPress` only for real key events; the initial `0-0` caret is not a move. | @@ -354,18 +354,22 @@ Compose field. ## Differences that remain -1. **Layout.** `TextInput` stretches to its parent; `NitroInput` sizes itself to - its content unless `style` sets `width` or `flex`. In a plain column a - `NitroInput` measured 39 pt wide where a `TextInput` filled 340 pt. Give it a - width to use it as a drop-in form field. (`alignSelf: 'stretch'` is not - enough — auto-sizing only looks at `width`/`flex`.) +1. **Layout, for `MorphInput` only.** `NitroInput` takes its width from its + parent the way a `TextInput` does: `autoWidth` defaults to `false`, so it + has no width of its own and flexbox stretches it. `MorphInput` sets + `autoWidth="auto"` and sizes itself to its content unless `style` sets + `width` or `flex`; in a plain column it measured 39 pt wide where a + `TextInput` filled 340 pt. Give it a width to make it fill its parent. + (`alignSelf: 'stretch'` is not enough — the inference only looks at + `width`/`flex`.) 2. **The morph is single line.** `multiline`, `numberOfLines`, `rows`, `scrollEnabled` and `textAlignVertical` are all supported, but a wrapping field is drawn by the platform rather than by the glyph engine, which lays - one run out on one baseline. Setting `multiline` therefore forces `plain`, - and `mode`, `mask` and the affixes - all single-line ideas - warn and are - ignored. `inlineImage*`, `dataDetectorTypes` and `clearButtonMode` do not - apply at all. + one run out on one baseline. Setting `multiline` therefore forces plain + drawing, and `morph`, a non-text `mode` (and with it `mask`) and the + `prefix` / `suffix` affixes - all single-line ideas - warn once in + development and are ignored. `inlineImage*`, `dataDetectorTypes` and + `clearButtonMode` do not apply at all. 3. **A plain field always puts a negative sign after a `prefix`.** The morph takes `signPlacement`: `'beforeAffix'` (the default) draws `-$1,234.56`, `'afterAffix'` draws `$-1,234.56`. A plain field cannot @@ -387,9 +391,10 @@ Compose field. `TextInput.js` copies those two function references by value at module-eval time (`TextInput.State = { focusTextInput: TextInputState.focusTextInput, … }`), long before `patchRegistryOnce()` runs, so the patch is invisible to them. - `ref.focus()` and `Keyboard.dismiss()` are unaffected - both read - `TextInputState.*` live. On iOS the view-command category covers the gap; on - Android those two entry points do not reach a `MorphInput`. + `ref.focus()`, `Keyboard.dismiss()` and a ScrollView's auto-blur are + unaffected - they read `TextInputState.*` live. On iOS the view-command + category (`ios/NitroInputCommands.mm`) covers the gap, so both entry points + work there; on Android they do not reach a `NitroInput`. 7. **keyboard-controller `target`** is the react tag of the host view. That is the right view to measure and scroll, but it is not the same tag a `TextInput` reports for itself. diff --git a/packages/react-native-nitro-input/android/CMakeLists.txt b/packages/react-native-nitro-input/android/CMakeLists.txt index b2e4f09c..432a8d6a 100644 --- a/packages/react-native-nitro-input/android/CMakeLists.txt +++ b/packages/react-native-nitro-input/android/CMakeLists.txt @@ -1,5 +1,5 @@ +cmake_minimum_required(VERSION 3.22.1) project(NitroInput) -cmake_minimum_required(VERSION 3.9.0) set (PACKAGE_NAME NitroInput) set (CMAKE_VERBOSE_MAKEFILE ON) diff --git a/packages/react-native-nitro-input/android/build.gradle b/packages/react-native-nitro-input/android/build.gradle index c8d0e428..61e2a08f 100644 --- a/packages/react-native-nitro-input/android/build.gradle +++ b/packages/react-native-nitro-input/android/build.gradle @@ -4,8 +4,14 @@ buildscript { mavenCentral() } - dependencies { - classpath "com.android.tools.build:gradle:9.3.1" + // Inside a React Native app the root project already has the Android Gradle + // plugin on its classpath and every subproject inherits it; pinning a second + // version here would only fight the app's. The pin is for building this + // library on its own. + if (project == rootProject) { + dependencies { + classpath "com.android.tools.build:gradle:9.3.1" + } } } @@ -108,8 +114,8 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } sourceSets { diff --git a/packages/react-native-nitro-input/android/gradle.properties b/packages/react-native-nitro-input/android/gradle.properties index 09de6fca..841d511a 100644 --- a/packages/react-native-nitro-input/android/gradle.properties +++ b/packages/react-native-nitro-input/android/gradle.properties @@ -1,4 +1,3 @@ -NitroInput_kotlinVersion=2.1.21 NitroInput_minSdkVersion=23 NitroInput_targetSdkVersion=36 NitroInput_compileSdkVersion=36 diff --git a/packages/react-native-nitro-input/android/src/main/cpp/JMaskEngine.cpp b/packages/react-native-nitro-input/android/src/main/cpp/JMaskEngine.cpp index 446f4afe..3e3eff44 100644 --- a/packages/react-native-nitro-input/android/src/main/cpp/JMaskEngine.cpp +++ b/packages/react-native-nitro-input/android/src/main/cpp/JMaskEngine.cpp @@ -23,14 +23,12 @@ void JMaskEngine::registerNatives() { makeNativeMethod("clearNotations", JMaskEngine::clearNotations), makeNativeMethod("addNotation", JMaskEngine::addNotation), makeNativeMethod("setFormat", JMaskEngine::setFormat), - makeNativeMethod("isActive", JMaskEngine::isActive), makeNativeMethod("applyEdit", JMaskEngine::applyEdit), makeNativeMethod("applyAll", JMaskEngine::applyAll), makeNativeMethod("lastCaret", JMaskEngine::lastCaret), makeNativeMethod("lastExtracted", JMaskEngine::lastExtracted), makeNativeMethod("lastTailPlaceholder", JMaskEngine::lastTailPlaceholder), makeNativeMethod("lastComplete", JMaskEngine::lastComplete), - makeNativeMethod("placeholder", JMaskEngine::placeholder), }); } @@ -51,10 +49,6 @@ bool JMaskEngine::setFormat(jni::alias_ref format) { return engine_.setFormat(str(format)); } -bool JMaskEngine::isActive() { - return engine_.isActive(); -} - jni::local_ref JMaskEngine::applyEdit(jni::alias_ref current, int start, int end, jni::alias_ref replacement, bool autocomplete, bool autoSkip) { @@ -84,8 +78,4 @@ bool JMaskEngine::lastComplete() { return last_.complete; } -jni::local_ref JMaskEngine::placeholder() { - return jni::make_jstring(engine_.placeholder()); -} - } // namespace margelo::nitro::nitroinput diff --git a/packages/react-native-nitro-input/android/src/main/cpp/JMaskEngine.hpp b/packages/react-native-nitro-input/android/src/main/cpp/JMaskEngine.hpp index c428b43c..a1b41a1c 100644 --- a/packages/react-native-nitro-input/android/src/main/cpp/JMaskEngine.hpp +++ b/packages/react-native-nitro-input/android/src/main/cpp/JMaskEngine.hpp @@ -27,7 +27,6 @@ class JMaskEngine final : public jni::HybridClass { void clearNotations(); void addNotation(jni::alias_ref character, jni::alias_ref characterSet, bool isOptional); bool setFormat(jni::alias_ref format); - bool isActive(); /// Replaces code points [start, end) of `current` with `replacement`. jni::local_ref applyEdit(jni::alias_ref current, int start, int end, @@ -40,7 +39,6 @@ class JMaskEngine final : public jni::HybridClass { jni::local_ref lastExtracted(); jni::local_ref lastTailPlaceholder(); bool lastComplete(); - jni::local_ref placeholder(); private: friend HybridBase; diff --git a/packages/react-native-nitro-input/android/src/main/cpp/JNitroInputWorklets.cpp b/packages/react-native-nitro-input/android/src/main/cpp/JNitroInputWorklets.cpp index 13d7abf8..c563ec13 100644 --- a/packages/react-native-nitro-input/android/src/main/cpp/JNitroInputWorklets.cpp +++ b/packages/react-native-nitro-input/android/src/main/cpp/JNitroInputWorklets.cpp @@ -17,7 +17,6 @@ int gLastSelectionEnd = 0; void JNitroInputWorklets::registerNatives() { javaClassStatic()->registerNatives({ - makeNativeMethod("isReady", JNitroInputWorklets::isReady), makeNativeMethod("runTransform", JNitroInputWorklets::runTransform), makeNativeMethod("lastSelectionStart", JNitroInputWorklets::lastSelectionStart), makeNativeMethod("lastSelectionEnd", JNitroInputWorklets::lastSelectionEnd), @@ -31,10 +30,6 @@ void JNitroInputWorklets::registerNatives() { }); } -jboolean JNitroInputWorklets::isReady(jni::alias_ref) { - return nitroinputworklets::isReady(); -} - jni::local_ref JNitroInputWorklets::runTransform(jni::alias_ref, jint id, jni::alias_ref text, jni::alias_ref previousText, jint selectionStart, diff --git a/packages/react-native-nitro-input/android/src/main/cpp/JNitroInputWorklets.hpp b/packages/react-native-nitro-input/android/src/main/cpp/JNitroInputWorklets.hpp index eb495c44..11d9bb46 100644 --- a/packages/react-native-nitro-input/android/src/main/cpp/JNitroInputWorklets.hpp +++ b/packages/react-native-nitro-input/android/src/main/cpp/JNitroInputWorklets.hpp @@ -19,7 +19,6 @@ struct JNitroInputWorklets final : public jni::JavaClass { static void registerNatives(); - static jboolean isReady(jni::alias_ref); /// The transformed text, or null when the worklet did not apply. The /// selection it chose is read with `lastSelectionStart` / `lastSelectionEnd`. static jni::local_ref runTransform(jni::alias_ref, jint id, jni::alias_ref text, diff --git a/packages/react-native-nitro-input/android/src/main/cpp/JOutlineGeometry.cpp b/packages/react-native-nitro-input/android/src/main/cpp/JOutlineGeometry.cpp index a721f2cb..a791580d 100644 --- a/packages/react-native-nitro-input/android/src/main/cpp/JOutlineGeometry.cpp +++ b/packages/react-native-nitro-input/android/src/main/cpp/JOutlineGeometry.cpp @@ -5,15 +5,8 @@ #include "JOutlineGeometry.hpp" -#include - namespace margelo::nitro::nitroinput { -namespace { -/// verb, x, y, radius, startAngle, sweepAngle. -constexpr size_t kPerSegment = 6; -} // namespace - jni::local_ref JOutlineGeometry::initHybrid(jni::alias_ref) { return makeCxxInstance(); } @@ -41,24 +34,14 @@ int JOutlineGeometry::outline(double width, double height, double radius, double gap.width = gapWidth; gap.padding = gapPadding; - scratch_ = OutlineGeometry::outline(box, gap, progress); - const size_t count = scratch_.size(); - const size_t needed = count * kPerSegment; + // Traced straight into the flat form, into a vector this object keeps: once + // it has grown to fit, a redraw allocates nothing on either side of the JNI. + const size_t count = OutlineGeometry::outline(box, gap, progress, flat_); + const size_t needed = count * OutlineGeometry::kFlatStride; if (static_cast(out->size()) < needed) { return -1; } - - std::vector flat(needed); - size_t i = 0; - for (const auto& segment : scratch_) { - flat[i++] = static_cast(static_cast(segment.verb)); - flat[i++] = segment.point.x; - flat[i++] = segment.point.y; - flat[i++] = segment.radius; - flat[i++] = segment.startAngle; - flat[i++] = segment.sweepAngle; - } - if (needed > 0) out->setRegion(0, needed, flat.data()); + if (needed > 0) out->setRegion(0, needed, flat_.data()); return static_cast(count); } diff --git a/packages/react-native-nitro-input/android/src/main/cpp/JOutlineGeometry.hpp b/packages/react-native-nitro-input/android/src/main/cpp/JOutlineGeometry.hpp index fa266cc6..19ad2434 100644 --- a/packages/react-native-nitro-input/android/src/main/cpp/JOutlineGeometry.hpp +++ b/packages/react-native-nitro-input/android/src/main/cpp/JOutlineGeometry.hpp @@ -7,8 +7,10 @@ // // Segments come back in a caller-owned `double[]`, six doubles each - verb, x, // y, radius, startAngle, sweepAngle - rather than as objects: the view reuses -// one array, so redrawing the frame allocates nothing, the same trick -// JMorphEngine uses for its glyphs. +// one array, and the geometry is traced into a vector this object keeps (the +// `std::vector` overload of `OutlineGeometry::outline`), so redrawing +// the frame allocates nothing on either side, the same trick JMorphEngine +// uses for its glyphs. // // `gapFor` needs no bridge. It only clamps, and `outline` already treats a // non-finite left as 0 and a non-positive width as no gap, so the caller @@ -19,6 +21,7 @@ #include "OutlineGeometry.hpp" #include +#include namespace margelo::nitro::nitroinput { @@ -45,8 +48,8 @@ class JOutlineGeometry final : public jni::HybridClass { friend HybridBase; JOutlineGeometry() = default; - /// Reused between calls so the geometry's own vector is not reallocated. - std::vector scratch_; + /// The flat segments, kept between calls so a redraw reallocates nothing. + std::vector flat_; }; } // namespace margelo::nitro::nitroinput diff --git a/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/HybridNitroInputView.kt b/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/HybridNitroInputView.kt index fdfe067e..8390cb27 100644 --- a/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/HybridNitroInputView.kt +++ b/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/HybridNitroInputView.kt @@ -93,7 +93,6 @@ class HybridNitroInputView(context: ThemedReactContext) : HybridNitroInputViewSp set(v) { field = v; markConfigDirty() } override var suffixFontSize: Double = Double.NaN set(v) { field = v; markConfigDirty() } - override var affixAlign: NitroInputAffixAlign = NitroInputAffixAlign.BASELINE override var signPlacement: NitroInputSignPlacement = NitroInputSignPlacement.BEFOREAFFIX set(v) { field = v; markConfigDirty() } override var prefixAlign: NitroInputAffixAlign = NitroInputAffixAlign.BASELINE @@ -218,11 +217,19 @@ class HybridNitroInputView(context: ThemedReactContext) : HybridNitroInputViewSp set(v) { field = v; markConfigDirty() } override var onChangeTextWorklet: Double = 0.0 set(v) { field = v; markConfigDirty() } + // Every worklet id reaches the view through the config flush: JS hands out a + // new id whenever a handler's identity changes and unregisters the old one, + // so an id that never landed would leave the view calling a dead worklet. override var onChangeValueWorklet: Double = 0.0 + set(v) { field = v; markConfigDirty() } override var onFocusChangeWorklet: Double = 0.0 + set(v) { field = v; markConfigDirty() } override var onSelectionChangeWorklet: Double = 0.0 + set(v) { field = v; markConfigDirty() } override var onSubmitEditingWorklet: Double = 0.0 + set(v) { field = v; markConfigDirty() } override var onEndEditingWorklet: Double = 0.0 + set(v) { field = v; markConfigDirty() } override var onKeyPressWorklet: Double = 0.0 set(v) { field = v; markConfigDirty() } override var onChangeText: ((text: String, eventCount: Double) -> Unit)? = null @@ -317,7 +324,6 @@ class HybridNitroInputView(context: ThemedReactContext) : HybridNitroInputViewSp suffix = "" prefixFontSize = Double.NaN suffixFontSize = Double.NaN - affixAlign = NitroInputAffixAlign.BASELINE signPlacement = NitroInputSignPlacement.BEFOREAFFIX prefixAlign = NitroInputAffixAlign.BASELINE suffixAlign = NitroInputAffixAlign.BASELINE @@ -594,7 +600,11 @@ class HybridNitroInputView(context: ThemedReactContext) : HybridNitroInputViewSp scrollEnabled = scrollEnabled, testID = fieldTestID.ifEmpty { null }, accessibilityLabel = fieldAccessibilityLabel.ifEmpty { null }, - blurOnSubmit = submitBehavior == NitroInputSubmitBehavior.BLURANDSUBMIT, + submitBehavior = when (submitBehavior) { + NitroInputSubmitBehavior.SUBMIT -> NitroInputView.SubmitBehavior.SUBMIT + NitroInputSubmitBehavior.BLURANDSUBMIT -> NitroInputView.SubmitBehavior.BLUR_AND_SUBMIT + NitroInputSubmitBehavior.NEWLINE -> NitroInputView.SubmitBehavior.NEWLINE + }, secureTextEntry = secureTextEntry, autofillHint = autofillHintFor(textContentType), showSoftInputOnFocus = showSoftInputOnFocus, diff --git a/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/MaskEngine.kt b/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/MaskEngine.kt index 40767939..3fb9e06a 100644 --- a/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/MaskEngine.kt +++ b/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/MaskEngine.kt @@ -25,7 +25,6 @@ class MaskEngine { external fun addNotation(character: String, characterSet: String, isOptional: Boolean) /** False when the pattern is malformed; the engine then passes text through. */ external fun setFormat(format: String): Boolean - external fun isActive(): Boolean /** Replaces code points `[start, end)` of [current] with [replacement]. */ external fun applyEdit( @@ -48,6 +47,4 @@ class MaskEngine { external fun lastTailPlaceholder(): String /** Whether every mandatory slot is filled. */ external fun lastComplete(): Boolean - /** The whole mask with nothing typed into it. */ - external fun placeholder(): String } diff --git a/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/NitroInputView.kt b/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/NitroInputView.kt index 2063a134..03ef7b17 100644 --- a/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/NitroInputView.kt +++ b/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/NitroInputView.kt @@ -2,6 +2,8 @@ package com.margelo.nitro.nitroinput import android.animation.ValueAnimator import android.content.Context +import android.content.res.Configuration +import android.database.ContentObserver import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint @@ -10,7 +12,10 @@ import android.graphics.Rect import android.graphics.RectF import android.graphics.Typeface import android.os.Build +import android.os.Handler +import android.os.Looper import android.os.SystemClock +import android.provider.Settings import android.text.Editable import android.text.InputFilter import android.text.InputType @@ -24,6 +29,7 @@ import android.view.KeyEvent import android.view.View import android.view.animation.PathInterpolator import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import android.widget.TextView @@ -62,6 +68,13 @@ class NitroInputView(context: Context) : FrameLayout(context) { enum class AutoCapitalize { NONE, SENTENCES, WORDS, CHARACTERS } enum class Variant { NONE, OUTLINED, FILLED } enum class LabelBehavior { FLOAT, ALWAYS } + /** + * What the return key does, as React Native's `submitBehavior`. On a single + * line `SUBMIT` and `BLUR_AND_SUBMIT` both fire `onSubmitEditing` (the latter + * also dismisses the keyboard) and `NEWLINE` does nothing at all; on a + * wrapping field `NEWLINE` inserts the line break instead. + */ + enum class SubmitBehavior { SUBMIT, BLUR_AND_SUBMIT, NEWLINE } /** A caller-defined slot character for [Format.mask]. */ data class MaskNotation(val character: String, val characterSet: String, val isOptional: Boolean) @@ -140,10 +153,9 @@ class NitroInputView(context: Context) : FrameLayout(context) { val autoCorrect: Boolean = true, val editable: Boolean = true, val autoFocus: Boolean = false, - /** Text mode: most characters accepted; 0 = unlimited. */ + /** Text mode only, as on iOS - a mask or a number format sets its own length; 0 = unlimited. */ val maxLength: Int = 0, - /** `submitBehavior`: whether the IME action also dismisses the keyboard. */ - val blurOnSubmit: Boolean = true, + val submitBehavior: SubmitBehavior = SubmitBehavior.BLUR_AND_SUBMIT, val secureTextEntry: Boolean = false, /** Android autofill hint, or null for no autofill. */ val autofillHint: String? = null, @@ -244,8 +256,15 @@ class NitroInputView(context: Context) : FrameLayout(context) { if (field == value) return val wasMultiline = field.multiline val wasLines = field.numberOfLines + val wasPlain = field.plain + val wasSecure = field.secureTextEntry field = value applyKeyboard() + // `applyPlain` emptied the engine on the way *into* plain mode, so coming + // back the overlay has nothing to draw until it is fed again. Masking + // changes what is drawn, not what is stored, so it re-feeds too - as iOS + // does; without this the real glyphs stayed up until the next edit. + if ((wasPlain && !value.plain) || wasSecure != value.secureTextEntry) requestFeed(-1) // How tall the field wants to be depends on `multiline` and // `numberOfLines`, and this prop lands *after* `typography` — whose // `rebuildFonts` is the usual reporter. Without this a wrapping field @@ -263,6 +282,7 @@ class NitroInputView(context: Context) : FrameLayout(context) { set(value) { if (field == value) return field = value + deriveColors() editText.highlightColor = value.selectionColor ?: defaultHighlightColor applyNativeCursor() restartBlink() @@ -338,9 +358,6 @@ class NitroInputView(context: Context) : FrameLayout(context) { /** The numeric value of [text] in number mode; NaN otherwise or when empty. */ fun currentValue(): Double = if (format.mode == Mode.NUMBER) formatter.value(text) else Double.NaN - val hasInputFocus: Boolean - get() = editText.hasFocus() - // endregion // region Views @@ -368,6 +385,18 @@ class NitroInputView(context: Context) : FrameLayout(context) { super.clearFocus() isFocusableInTouchMode = wasFocusable } + + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { + val connection = super.onCreateInputConnection(outAttrs) + // The platform gives every wrapping field IME_FLAG_NO_ENTER_ACTION, so + // the IME's return key inserts a line break. One that submits instead + // has to lose the flag, or the IME never shows the action key nor sends + // the action - the same fix-up ReactEditText applies. + if (keyboard.multiline && keyboard.submitBehavior != SubmitBehavior.NEWLINE) { + outAttrs.imeOptions = outAttrs.imeOptions and EditorInfo.IME_FLAG_NO_ENTER_ACTION.inv() + } + return connection + } } /// `contextMenuHidden`: an action-mode callback that never creates a menu. @@ -390,8 +419,8 @@ class NitroInputView(context: Context) : FrameLayout(context) { // region State + /** One drawn glyph. The engine also reports an id and an exiting flag per slot; nothing here draws by them, so they are skipped. */ private class Glyph { - var id = 0L var character = 0 var role = 1 var kind = 0 @@ -401,13 +430,36 @@ class NitroInputView(context: Context) : FrameLayout(context) { var y = 0f var opacity = 1f var scale = 1f - var exiting = false } private val engine = MorphEngine() private val formatter = AmountFormatter() private val maskEngine = MaskEngine() private val density = context.resources.displayMetrics.density + /** + * The theme's primary text and accent colours. Resolved here, on a + * configuration change and never per frame: each lookup allocates a + * `TypedValue`, walks the theme and fetches a `ColorStateList`. + */ + private var themeTextColor = resolveThemeColor(android.R.attr.textColorPrimary, Color.BLACK) + private var themeCaretColor = resolveThemeColor(android.R.attr.colorAccent, 0xFF2196F3.toInt()) + /** `typography.color`, or the theme's. Derived whenever the fonts are rebuilt. */ + private var resolvedTextColor = themeTextColor + /** + * [resolvedTextColor] at hint strength: what the placeholder, the stroke and + * the label fall back to. A React Native activity usually runs a bare + * `AppCompat` theme with no text appearance, so `android.R.attr.textColorHint` + * comes back as the *primary* text colour — which would draw the placeholder + * solid black instead of the muted grey every other input on the platform + * shows. Deriving it from the text colour, the way iOS's `.placeholderText` + * is derived from `.label`, is theme-independent and still reads correctly + * on a dark background. + */ + private var mutedTextColor = muted(themeTextColor) + /** `typography.placeholderColor`, or [mutedTextColor]. */ + private var resolvedPlaceholderColor = mutedTextColor + /** `caret.color`, or the theme's accent. */ + private var resolvedCaretColor = themeCaretColor private var fonts: FontSet = FontSet(Typography()) private val glyphs = ArrayList() /** Reused per frame so the JNI hop never allocates; grown on demand. */ @@ -502,15 +554,29 @@ class NitroInputView(context: Context) : FrameLayout(context) { val isEnter = event != null && event.keyCode == KeyEvent.KEYCODE_ENTER if (isEnter && event.action != KeyEvent.ACTION_DOWN) return@setOnEditorActionListener true if (actionId == EditorInfo.IME_ACTION_NONE && !isEnter) return@setOnEditorActionListener false - if (worklets.onKeyPress != 0) NitroInputWorklets.runKeyPress(worklets.onKeyPress, "Enter") - if (worklets.onSubmitEditing != 0) { - NitroInputWorklets.runSubmitEditing(worklets.onSubmitEditing, text) + // The three-way `submitBehavior`, decided the way ReactEditText decides + // it: `submit` fires and keeps focus so a form can move on itself, + // `blurAndSubmit` fires and dismisses the keyboard, `newline` does + // neither - on a wrapping field it is the line break. + val behavior = keyboard.submitBehavior + val submits = behavior != SubmitBehavior.NEWLINE + val blurs = behavior == SubmitBehavior.BLUR_AND_SUBMIT + if (submits) { + if (worklets.onKeyPress != 0) NitroInputWorklets.runKeyPress(worklets.onKeyPress, "Enter") + if (worklets.onSubmitEditing != 0) { + NitroInputWorklets.runSubmitEditing(worklets.onSubmitEditing, text) + } + onKeyPress?.invoke("Enter") + onSubmit?.invoke(text) } - onKeyPress?.invoke("Enter") - onSubmit?.invoke(text) - // `submitBehavior: 'submit'` keeps focus so a form can move on itself. - if (keyboard.blurOnSubmit) blur() - true + if (blurs) blur() + // Consumed unless a wrapping field is to get its line break. A single + // line never inserts one, so there `newline` swallows the key, which is + // what React Native does too. + if (submits || blurs || !keyboard.multiline) return@setOnEditorActionListener true + // A full-screen IME (landscape) renders the `returnKeyType` action even + // on a wrapping field; next / previous must not move focus on their own. + actionId == EditorInfo.IME_ACTION_NEXT || actionId == EditorInfo.IME_ACTION_PREVIOUS } addView(editText, matchParent) @@ -628,8 +694,10 @@ class NitroInputView(context: Context) : FrameLayout(context) { val paint = TextPaint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) paint.typeface = makeTypeface(t) paint.textSize = sizeDp * density - paint.color = t.color ?: defaultTextColor() - if (format.mode == Mode.NUMBER) paint.fontFeatureSettings = "tnum" + paint.color = t.color ?: themeTextColor + // Tabular figures in every mode, as on iOS: the engine slides digit runs + // in text mode too, and proportional digits would jitter as they change. + paint.fontFeatureSettings = "tnum" return paint } @@ -667,29 +735,33 @@ class NitroInputView(context: Context) : FrameLayout(context) { } } - private fun defaultTextColor(): Int = resolveThemeColor(android.R.attr.textColorPrimary, Color.BLACK) - /** - * A React Native activity usually runs a bare `AppCompat` theme with no text - * appearance, so `android.R.attr.textColorHint` comes back as the *primary* - * text colour — which draws the placeholder solid black instead of the muted - * grey every other input on the platform shows. Derive it from the text - * colour instead, the way iOS's `.placeholderText` is derived from `.label`: - * theme-independent, and it still reads correctly on a dark background. - */ - private fun defaultPlaceholderColor(): Int { - val text = typography.color ?: defaultTextColor() - return Color.argb( - (Color.alpha(text) * PLACEHOLDER_ALPHA).toInt(), - Color.red(text), - Color.green(text), - Color.blue(text), - ) + /** [color] at placeholder strength: the same hue, its alpha multiplied down. */ + private fun muted(color: Int): Int = Color.argb( + (Color.alpha(color) * PLACEHOLDER_ALPHA).toInt(), + Color.red(color), + Color.green(color), + Color.blue(color), + ) + + /** Re-reads the theme (a light / dark switch) and everything derived from it. */ + private fun refreshThemeColors() { + themeTextColor = resolveThemeColor(android.R.attr.textColorPrimary, Color.BLACK) + themeCaretColor = resolveThemeColor(android.R.attr.colorAccent, 0xFF2196F3.toInt()) + deriveColors() + } + + /** The colours the frames draw with, settled once here rather than per frame. */ + private fun deriveColors() { + resolvedTextColor = typography.color ?: themeTextColor + mutedTextColor = muted(resolvedTextColor) + resolvedPlaceholderColor = typography.placeholderColor ?: mutedTextColor + resolvedCaretColor = caret.color ?: themeCaretColor } - private fun defaultCaretColor(): Int = resolveThemeColor(android.R.attr.colorAccent, 0xFF2196F3.toInt()) private fun charString(codePoint: Int): String = charCache.getOrPut(codePoint) { String(Character.toChars(codePoint)) } private fun rebuildFonts() { + deriveColors() fonts = FontSet(typography) applyEditTextLayout() reportIntrinsicSize() @@ -708,6 +780,10 @@ class NitroInputView(context: Context) : FrameLayout(context) { // own - unlike the overlay, which positions its own glyphs. if (typography.lineHeightPx > 0f) { TextViewCompat.setLineHeight(editText, typography.lineHeightPx.roundToInt()) + } else { + // `setLineHeight` is line spacing underneath; back to the font's own + // once the override is gone, or the last value would stick. + editText.setLineSpacing(0f, 1f) } // An outlined or filled frame reserves room at the sides for its stroke and // at the top for the floated label. A single line is centred in whatever @@ -820,7 +896,14 @@ class NitroInputView(context: Context) : FrameLayout(context) { ReturnKeyType.SEARCH -> EditorInfo.IME_ACTION_SEARCH ReturnKeyType.SEND -> EditorInfo.IME_ACTION_SEND } - editText.filters = if (!numberMode && k.maxLength > 0) arrayOf(InputFilter.LengthFilter(k.maxLength)) else emptyArray() + // Text mode only, as on iOS. A mask's pattern is its own length, and + // `setEditable` replaces through the `Editable`, which re-runs the filters: + // a length filter there clipped the mask's punctuation off the end. + editText.filters = if (format.mode == Mode.TEXT && k.maxLength > 0) { + arrayOf(InputFilter.LengthFilter(k.maxLength)) + } else { + emptyArray() + } editText.isEnabled = k.editable editText.isFocusable = k.editable editText.isFocusableInTouchMode = k.editable @@ -906,7 +989,7 @@ class NitroInputView(context: Context) : FrameLayout(context) { view.gravity = Gravity.CENTER_VERTICAL view.typeface = paint.typeface view.setTextSize(TypedValue.COMPLEX_UNIT_PX, paint.textSize) - view.setTextColor(typography.color ?: defaultTextColor()) + view.setTextColor(resolvedTextColor) if (view.text?.toString() != affix) view.text = affix return view } @@ -933,8 +1016,8 @@ class NitroInputView(context: Context) : FrameLayout(context) { overlay.visibility = GONE stopAnimation() engine.reset() - editText.setTextColor(typography.color ?: defaultTextColor()) - editText.setHintTextColor(typography.placeholderColor ?: defaultPlaceholderColor()) + editText.setTextColor(resolvedTextColor) + editText.setHintTextColor(resolvedPlaceholderColor) applyNativeCursor() applyAutoSize() applyAffixes() @@ -992,7 +1075,13 @@ class NitroInputView(context: Context) : FrameLayout(context) { if (!keyboard.editable) return editText.isFocusableInTouchMode = true if (!editText.hasFocus()) editText.requestFocus() - inputMethodManager()?.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT) + // `showSoftInputOnFocus: false` keeps focus and the caret but no keyboard; + // the EditText honours it on its own, so only the explicit ask is guarded. + // No flags, as ReactEditText asks: `SHOW_IMPLICIT` is deprecated and only + // ever let the platform decline to show the keyboard. + if (keyboard.showSoftInputOnFocus) { + inputMethodManager()?.showSoftInput(editText, 0) + } } /// Moves the caret/selection to `start`..`end`, in code points. @@ -1119,6 +1208,12 @@ class NitroInputView(context: Context) : FrameLayout(context) { override fun onAttachedToWindow() { super.onAttachedToWindow() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + animatorScale = -1f + context.contentResolver.registerContentObserver( + Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE), false, animatorScaleObserver, + ) + } scheduleFrameIfNeeded() restartBlink() syncAccessibilityFromHost() @@ -1128,9 +1223,23 @@ class NitroInputView(context: Context) : FrameLayout(context) { override fun onDetachedFromWindow() { stopAnimation() removeCallbacks(blink) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + context.contentResolver.unregisterContentObserver(animatorScaleObserver) + } super.onDetachedFromWindow() } + override fun onConfigurationChanged(newConfig: Configuration?) { + super.onConfigurationChanged(newConfig) + // The paints resolve the theme's colours and the system font scale when + // they are built; a light / dark switch or a text-size change rebuilds + // them, and a plain field's own text colour follows. + refreshThemeColors() + rebuildFonts() + applyPlain() + invalidate() + } + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) overlay.invalidate() @@ -1213,7 +1322,13 @@ class NitroInputView(context: Context) : FrameLayout(context) { // Like React Native's `onKeyPress`: the inserted text, or 'Backspace'. // Only for real key events — the watcher also runs for the initial set. if (editText.hasFocus()) { - val key = if (replacement.isEmpty()) "Backspace" else replacement + // A wrapping field's IME commits the line break as text; React Native + // reports that as the Enter key, and so does the single-line path. + val key = when (replacement) { + "" -> "Backspace" + "\n" -> "Enter" + else -> replacement + } if (worklets.onKeyPress != 0) NitroInputWorklets.runKeyPress(worklets.onKeyPress, key) onKeyPress?.invoke(key) } @@ -1409,12 +1524,27 @@ class NitroInputView(context: Context) : FrameLayout(context) { } } + /** + * The animator duration scale on API < 33, read once and refreshed by + * [animatorScaleObserver] (a settings query per keystroke was a binder call + * on every edit). -1 = not read yet. + */ + private var animatorScale = -1f + private val animatorScaleObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + animatorScale = -1f + } + } + /** True when the user removed animations (Android's animator scale is 0), the equivalent of Reduce Motion. */ private fun animationsDisabled(): Boolean { val scale = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ValueAnimator.getDurationScale() } else { - android.provider.Settings.Global.getFloat(context.contentResolver, android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + if (animatorScale < 0f) { + animatorScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + } + animatorScale } return scale <= 0f } @@ -1433,9 +1563,10 @@ class NitroInputView(context: Context) : FrameLayout(context) { while (glyphs.size < count) glyphs.add(Glyph()) while (glyphs.size > count) glyphs.removeAt(glyphs.size - 1) for (i in 0 until count) { + // Slot 0 is the engine's glyph id and slot 10 its exiting flag; neither + // is needed to draw, so they are left in the buffer. val base = 3 + i * 11 val g = glyphs[i] - g.id = f[base].toLong() g.character = f[base + 1].toInt() g.role = f[base + 2].toInt() g.kind = f[base + 3].toInt() @@ -1445,7 +1576,6 @@ class NitroInputView(context: Context) : FrameLayout(context) { g.y = f[base + 7].toFloat() g.opacity = f[base + 8].toFloat() g.scale = f[base + 9].toFloat() - g.exiting = f[base + 10] != 0.0 } } @@ -1591,8 +1721,8 @@ class NitroInputView(context: Context) : FrameLayout(context) { val boxHeight = max(0, (view.height - top).roundToInt()) val layout = contentLayout(boxWidth, boxHeight, contentWidth, trackCaret = true) val lineHeight = f.lineHeight - val textColor = typography.color ?: defaultTextColor() - val placeholderColor = typography.placeholderColor ?: defaultPlaceholderColor() + val textColor = resolvedTextColor + val placeholderColor = resolvedPlaceholderColor val effect = timing.effect val outer = canvas.save() @@ -1636,7 +1766,7 @@ class NitroInputView(context: Context) : FrameLayout(context) { val w = CARET_WIDTH_DP * density val h = lineHeight * (1f - CARET_INSET) val top = (lineHeight - h) / 2f - caretPaint.color = caret.color ?: defaultCaretColor() + caretPaint.color = resolvedCaretColor canvas.drawRoundRect(x, top, x + w, top + h, w / 2f, w / 2f, caretPaint) } canvas.restoreToCount(outer) @@ -1695,12 +1825,12 @@ class NitroInputView(context: Context) : FrameLayout(context) { get() = if (inputFrame.hasLabel && !labelShouldFloat) "" else format.placeholder private fun resolvedStrokeColor(): Int { - val base = inputFrame.strokeColor ?: defaultPlaceholderColor() + val base = inputFrame.strokeColor ?: mutedTextColor return blend(base, inputFrame.focusedStrokeColor ?: base, focusProgress) } private fun resolvedLabelColor(): Int { - val resting = inputFrame.labelColor ?: defaultPlaceholderColor() + val resting = inputFrame.labelColor ?: mutedTextColor val focused = inputFrame.labelFocusedColor ?: inputFrame.focusedStrokeColor ?: resting return blend(resting, focused, focusProgress) } diff --git a/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/NitroInputWorklets.kt b/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/NitroInputWorklets.kt index 185b5a19..65b7a981 100644 --- a/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/NitroInputWorklets.kt +++ b/packages/react-native-nitro-input/android/src/main/java/com/margelo/nitro/nitroinput/NitroInputWorklets.kt @@ -11,8 +11,6 @@ import com.facebook.proguard.annotations.DoNotStrip @Keep @DoNotStrip object NitroInputWorklets { - @JvmStatic external fun isReady(): Boolean - /** The transformed text, or null when the worklet did not apply; the selection is in [lastSelectionStart] / [lastSelectionEnd]. */ @JvmStatic external fun runTransform( id: Int, diff --git a/packages/react-native-nitro-input/cpp/MaskEngine.cpp b/packages/react-native-nitro-input/cpp/MaskEngine.cpp index 60c8cc32..6c0b5f8b 100644 --- a/packages/react-native-nitro-input/cpp/MaskEngine.cpp +++ b/packages/react-native-nitro-input/cpp/MaskEngine.cpp @@ -283,11 +283,6 @@ bool MaskEngine::isActive() const { return compiled_ != nullptr && compiled_->initial != nullptr; } -const std::string& MaskEngine::format() const { - static const std::string empty; - return compiled_ != nullptr ? compiled_->format : empty; -} - namespace { const MaskEngine::State* makeState(std::vector>& arena, MaskEngine::State state) { arena.push_back(std::make_unique(state)); @@ -422,7 +417,6 @@ bool MaskEngine::setFormat(const std::string& format, const std::vectorinitial = initial; - next->format = format; compiled_ = std::move(next); return true; } @@ -592,43 +586,4 @@ MaskEngine::Result MaskEngine::applyEdit(const std::string& current, int start, return apply(AmountFormatter::encode(spliced), caret, !deleting, !deleting && autocomplete, deleting && autoSkip); } -// MARK: - Introspection - -std::string MaskEngine::placeholder() const { - if (!isActive()) return ""; - CodePoints out; - for (const State* s = compiled_->initial; s != nullptr;) { - if (s->slot == Slot::EndOfLine) break; - if (s->slot == Slot::Free || s->slot == Slot::Fixed) { - out.push_back(s->ownCharacter); - } else if (s->elliptical) { - break; - } else { - out.push_back(s->placeholderCharacter()); - } - s = s->child; - } - return AmountFormatter::encode(out); -} - -int MaskEngine::totalTextLength() const { - if (!isActive()) return 0; - int length = 0; - for (const State* s = compiled_->initial; s != nullptr && s->slot != Slot::EndOfLine; s = s->child) { - length += 1; - if (s->elliptical) break; - } - return length; -} - -int MaskEngine::totalValueLength() const { - if (!isActive()) return 0; - int length = 0; - for (const State* s = compiled_->initial; s != nullptr && s->slot != Slot::EndOfLine; s = s->child) { - if (s->slot != Slot::Free) length += 1; - if (s->elliptical) break; - } - return length; -} - } // namespace margelo::nitro::nitroinput diff --git a/packages/react-native-nitro-input/cpp/MaskEngine.hpp b/packages/react-native-nitro-input/cpp/MaskEngine.hpp index 11aa9959..81f01eb1 100644 --- a/packages/react-native-nitro-input/cpp/MaskEngine.hpp +++ b/packages/react-native-nitro-input/cpp/MaskEngine.hpp @@ -93,9 +93,6 @@ class MaskEngine final { /// False until a valid format has been set; `apply` then passes text through. bool isActive() const; - /// The format currently compiled, as given. - const std::string& format() const; - /// Masks `text` whole. `caret` is a code point offset into `text`; /// `caretForward` biases the caret to the right of an inserted character, /// which is what you want after typing and not after deleting. @@ -108,13 +105,6 @@ class MaskEngine final { Result applyEdit(const std::string& current, int start, int end, const std::string& replacement, bool autocomplete, bool autoSkip) const; - /// The whole mask with nothing typed into it, e.g. "+1 (000) 000-0000". - std::string placeholder() const; - - /// Slot counts, for sizing and for choosing between candidate masks. - int totalTextLength() const; - int totalValueLength() const; - public: /// Implementation detail, public only so the compiler's helpers can name it. /// The state chain and its transitions are defined in the .cpp. @@ -130,7 +120,6 @@ class MaskEngine final { struct Compiled { std::vector> arena; const State* initial = nullptr; - std::string format; std::vector notations; }; diff --git a/packages/react-native-nitro-input/cpp/MorphEngine.cpp b/packages/react-native-nitro-input/cpp/MorphEngine.cpp index ddb8599e..b79ff8a4 100644 --- a/packages/react-native-nitro-input/cpp/MorphEngine.cpp +++ b/packages/react-native-nitro-input/cpp/MorphEngine.cpp @@ -278,9 +278,6 @@ void MorphEngine::commitText(int caretIndex, double now) { return -1; }; - // A run of adjacent glyphs that are wholly replaced — no survivor inside it — - // recedes as one shape instead of each glyph sliding a line box on its own. A - // survivor breaks the run: it is right there to animate against. // A run of adjacent glyphs that are wholly replaced — no survivor inside it — // recedes as one shape instead of each glyph sliding a line box on its own. A // survivor breaks the run: it is right there to animate against. @@ -822,10 +819,21 @@ void MorphEngine::publish() { glyphs_.clear(); glyphs_.reserve(slots_.size()); for (const auto& s : slots_) glyphs_.push_back(s.g); + caretBlocks_ = CaretBlocks{}; if (!rightToLeft_ || glyphs_.empty()) return; const Blocks b = blocksOf(glyphs_); const double total = contentWidth_; for (auto& g : glyphs_) g.x = mirrored(g.x, b.of(g), total); + // The caret lives among the live glyphs alone - one on its way out is drawn + // but is not part of the text - so its blocks are measured over those, from + // the slots' left-to-right positions, and kept for `caretX`. + liveBuf_.clear(); + for (const auto& s : slots_) { + if (!s.g.exiting) liveBuf_.push_back(s.g); + } + const Blocks live = blocksOf(liveBuf_); + caretBlocks_.sign = Extent{live.sign.start, live.sign.end, live.sign.any}; + caretBlocks_.rest = Extent{live.rest.start, live.rest.end, live.rest.any}; } void MorphEngine::setRightToLeft(bool rightToLeft) { @@ -853,16 +861,11 @@ double MorphEngine::caretX(int index) const { if (!rightToLeft_) return x; // The caret lives in the body's block - the sign's for index 0 when the sign // is laid out ahead of the prefix - and moves with it. An empty body has no - // block, and its caret is a point: mirror the point. - std::vector live; - live.reserve(slots_.size()); - for (const auto& s : slots_) { - if (!s.g.exiting) live.push_back(s.g); - } - const Blocks b = blocksOf(live); - const Block& block = (index == 0 && b.sign.any) ? b.sign : b.rest; + // block, and its caret is a point: mirror the point. The blocks were + // measured when the frame was published (see `publish`). + const Extent& block = (index == 0 && caretBlocks_.sign.any) ? caretBlocks_.sign : caretBlocks_.rest; if (!block.any) return contentWidth_ - x; - return mirrored(x, block, contentWidth_); + return contentWidth_ - block.end + (x - block.start); } void MorphEngine::reset() { @@ -873,6 +876,7 @@ void MorphEngine::reset() { pending_.clear(); slots_.clear(); glyphs_.clear(); + caretBlocks_ = CaretBlocks{}; committed_ = false; animating_ = false; contentWidth_ = targetWidth_ = widthFrom_ = 0; diff --git a/packages/react-native-nitro-input/cpp/MorphEngine.hpp b/packages/react-native-nitro-input/cpp/MorphEngine.hpp index 1576e22e..93647978 100644 --- a/packages/react-native-nitro-input/cpp/MorphEngine.hpp +++ b/packages/react-native-nitro-input/cpp/MorphEngine.hpp @@ -89,7 +89,6 @@ class MorphEngine final { /// same way in every script). Applied to the published frames and `caretX`; /// the layout itself is always computed left-to-right. void setRightToLeft(bool rightToLeft); - bool rightToLeft() const { return rightToLeft_; } // MARK: Text @@ -172,6 +171,25 @@ class MorphEngine final { /// Copies the slots' frames into `glyphs_`, mirrored when right-to-left. void publish(); + /// The horizontal extent of one block of the run (see `Blocks` in the .cpp). + struct Extent { + double start = 0; + double end = 0; + bool any = false; + }; + /// The two blocks a caret can sit in - a sign laid out ahead of the prefix, + /// and the rest of the body - measured over the live glyphs each time a + /// frame is published, so `caretX` under right-to-left neither allocates + /// nor re-measures the run on every call. + struct CaretBlocks { + Extent sign; + Extent rest; + }; + CaretBlocks caretBlocks_; + /// Scratch for measuring the live glyphs in `publish`, kept so a frame + /// allocates nothing once it has grown. + std::vector liveBuf_; + double duration_ = 0.4; int easing_ = 0; double bounce_ = 0.15; diff --git a/packages/react-native-nitro-input/cpp/OutlineGeometry.cpp b/packages/react-native-nitro-input/cpp/OutlineGeometry.cpp index 76e77a28..4044f379 100644 --- a/packages/react-native-nitro-input/cpp/OutlineGeometry.cpp +++ b/packages/react-native-nitro-input/cpp/OutlineGeometry.cpp @@ -41,13 +41,47 @@ OutlineGeometry::Gap OutlineGeometry::gapFor(const Rect& floatedLabel, double pa return gap; } -std::vector OutlineGeometry::outline(const Box& box, const Gap& gap, double progress) { - std::vector path; +namespace { +using Box = OutlineGeometry::Box; +using Gap = OutlineGeometry::Gap; +using Verb = OutlineGeometry::Verb; + +/// Appends `Segment`s to a vector: the form iOS and the docs consume. +struct SegmentSink { + std::vector& path; + void move(double x, double y) { path.push_back({Verb::Move, {x, y}, 0, 0, 0}); } + void line(double x, double y) { path.push_back({Verb::Line, {x, y}, 0, 0, 0}); } + void arc(double cx, double cy, double r, double start, double sweep) { + path.push_back({Verb::Arc, {cx, cy}, r, start, sweep}); + } +}; + +/// Appends `kFlatStride` doubles per segment: the form the Android bridge +/// copies straight into a `double[]`. +struct FlatSink { + std::vector& out; + void emit(Verb verb, double x, double y, double r, double start, double sweep) { + out.push_back(static_cast(static_cast(verb))); + out.push_back(x); + out.push_back(y); + out.push_back(r); + out.push_back(start); + out.push_back(sweep); + } + void move(double x, double y) { emit(Verb::Move, x, y, 0, 0, 0); } + void line(double x, double y) { emit(Verb::Line, x, y, 0, 0, 0); } + void arc(double cx, double cy, double r, double start, double sweep) { emit(Verb::Arc, cx, cy, r, start, sweep); } +}; + +/// The one tracing of the outline; the public overloads only differ in the +/// sink the segments land in. +template +void trace(const Box& box, const Gap& gap, double progress, Sink& sink) { const double width = std::max(0.0, finite(box.width, 0)); const double height = std::max(0.0, finite(box.height, 0)); const double stroke = std::max(0.0, finite(box.strokeWidth, 0)); - if (width <= stroke || height <= stroke) return path; + if (width <= stroke || height <= stroke) return; // The path runs down the middle of the stroke, so it is inset by half of it. const double inset = stroke / 2; @@ -82,34 +116,44 @@ std::vector OutlineGeometry::outline(const Box& box, c } } - const auto line = [&](double x, double y) { path.push_back(Segment{Verb::Line, Point{x, y}, 0, 0, 0}); }; - const auto arc = [&](double cx, double cy, double r, double start, double sweep) { - path.push_back(Segment{Verb::Arc, Point{cx, cy}, r, start, sweep}); - }; - // The gap is always expressed, even when it is zero wide, so the path has the // same verbs and the same point count at every progress. That is what lets // Core Animation and ValueAnimator interpolate between two of them: a path // that changed shape mid-animation could not be tweened, and would need a // display link redrawing it every frame instead. if (gapWidth <= 0) gapStart = straightStart; - path.push_back(Segment{Verb::Move, Point{gapStart + gapWidth, top}, 0, 0, 0}); - line(straightEnd, top); + sink.move(gapStart + gapWidth, top); + sink.line(straightEnd, top); // Clockwise from the top-right corner. Angles are measured from the positive // x axis with y growing downwards, which is what both platforms use. - if (topRadius > 0) arc(straightEnd, top + topRadius, topRadius, -90, 90); - line(right, bottom - bottomRadius); - if (bottomRadius > 0) arc(bottomEnd, bottom - bottomRadius, bottomRadius, 0, 90); - line(bottomStart, bottom); - if (bottomRadius > 0) arc(bottomStart, bottom - bottomRadius, bottomRadius, 90, 90); - line(left, top + topRadius); - if (topRadius > 0) arc(straightStart, top + topRadius, topRadius, 180, 90); + if (topRadius > 0) sink.arc(straightEnd, top + topRadius, topRadius, -90, 90); + sink.line(right, bottom - bottomRadius); + if (bottomRadius > 0) sink.arc(bottomEnd, bottom - bottomRadius, bottomRadius, 0, 90); + sink.line(bottomStart, bottom); + if (bottomRadius > 0) sink.arc(bottomStart, bottom - bottomRadius, bottomRadius, 90, 90); + sink.line(left, top + topRadius); + if (topRadius > 0) sink.arc(straightStart, top + topRadius, topRadius, 180, 90); // Back to the near edge of the gap - the same point we started from when the // gap is closed, so the rectangle reads as unbroken. - line(gapStart, top); + sink.line(gapStart, top); +} + +} // namespace + +std::vector OutlineGeometry::outline(const Box& box, const Gap& gap, double progress) { + std::vector path; + SegmentSink sink{path}; + trace(box, gap, progress, sink); return path; } +size_t OutlineGeometry::outline(const Box& box, const Gap& gap, double progress, std::vector& out) { + out.clear(); + FlatSink sink{out}; + trace(box, gap, progress, sink); + return out.size() / kFlatStride; +} + } // namespace margelo::nitro::nitroinput diff --git a/packages/react-native-nitro-input/cpp/OutlineGeometry.hpp b/packages/react-native-nitro-input/cpp/OutlineGeometry.hpp index f9d3e3fe..ecda263c 100644 --- a/packages/react-native-nitro-input/cpp/OutlineGeometry.hpp +++ b/packages/react-native-nitro-input/cpp/OutlineGeometry.hpp @@ -19,6 +19,7 @@ #pragma once +#include #include #include @@ -77,6 +78,17 @@ class OutlineGeometry final { /// also yields an unbroken rectangle. static std::vector outline(const Box& box, const Gap& gap, double progress); + /// Doubles per segment in the flat form below: verb, x, y, radius, + /// startAngle, sweepAngle. + static constexpr size_t kFlatStride = 6; + + /// The same outline, flattened into a caller-owned vector - `kFlatStride` + /// doubles per segment, the verb as its integer value - and the number of + /// segments written. `out` is cleared and refilled, never shrunk, so a + /// caller that keeps one vector allocates nothing once it has grown to fit: + /// that is the form the Android bridge redraws from every frame. + static size_t outline(const Box& box, const Gap& gap, double progress, std::vector& out); + /// Linear interpolation of a rectangle, for animating the label between its /// resting place inside the field and its floated place on the outline. /// Both rects are measured by the platform. diff --git a/packages/react-native-nitro-input/cpp/__tests__/MaskEngineTest.cpp b/packages/react-native-nitro-input/cpp/__tests__/MaskEngineTest.cpp index 657c0be7..777ba0c1 100644 --- a/packages/react-native-nitro-input/cpp/__tests__/MaskEngineTest.cpp +++ b/packages/react-native-nitro-input/cpp/__tests__/MaskEngineTest.cpp @@ -183,12 +183,10 @@ static void completeTracksMandatorySlots() { static void tailPlaceholderShowsWhatIsMissing() { const MaskEngine phone = make("+1 ([000]) [000]-[0000]"); - CHECK_EQ_STR(phone.placeholder(), "+1 (000) 000-0000"); CHECK_EQ_STR(type(phone, "212").tailPlaceholder, "000-0000"); CHECK_EQ_STR(type(phone, "2125551234").tailPlaceholder, ""); const MaskEngine date = make("[00]{/}[00]"); - CHECK_EQ_STR(date.placeholder(), "00/00"); CHECK_EQ_STR(type(date, "12").tailPlaceholder, "00"); } @@ -228,9 +226,7 @@ static void deletingPullsTheCaretBack() { static void blocksAreNormalized() { // Optional digits sort behind mandatory ones: [9900] means [0099]. const MaskEngine engine = make("[9900]"); - // The placeholder shows the class, so all four read "0"; that the two - // mandatory slots came first is what `complete` proves. - CHECK_EQ_STR(engine.placeholder(), "0000"); + // That the two mandatory slots came first is what `complete` proves. CHECK(type(engine, "12").complete); CHECK(!make("[0099]").isActive() == false); @@ -267,7 +263,6 @@ static void customNotations() { const MaskEngine engine = make("#[HHHHHH]", {hex}); CHECK_EQ_STR(type(engine, "1a2b3c").formattedText, "#1a2b3c"); CHECK_EQ_STR(type(engine, "1z2").formattedText, "#12"); - CHECK_EQ_STR(engine.placeholder(), "#HHHHHH"); // An optional custom notation may be skipped. MaskEngine::Notation maybe{'h', {'x', 'y'}, true}; @@ -335,7 +330,6 @@ static void copiesShareTheCompiledMask() { CHECK(copy.isActive()); CHECK_EQ_STR(type(copy, "ab").formattedText, "ab"); CHECK_EQ_STR(type(copy, "az").formattedText, "a"); - CHECK_EQ_STR(copy.placeholder(), "HH"); // Re-compiling one does not disturb the other. MaskEngine other = copy; diff --git a/packages/react-native-nitro-input/cpp/__tests__/OutlineGeometryTest.cpp b/packages/react-native-nitro-input/cpp/__tests__/OutlineGeometryTest.cpp index 8095a7bd..404342cc 100644 --- a/packages/react-native-nitro-input/cpp/__tests__/OutlineGeometryTest.cpp +++ b/packages/react-native-nitro-input/cpp/__tests__/OutlineGeometryTest.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -243,6 +244,48 @@ static void bottomCornersCanBeSquaredOnTheirOwn() { CHECK(open.back().point.x >= 8.5 - 1e-9); } +/// The flat form the Android bridge draws from says exactly what the segment +/// form says, six doubles per segment, and reuses the caller's vector. +static void flatFormMatchesTheSegments() { + CHECK(OutlineGeometry::kFlatStride == 6); + std::vector flat; + const OutlineGeometry::Gap gap{24, 60, 4}; + OutlineGeometry::Box squaredBottom{300, 56, 8, 1}; + squaredBottom.bottomRadius = 0; + const OutlineGeometry::Box boxes[] = {box(), box(300, 56, 0, 1), box(300, 56, 999, 2), squaredBottom}; + for (const auto& b : boxes) { + for (double t : {0.0, 0.37, 1.0}) { + const auto segments = OutlineGeometry::outline(b, gap, t); + const size_t count = OutlineGeometry::outline(b, gap, t, flat); + CHECK(count == segments.size()); + CHECK(flat.size() == segments.size() * OutlineGeometry::kFlatStride); + for (size_t i = 0; i < segments.size() && (i + 1) * OutlineGeometry::kFlatStride <= flat.size(); ++i) { + const double* s = flat.data() + i * OutlineGeometry::kFlatStride; + CHECK_NEAR(s[0], static_cast(static_cast(segments[i].verb))); + CHECK_NEAR(s[1], segments[i].point.x); + CHECK_NEAR(s[2], segments[i].point.y); + CHECK_NEAR(s[3], segments[i].radius); + CHECK_NEAR(s[4], segments[i].startAngle); + CHECK_NEAR(s[5], segments[i].sweepAngle); + } + } + } + + // Once grown, redrawing allocates nothing: the storage stays where it was. + OutlineGeometry::outline(box(), gap, 1, flat); + const double* storage = flat.data(); + const size_t capacity = flat.capacity(); + for (double t : {0.0, 0.5, 1.0}) { + OutlineGeometry::outline(box(), gap, t, flat); + CHECK(flat.data() == storage); + CHECK(flat.capacity() == capacity); + } + + // A box with no outline writes nothing - and clears what was there before. + CHECK(OutlineGeometry::outline(box(0, 56), gap, 1, flat) == 0); + CHECK(flat.empty()); +} + int main() { everyPathHasTheSameShape(); closedWhenThereIsNoLabel(); @@ -257,6 +300,7 @@ int main() { labelRectInterpolates(); gapFollowsTheFloatedLabel(); bottomCornersCanBeSquaredOnTheirOwn(); + flatFormMatchesTheSegments(); if (failures == 0) { std::printf("OutlineGeometry: all checks passed\n"); return 0; diff --git a/packages/react-native-nitro-input/cpp/worklets/NitroInputWorkletsBridge.cpp b/packages/react-native-nitro-input/cpp/worklets/NitroInputWorkletsBridge.cpp index 90f18d42..14fb8bbc 100644 --- a/packages/react-native-nitro-input/cpp/worklets/NitroInputWorkletsBridge.cpp +++ b/packages/react-native-nitro-input/cpp/worklets/NitroInputWorkletsBridge.cpp @@ -68,15 +68,6 @@ bool isAvailable() { return true; } -bool isReady() { - return runtime() != nullptr; -} - -void uninstallRuntime() { - std::lock_guard lock(gMutex); - gRuntime.reset(); -} - bool installRuntime(Runtime& rt, const Value& holder) { if (!holder.isObject()) return false; Object object = holder.asObject(rt); @@ -185,16 +176,10 @@ bool isAvailable() { return false; } -bool isReady() { - return false; -} - bool installRuntime(facebook::jsi::Runtime&, const facebook::jsi::Value&) { return false; } -void uninstallRuntime() {} - TransformResult runTransform(int, const std::string&, const std::string&, int, int, int, int) { return TransformResult{false, "", 0, 0}; } diff --git a/packages/react-native-nitro-input/cpp/worklets/NitroInputWorkletsBridge.hpp b/packages/react-native-nitro-input/cpp/worklets/NitroInputWorkletsBridge.hpp index 3cd37e77..9fab260c 100644 --- a/packages/react-native-nitro-input/cpp/worklets/NitroInputWorkletsBridge.hpp +++ b/packages/react-native-nitro-input/cpp/worklets/NitroInputWorkletsBridge.hpp @@ -19,14 +19,8 @@ namespace margelo::nitro::nitroinput::nitroinputworklets { -/// True when the package was built against react-native-worklets and JS has -/// installed the UI runtime. -bool isReady(); /// True when react-native-worklets was available at build time. bool isAvailable(); -/// Forgets the installed runtime. The reference is weak, so this is only -/// needed to drop it eagerly; a torn-down runtime expires on its own. -void uninstallRuntime(); struct TransformResult { /// False when there is no worklet for `id`, it returned nothing, or it threw. diff --git a/packages/react-native-nitro-input/ios/HybridMorphInputView.swift b/packages/react-native-nitro-input/ios/HybridMorphInputView.swift index ec41e9ca..cf71fd45 100644 --- a/packages/react-native-nitro-input/ios/HybridMorphInputView.swift +++ b/packages/react-native-nitro-input/ios/HybridMorphInputView.swift @@ -103,7 +103,6 @@ final class HybridNitroInputView: HybridNitroInputViewSpec, RecyclableView { var suffix: String = "" { didSet { markConfigDirty() } } var prefixFontSize: Double = 32 { didSet { markConfigDirty() } } var suffixFontSize: Double = 32 { didSet { markConfigDirty() } } - var affixAlign: NitroInputAffixAlign = .baseline { didSet { markConfigDirty() } } var signPlacement: NitroInputSignPlacement = .beforeaffix { didSet { markConfigDirty() } } var prefixAlign: NitroInputAffixAlign = .baseline { didSet { markConfigDirty() } } var suffixAlign: NitroInputAffixAlign = .baseline { didSet { markConfigDirty() } } @@ -281,7 +280,6 @@ final class HybridNitroInputView: HybridNitroInputViewSpec, RecyclableView { suffix = "" prefixFontSize = 32 suffixFontSize = 32 - affixAlign = .baseline signPlacement = .beforeaffix prefixAlign = .baseline suffixAlign = .baseline @@ -472,7 +470,7 @@ final class HybridNitroInputView: HybridNitroInputViewSpec, RecyclableView { format.maskAutocomplete = maskAutocomplete format.maskAutoSkip = maskAutoSkip format.fractionDigits = Self.clampInt(fractionDigits, 0, 9, fallback: 2) - format.maxIntegerDigits = Self.clampInt(maxIntegerDigits, 1, 15, fallback: 15) + format.maxIntegerDigits = Self.clampInt(maxIntegerDigits, 1, 30, fallback: 15) format.groupingSeparator = groupingSeparator format.decimalSeparator = decimalSeparator.isEmpty ? "." : decimalSeparator format.prefix = prefix @@ -525,7 +523,7 @@ final class HybridNitroInputView: HybridNitroInputViewSpec, RecyclableView { traits.plain = plain traits.testID = fieldTestID.isEmpty ? nil : fieldTestID traits.accessibilityLabel = fieldAccessibilityLabel.isEmpty ? nil : fieldAccessibilityLabel - traits.blurOnSubmit = submitBehavior == .blurandsubmit + traits.submitBehavior = Self.mapSubmitBehavior(submitBehavior) traits.secureTextEntry = secureTextEntry traits.keyboardAppearance = Self.mapKeyboardAppearance(keyboardAppearance) traits.textContentType = Self.mapTextContentType(textContentType) @@ -635,6 +633,15 @@ final class HybridNitroInputView: HybridNitroInputViewSpec, RecyclableView { } } + private static func mapSubmitBehavior(_ behavior: NitroInputSubmitBehavior) -> NitroInputView.SubmitBehavior { + switch behavior { + case .submit: return .submit + case .newline: return .newline + case .blurandsubmit: return .blurAndSubmit + default: return .blurAndSubmit + } + } + private static func mapAlignment(_ align: NitroInputTextAlign) -> NitroInputView.Alignment { switch align { case .auto: return .auto diff --git a/packages/react-native-nitro-input/ios/NitroInputCommands.mm b/packages/react-native-nitro-input/ios/NitroInputCommands.mm index 770b28d1..8c6e5008 100644 --- a/packages/react-native-nitro-input/ios/NitroInputCommands.mm +++ b/packages/react-native-nitro-input/ios/NitroInputCommands.mm @@ -16,36 +16,50 @@ @interface HybridNitroInputViewComponent : RCTViewComponentView @end -/// The hidden system field NitroInputView keeps as its only subview. -static UITextField *NitroInputFindField(UIView *view) +/// The system editor NitroInputView keeps as a subview: a `UITextField`, or a +/// `UITextView` once the field wraps (`multiline`). The view keeps both around +/// and shows one, so the hidden one is passed over. +static UIView *NitroInputFindEditor(UIView *view) { - if ([view isKindOfClass:[UITextField class]]) { - return (UITextField *)view; + if ([view isKindOfClass:[UITextField class]] || [view isKindOfClass:[UITextView class]]) { + return view.isHidden ? nil : view; } for (UIView *subview in view.subviews) { - UITextField *field = NitroInputFindField(subview); - if (field != nil) { - return field; + UIView *editor = NitroInputFindEditor(subview); + if (editor != nil) { + return editor; } } return nil; } +/// Mirrors the `editable` prop: `isEnabled` on a text field, `isEditable` on +/// a text view. A read-only field declines focus either way. +static BOOL NitroInputEditorIsEditable(UIView *editor) +{ + if ([editor isKindOfClass:[UITextField class]]) { + return ((UITextField *)editor).isEnabled; + } + if ([editor isKindOfClass:[UITextView class]]) { + return ((UITextView *)editor).isEditable; + } + return NO; +} + @implementation HybridNitroInputViewComponent (NitroInputCommands) - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args { - UITextField *field = NitroInputFindField(self.contentView); - if (field == nil) { + UIView *editor = NitroInputFindEditor(self.contentView); + if (editor == nil) { return; } if ([(NSString *)commandName isEqualToString:@"focus"]) { - // `isEnabled` mirrors the `editable` prop, so a read-only field declines. - if (field.isEnabled) { - [field becomeFirstResponder]; + if (NitroInputEditorIsEditable(editor)) { + [editor becomeFirstResponder]; } } else if ([(NSString *)commandName isEqualToString:@"blur"]) { - [field resignFirstResponder]; + [editor resignFirstResponder]; } } diff --git a/packages/react-native-nitro-input/ios/NitroInputView.swift b/packages/react-native-nitro-input/ios/NitroInputView.swift index 59c124b3..f6861882 100644 --- a/packages/react-native-nitro-input/ios/NitroInputView.swift +++ b/packages/react-native-nitro-input/ios/NitroInputView.swift @@ -73,6 +73,17 @@ final class NitroInputView: UIView { /// Where a negative amount's sign sits relative to the prefix. enum SignPlacement: Equatable { case beforeAffix, afterAffix } + /// What the return key does, as React Native's `TextInput` has it on iOS. + enum SubmitBehavior: Equatable { + /// Fires `onSubmitEditing` and keeps focus, so a form can move on itself. + case submit + /// Fires `onSubmitEditing` and dismisses the keyboard. + case blurAndSubmit + /// Inserts a line break on a wrapping field; on a single line it neither + /// submits nor blurs. + case newline + } + enum Variant: Equatable { case none, outlined, filled } enum LabelBehavior: Equatable { case float, always } @@ -141,8 +152,7 @@ final class NitroInputView: UIView { var caretHidden: Bool = false var caretColor: UIColor? = nil var selectionColor: UIColor? = nil - /// `submitBehavior`: whether the return key also dismisses the keyboard. - var blurOnSubmit: Bool = true + var submitBehavior: SubmitBehavior = .blurAndSubmit var secureTextEntry: Bool = false var keyboardAppearance: UIKeyboardAppearance = .default var textContentType: UITextContentType? = nil @@ -215,7 +225,7 @@ final class NitroInputView: UIView { var traits = Traits() { didSet { guard traits != oldValue else { return } - applyTraits() + applyTraits(previous: oldValue) // Nothing else re-measures when a field turns multiline or changes how // many lines it may be: the text is unchanged, so no edit comes through // to carry a new height out with it. @@ -297,7 +307,6 @@ final class NitroInputView: UIView { guard format.mode == .number else { return .nan } return formatter.value(std.string(text)) } - var hasFocus: Bool { editor.isFirstResponder } /// Incremented on every native text change JS has not caused itself. private(set) var eventCount = 0 @@ -307,40 +316,68 @@ final class NitroInputView: UIView { case body, prefix, suffix } + /// One glyph in one role, as the caches key it. A value type rather than a + /// concatenated string: a lookup on the frame path must not allocate. + fileprivate struct GlyphKey: Hashable { + let text: String + let role: GlyphRole + let placeholder: Bool + } + /// Body / prefix / suffix fonts with per-glyph caches (advance widths, ink /// metrics and pre-rasterized images in the text or placeholder colour). fileprivate final class FontSet { let body: UIFont let prefix: UIFont let suffix: UIFont + /// The text and placeholder colours resolved for the view's traits: the + /// glyph images bake them in, and `.label` / `.placeholderText` are + /// dynamic (see `traitCollectionDidChange`). let color: UIColor let placeholderColor: UIColor let prefixAlign: AffixAlign let suffixAlign: AffixAlign /// Height of the line box (the body font's line height). let lineHeight: CGFloat + /// Pixel density the glyph images are rendered at. let renderScale: CGFloat - private var glyphCache: [String: NSAttributedString] = [:] - private var widthCache: [String: CGFloat] = [:] - private var imageCache: [String: UIImage] = [:] - private var inkDescentCache: [String: CGFloat] = [:] - - /// Font sets are immutable for a given `Typography` and their caches are - /// pure functions of it, so views that look alike share one. Without this - /// every field builds its own three `UIFont`s and re-rasterizes the same - /// glyphs: 20 identical fields did the work 20 times, and each field did it - /// twice over (once for the defaults in `init`, once when the real - /// typography arrived). Main-thread only, like the rest of the view. - private static var shared: [Typography: FontSet] = [:] - private static var sharedOrder: [Typography] = [] - - static func shared(for t: Typography) -> FontSet { - if let hit = shared[t] { return hit } - let made = FontSet(t) - shared[t] = made - sharedOrder.append(t) + private var glyphCache: [GlyphKey: NSAttributedString] = [:] + private var widthCache: [GlyphKey: CGFloat] = [:] + private var imageCache: [GlyphKey: UIImage] = [:] + private var inkDescentCache: [GlyphKey: CGFloat] = [:] + + /// What a shared font set is a pure function of: the typography, the + /// colours it resolves to under the view's appearance, and the display + /// density. Two views that look alike under the same appearance on the + /// same kind of screen share one; a light one and a dark one do not. + private struct SharedKey: Hashable { + let typography: Typography + let color: UIColor + let placeholderColor: UIColor + let scale: CGFloat + } + + /// Font sets are immutable for a given key and their caches are pure + /// functions of it, so views that look alike share one. Without this every + /// field builds its own three `UIFont`s and re-rasterizes the same glyphs: + /// 20 identical fields did the work 20 times, and each field did it twice + /// over (once for the defaults in `init`, once when the real typography + /// arrived). Main-thread only, like the rest of the view. + private static var shared: [SharedKey: FontSet] = [:] + private static var sharedOrder: [SharedKey] = [] + + static func shared(for t: Typography, traits: UITraitCollection) -> FontSet { + let key = SharedKey(typography: t, + color: t.color.resolvedColor(with: traits), + placeholderColor: t.placeholderColor.resolvedColor(with: traits), + scale: max(1, traits.displayScale)) + if let hit = shared[key] { return hit } + let made = FontSet(t, traits: traits) + shared[key] = made + sharedOrder.append(key) // A screen rarely uses many distinct typographies; keep the newest few - // so the caches cannot grow without bound. + // so the caches cannot grow without bound. The sets an appearance switch + // leaves behind age out the same way. if sharedOrder.count > 12 { let evicted = sharedOrder.removeFirst() shared.removeValue(forKey: evicted) @@ -348,14 +385,15 @@ final class NitroInputView: UIView { return made } - init(_ t: Typography) { - renderScale = max(1, UIScreen.main.scale) + init(_ t: Typography, traits: UITraitCollection) { + // The view's own display, not the main screen's (deprecated, and absent on visionOS). + renderScale = max(1, traits.displayScale) let scale = NitroInputView.systemFontMultiplier(t) body = NitroInputView.makeFont(size: t.fontSize * scale, weight: t.fontWeight, family: t.fontFamily) prefix = NitroInputView.makeFont(size: (t.prefixFontSize ?? t.fontSize) * scale, weight: t.fontWeight, family: t.fontFamily) suffix = NitroInputView.makeFont(size: (t.suffixFontSize ?? t.fontSize) * scale, weight: t.fontWeight, family: t.fontFamily) - color = t.color - placeholderColor = t.placeholderColor + color = t.color.resolvedColor(with: traits) + placeholderColor = t.placeholderColor.resolvedColor(with: traits) prefixAlign = t.prefixAlign suffixAlign = t.suffixAlign lineHeight = ceil(body.lineHeight) @@ -370,7 +408,7 @@ final class NitroInputView: UIView { } func attributed(_ text: String, role: GlyphRole, placeholder: Bool = false) -> NSAttributedString { - let key = cacheKey(text, role, placeholder) + let key = GlyphKey(text: text, role: role, placeholder: placeholder) if let cached = glyphCache[key] { return cached } let string = NSAttributedString(string: text, attributes: [ .font: font(for: role), @@ -383,7 +421,7 @@ final class NitroInputView: UIView { /// Advance width of `text` in the role's font, in points. func width(of text: String, role: GlyphRole) -> CGFloat { - let key = cacheKey(text, role, false) + let key = GlyphKey(text: text, role: role, placeholder: false) if let cached = widthCache[key] { return cached } let width = attributed(text, role: role).size().width widthCache[key] = width @@ -393,7 +431,7 @@ final class NitroInputView: UIView { /// The glyph rasterized once (colour baked in, at screen density): a frame /// only moves layers, the main thread never redraws a bitmap. func image(_ text: String, role: GlyphRole, placeholder: Bool) -> UIImage? { - let key = cacheKey(text, role, placeholder) + let key = GlyphKey(text: text, role: role, placeholder: placeholder) if let cached = imageCache[key] { return cached } let string = attributed(text, role: role, placeholder: placeholder) let size = string.size() @@ -411,7 +449,7 @@ final class NitroInputView: UIView { /// How far `text`'s ink hangs below the baseline (0 for digits and capitals). func inkDescent(_ text: String, role: GlyphRole) -> CGFloat { - let key = "ink|" + cacheKey(text, role, false) + let key = GlyphKey(text: text, role: role, placeholder: false) if let cached = inkDescentCache[key] { return cached } let line = CTLineCreateWithAttributedString(attributed(text, role: role)) let bounds = CTLineGetImageBounds(line, nil) @@ -439,16 +477,6 @@ final class NitroInputView: UIView { return affixBaseline - f.ascender } } - - private func cacheKey(_ text: String, _ role: GlyphRole, _ placeholder: Bool) -> String { - let r: String - switch role { - case .body: r = "b|" - case .prefix: r = "p|" - case .suffix: r = "s|" - } - return (placeholder ? "ph|" : "") + r + text - } } // MARK: - State @@ -473,7 +501,7 @@ final class NitroInputView: UIView { }() private lazy var labelLayer: CATextLayer = { let text = CATextLayer() - text.contentsScale = UIScreen.main.scale + text.contentsScale = max(1, traitCollection.displayScale) text.anchorPoint = CGPoint(x: 0, y: 0.5) text.alignmentMode = .left layer.addSublayer(text) @@ -498,7 +526,7 @@ final class NitroInputView: UIView { var frame = Frame() } fileprivate var lastFrameSnapshot = FrameSnapshot() - fileprivate var fonts: FontSet + fileprivate lazy var fonts = FontSet.shared(for: Typography(), traits: traitCollection) private var fontScale: CGFloat = 1 /// Horizontal offset (in view points) when the content is wider than the /// view: it follows the caret like a UITextField's own scrolling does. @@ -538,8 +566,18 @@ final class NitroInputView: UIView { private let clipLayer = CALayer() private let edgeMask = CAGradientLayer() private let caretLayer = CALayer() - /// One layer per live engine glyph id. - private var glyphLayers: [Int64: CALayer] = [:] + /// One layer per live engine glyph id, with what it was rasterized for. + /// `topOffset` is the affix's baseline correction (`FontSet.top` with the + /// line top at 0), a pure function of the role and text, so a frame neither + /// rebuilds the text from the code point nor measures ink. + private struct GlyphEntry { + let layer: CALayer + let topOffset: CGFloat + /// The render pass that last saw this glyph; one a pass missed has left. + var generation: UInt + } + private var glyphLayers: [Int64: GlyphEntry] = [:] + private var renderGeneration: UInt = 0 /// Where the content box sits in the bounds, as of the last render. private var placement: (origin: CGPoint, scale: CGFloat) = (.zero, 1) /// Last selection handed to `onSelectionChange`, so it only fires on a move. @@ -548,7 +586,6 @@ final class NitroInputView: UIView { // MARK: - Lifecycle override init(frame: CGRect) { - fonts = FontSet.shared(for: Typography()) super.init(frame: frame) isOpaque = false backgroundColor = .clear @@ -585,7 +622,7 @@ final class NitroInputView: UIView { formatter.setFormat(Int32(format.fractionDigits), Int32(format.maxIntegerDigits), std.string(format.groupingSeparator), std.string(format.decimalSeparator)) applyFonts() - applyTraits() + applyTraits(previous: traits) NotificationCenter.default.addObserver( self, selector: #selector(contentSizeCategoryDidChange), @@ -705,9 +742,42 @@ final class NitroInputView: UIView { override func didMoveToWindow() { super.didMoveToWindow() syncAccessibilityFromHost() + // The fonts were built against whatever traits the view had when it was + // made; the window's are the ones that count. + if window != nil { syncWithTraits(appearanceChanged: false) } maybeAutoFocus() } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + let appearanceChanged = previousTraitCollection.map { traitCollection.hasDifferentColorAppearance(comparedTo: $0) } ?? true + syncWithTraits(appearanceChanged: appearanceChanged) + } + + /// Everything drawn through Core Animation bakes the view's traits in: the + /// glyph images carry the resolved text colour and the pixel density, and + /// the frame's stroke, fill and label are `CGColor`s. A light/dark switch + /// (`.label`, `.separator` and the rest are dynamic) or a move to another + /// display rebuilds them, settled rather than animated: the field has not + /// changed, only what it is drawn with. + private func syncWithTraits(appearanceChanged: Bool) { + let scale = max(1, traitCollection.displayScale) + let fontsStale = scale != fonts.renderScale + || typography.color.resolvedColor(with: traitCollection) != fonts.color + || typography.placeholderColor.resolvedColor(with: traitCollection) != fonts.placeholderColor + guard fontsStale || appearanceChanged else { return } + if scale != contentLayer.contentsScale { + contentLayer.contentsScale = scale + if didBuildFrameLayers { labelLayer.contentsScale = scale } + } + if fontsStale { rebuildFonts() } + // The frame's colours are resolved as it is laid out, and an unchanged + // geometry would be skipped: forget the last one so this pass redraws. + lastFrameSnapshot = FrameSnapshot() + if wantsFrameDrawing { layoutFrame(animated: false) } + updateCaret() + } + /// `autoFocus`, taken **synchronously** as soon as the view has a window and /// the prop has arrived — whichever of the two happens last. /// @@ -906,7 +976,7 @@ final class NitroInputView: UIView { } private func rebuildFonts() { - fonts = FontSet.shared(for: typography) + fonts = FontSet.shared(for: typography, traits: traitCollection) fontScale = 1 // Every cached glyph image belongs to the old font set. clearGlyphLayers() @@ -1120,7 +1190,7 @@ final class NitroInputView: UIView { right: frameSideInset) } - private func applyTraits() { + private func applyTraits(previous: Traits) { applyMultiline() field.keyboardType = traits.keyboardType field.returnKeyType = traits.returnKeyType @@ -1141,7 +1211,7 @@ final class NitroInputView: UIView { // The mask changes what is drawn, not what is stored. if engine.hasText() { feedEngine(caret: -1) } } - applyPlain() + applyPlain(wasPlain: previous.plain) syncAccessibilityFromHost() field.showSoftInputOnFocus = traits.showSoftInputOnFocus field.contextMenuHidden = traits.contextMenuHidden @@ -1154,7 +1224,7 @@ final class NitroInputView: UIView { } /// Switches between "hidden field + overlay" and "the field draws itself". - private func applyPlain() { + private func applyPlain(wasPlain: Bool) { let plain = traits.plain defer { syncAccessibilityPlaceholder() } field.hidesNativeCaret = !plain || traits.caretHidden @@ -1183,6 +1253,11 @@ final class NitroInputView: UIView { attributes[.foregroundColor] = plain ? typography.color : UIColor.clear field.defaultTextAttributes = attributes updateFieldInsets() + // Turning plain on tore the overlay down and reset the engine, so on the + // way back it has nothing to draw until the next edit: hand it the text. + if wasPlain, !plain, !engine.hasText() { + feedEngine(caret: -1) + } } private func textAlignment(for alignment: Alignment) -> NSTextAlignment { @@ -1346,14 +1421,9 @@ final class NitroInputView: UIView { reportIntrinsicSize() } updateDisplayLinkNeed() - // One Core Animation commit per frame: render() and updateCaret() each - // open a transaction, and inside a display-link callback the outermost - // explicit transaction commits to the render server immediately. - CATransaction.begin() - CATransaction.setDisableActions(true) + // `render()` places the caret along with the glyphs; whether it shows is + // decided by the focus and selection callbacks, never by a frame. render() - updateCaret() - CATransaction.commit() } private func updateDisplayLinkNeed() { @@ -1417,12 +1487,7 @@ final class NitroInputView: UIView { base = UIFont.systemFont(ofSize: size, weight: uiWeight) } // Tabular digits: a column keeps its width while its digit morphs. - let feature: [UIFontDescriptor.FeatureKey: Int] - if #available(iOS 15.0, *) { - feature = [.type: kNumberSpacingType, .selector: kMonospacedNumbersSelector] - } else { - feature = [.featureIdentifier: kNumberSpacingType, .typeIdentifier: kMonospacedNumbersSelector] - } + let feature: [UIFontDescriptor.FeatureKey: Int] = [.type: kNumberSpacingType, .selector: kMonospacedNumbersSelector] let descriptor = base.fontDescriptor.addingAttributes([.featureSettings: [feature]]) return UIFont(descriptor: descriptor, size: size) } @@ -1504,8 +1569,8 @@ final class NitroInputView: UIView { // MARK: - Rendering private func clearGlyphLayers() { - for layer in glyphLayers.values { - layer.removeFromSuperlayer() + for entry in glyphLayers.values { + entry.layer.removeFromSuperlayer() } glyphLayers = [:] } @@ -1605,18 +1670,24 @@ final class NitroInputView: UIView { let fade = band / max(lineHeight + 2 * band, 1) edgeMask.locations = [0, NSNumber(value: Double(fade)), NSNumber(value: Double(1 - fade)), 1] - var seen = Set() + // A frame allocates nothing once every glyph has its layer: each entry is + // stamped with this pass's generation rather than collected into a set, + // and only a pass that has more layers than the engine has glyphs goes + // looking for the ones that left. + renderGeneration &+= 1 + let generation = renderGeneration let count = Int(engine.glyphCount()) - seen.reserveCapacity(count) for i in 0.. Bool { + // The return key arrives as a lone line break. Only that one is the key: + // a pasted line break is text, and goes in whatever the submit behaviour. + guard text == "\n", (textView as? HiddenTextView)?.isPasting != true else { return true } + // `newline` is the one behaviour that inserts it. + return !handleReturnKey() + } } // MARK: - UITextFieldDelegate @@ -2087,19 +2179,30 @@ extension NitroInputView: UITextFieldDelegate { } func textFieldShouldReturn(_ textField: UITextField) -> Bool { + handleReturnKey() + return false + } + + /// The return key, as React Native's `TextInput` takes it on iOS: + /// `blurAndSubmit` submits and dismisses the keyboard, `submit` submits and + /// keeps focus so a form can move on itself, and `newline` does neither (a + /// wrapping field inserts the line break instead; a single line does + /// nothing). Returns whether the press was taken as a submit. + @discardableResult + private func handleReturnKey() -> Bool { + let submits = traits.submitBehavior != .newline if worklets.onKeyPress != 0 { margelo.nitro.nitroinput.nitroinputworklets.runKeyPress(Int32(worklets.onKeyPress), std.string("Enter")) } - if worklets.onSubmitEditing != 0 { + if submits, worklets.onSubmitEditing != 0 { margelo.nitro.nitroinput.nitroinputworklets.runSubmitEditing(Int32(worklets.onSubmitEditing), std.string(text)) } onKeyPress?("Enter") - onSubmit?(text) - // `submitBehavior: 'submit'` keeps focus so a form can move on itself. - if traits.blurOnSubmit { - textField.resignFirstResponder() + if submits { onSubmit?(text) } + if traits.submitBehavior == .blurAndSubmit { + editor.resignFirstResponder() } - return false + return submits } /// Reports the caret/selection in code points, and only when it moved. @@ -2176,17 +2279,24 @@ extension NitroInputView { return max(9, restingLabelFont.pointSize * Self.floatedLabelRatio) } + // The frame is drawn in `CGColor`s, which have no appearance of their own: + // each is resolved against the view's traits here, and an appearance change + // lays the frame out again (`syncWithTraits`). private var resolvedOutlineColor: UIColor { let base = inputFrame.strokeColor ?? UIColor.separator - guard editor.isFirstResponder else { return base } - return inputFrame.focusedStrokeColor ?? base + let color = editor.isFirstResponder ? (inputFrame.focusedStrokeColor ?? base) : base + return color.resolvedColor(with: traitCollection) } private var resolvedLabelColor: UIColor { - if editor.isFirstResponder { - return inputFrame.labelFocusedColor ?? inputFrame.focusedStrokeColor ?? typography.placeholderColor - } - return inputFrame.labelColor ?? typography.placeholderColor + let color = editor.isFirstResponder + ? inputFrame.labelFocusedColor ?? inputFrame.focusedStrokeColor ?? typography.placeholderColor + : inputFrame.labelColor ?? typography.placeholderColor + return color.resolvedColor(with: traitCollection) + } + + private var resolvedFillColor: UIColor { + (inputFrame.fillColor ?? UIColor.secondarySystemFill).resolvedColor(with: traitCollection) } /// Applies everything that does not depend on the bounds, then lays out. @@ -2339,7 +2449,7 @@ extension NitroInputView { path: path, lineWidth: strokeWidthNow, strokeColor: filled ? nil : resolvedOutlineColor.cgColor, - fillColor: filled ? (inputFrame.fillColor ?? UIColor.secondarySystemFill).cgColor : nil, + fillColor: filled ? resolvedFillColor.cgColor : nil, animated: animating, opening: target == 1, ) diff --git a/packages/react-native-nitro-input/jest.config.js b/packages/react-native-nitro-input/jest.config.js index b5961860..5be7c432 100644 --- a/packages/react-native-nitro-input/jest.config.js +++ b/packages/react-native-nitro-input/jest.config.js @@ -1,6 +1,6 @@ module.exports = { preset: '@react-native/jest-preset', - testMatch: ['/src/**/__tests__/**/*.test.tsx'], + testMatch: ['/src/**/__tests__/**/*.test.{ts,tsx}'], moduleNameMapper: { '^react-native-nitro-modules$': '/src/__tests__/__mocks__/react-native-nitro-modules.ts', }, diff --git a/packages/react-native-nitro-input/nitrogen/generated/android/c++/JHybridNitroInputViewSpec.cpp b/packages/react-native-nitro-input/nitrogen/generated/android/c++/JHybridNitroInputViewSpec.cpp index 4d428f70..1e5cdff8 100644 --- a/packages/react-native-nitro-input/nitrogen/generated/android/c++/JHybridNitroInputViewSpec.cpp +++ b/packages/react-native-nitro-input/nitrogen/generated/android/c++/JHybridNitroInputViewSpec.cpp @@ -15,10 +15,10 @@ namespace margelo::nitro::nitroinput { struct NitroInputNotation; } namespace margelo::nitro::nitroinput { enum class NitroInputVariant; } // Forward declaration of `NitroInputLabelBehavior` to properly resolve imports. namespace margelo::nitro::nitroinput { enum class NitroInputLabelBehavior; } -// Forward declaration of `NitroInputAffixAlign` to properly resolve imports. -namespace margelo::nitro::nitroinput { enum class NitroInputAffixAlign; } // Forward declaration of `NitroInputSignPlacement` to properly resolve imports. namespace margelo::nitro::nitroinput { enum class NitroInputSignPlacement; } +// Forward declaration of `NitroInputAffixAlign` to properly resolve imports. +namespace margelo::nitro::nitroinput { enum class NitroInputAffixAlign; } // Forward declaration of `NitroInputEasing` to properly resolve imports. namespace margelo::nitro::nitroinput { enum class NitroInputEasing; } // Forward declaration of `NitroInputEffect` to properly resolve imports. @@ -48,10 +48,10 @@ namespace margelo::nitro::nitroinput { enum class NitroInputKeyboardAppearance; #include "JNitroInputVariant.hpp" #include "NitroInputLabelBehavior.hpp" #include "JNitroInputLabelBehavior.hpp" -#include "NitroInputAffixAlign.hpp" -#include "JNitroInputAffixAlign.hpp" #include "NitroInputSignPlacement.hpp" #include "JNitroInputSignPlacement.hpp" +#include "NitroInputAffixAlign.hpp" +#include "JNitroInputAffixAlign.hpp" #include "NitroInputEasing.hpp" #include "JNitroInputEasing.hpp" #include "NitroInputEffect.hpp" @@ -371,15 +371,6 @@ namespace margelo::nitro::nitroinput { static const auto method = _javaPart->javaClassStatic()->getMethod("setSuffixFontSize"); method(_javaPart, suffixFontSize); } - NitroInputAffixAlign JHybridNitroInputViewSpec::getAffixAlign() { - static const auto method = _javaPart->javaClassStatic()->getMethod()>("getAffixAlign"); - auto __result = method(_javaPart); - return __result->toCpp(); - } - void JHybridNitroInputViewSpec::setAffixAlign(NitroInputAffixAlign affixAlign) { - static const auto method = _javaPart->javaClassStatic()->getMethod /* affixAlign */)>("setAffixAlign"); - method(_javaPart, JNitroInputAffixAlign::fromCpp(affixAlign)); - } NitroInputSignPlacement JHybridNitroInputViewSpec::getSignPlacement() { static const auto method = _javaPart->javaClassStatic()->getMethod()>("getSignPlacement"); auto __result = method(_javaPart); diff --git a/packages/react-native-nitro-input/nitrogen/generated/android/c++/JHybridNitroInputViewSpec.hpp b/packages/react-native-nitro-input/nitrogen/generated/android/c++/JHybridNitroInputViewSpec.hpp index 1e21707f..39e4102b 100644 --- a/packages/react-native-nitro-input/nitrogen/generated/android/c++/JHybridNitroInputViewSpec.hpp +++ b/packages/react-native-nitro-input/nitrogen/generated/android/c++/JHybridNitroInputViewSpec.hpp @@ -104,8 +104,6 @@ namespace margelo::nitro::nitroinput { void setPrefixFontSize(double prefixFontSize) override; double getSuffixFontSize() override; void setSuffixFontSize(double suffixFontSize) override; - NitroInputAffixAlign getAffixAlign() override; - void setAffixAlign(NitroInputAffixAlign affixAlign) override; NitroInputSignPlacement getSignPlacement() override; void setSignPlacement(NitroInputSignPlacement signPlacement) override; NitroInputAffixAlign getPrefixAlign() override; diff --git a/packages/react-native-nitro-input/nitrogen/generated/android/c++/JNitroInputSubmitBehavior.hpp b/packages/react-native-nitro-input/nitrogen/generated/android/c++/JNitroInputSubmitBehavior.hpp index 30a9fdcb..b7f75730 100644 --- a/packages/react-native-nitro-input/nitrogen/generated/android/c++/JNitroInputSubmitBehavior.hpp +++ b/packages/react-native-nitro-input/nitrogen/generated/android/c++/JNitroInputSubmitBehavior.hpp @@ -48,6 +48,9 @@ namespace margelo::nitro::nitroinput { case NitroInputSubmitBehavior::BLURANDSUBMIT: static const auto fieldBLURANDSUBMIT = clazz->getStaticField("BLURANDSUBMIT"); return clazz->getStaticFieldValue(fieldBLURANDSUBMIT); + case NitroInputSubmitBehavior::NEWLINE: + static const auto fieldNEWLINE = clazz->getStaticField("NEWLINE"); + return clazz->getStaticFieldValue(fieldNEWLINE); default: std::string stringValue = std::to_string(static_cast(value)); throw std::invalid_argument("Invalid enum value (" + stringValue + "!"); diff --git a/packages/react-native-nitro-input/nitrogen/generated/android/c++/views/JHybridNitroInputViewStateUpdater.cpp b/packages/react-native-nitro-input/nitrogen/generated/android/c++/views/JHybridNitroInputViewStateUpdater.cpp index 8df54b5a..e812564f 100644 --- a/packages/react-native-nitro-input/nitrogen/generated/android/c++/views/JHybridNitroInputViewStateUpdater.cpp +++ b/packages/react-native-nitro-input/nitrogen/generated/android/c++/views/JHybridNitroInputViewStateUpdater.cpp @@ -188,11 +188,6 @@ void JHybridNitroInputViewStateUpdater::updateViewProps(jni::alias_refsuffixFontSize.hasSameValue(oldProps->suffixFontSize)) { hybridView->setSuffixFontSize(newProps->suffixFontSize.get()); } - if (oldProps == nullptr - ? newProps->affixAlign.isProvided() - : !newProps->affixAlign.hasSameValue(oldProps->affixAlign)) { - hybridView->setAffixAlign(newProps->affixAlign.get()); - } if (oldProps == nullptr ? newProps->signPlacement.isProvided() : !newProps->signPlacement.hasSameValue(oldProps->signPlacement)) { diff --git a/packages/react-native-nitro-input/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroinput/HybridNitroInputViewSpec.kt b/packages/react-native-nitro-input/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroinput/HybridNitroInputViewSpec.kt index a1ffab5b..5119a4e8 100644 --- a/packages/react-native-nitro-input/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroinput/HybridNitroInputViewSpec.kt +++ b/packages/react-native-nitro-input/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroinput/HybridNitroInputViewSpec.kt @@ -189,12 +189,6 @@ abstract class HybridNitroInputViewSpec: HybridView() { @set:Keep abstract var suffixFontSize: Double - @get:DoNotStrip - @get:Keep - @set:DoNotStrip - @set:Keep - abstract var affixAlign: NitroInputAffixAlign - @get:DoNotStrip @get:Keep @set:DoNotStrip diff --git a/packages/react-native-nitro-input/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroinput/NitroInputSubmitBehavior.kt b/packages/react-native-nitro-input/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroinput/NitroInputSubmitBehavior.kt index 36293ac9..13f4234e 100644 --- a/packages/react-native-nitro-input/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroinput/NitroInputSubmitBehavior.kt +++ b/packages/react-native-nitro-input/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroinput/NitroInputSubmitBehavior.kt @@ -17,7 +17,8 @@ import com.facebook.proguard.annotations.DoNotStrip @Keep enum class NitroInputSubmitBehavior(@DoNotStrip @Keep val value: Int) { SUBMIT(0), - BLURANDSUBMIT(1); + BLURANDSUBMIT(1), + NEWLINE(2); companion object } diff --git a/packages/react-native-nitro-input/nitrogen/generated/ios/c++/HybridNitroInputViewSpecSwift.hpp b/packages/react-native-nitro-input/nitrogen/generated/ios/c++/HybridNitroInputViewSpecSwift.hpp index 01635362..79d28343 100644 --- a/packages/react-native-nitro-input/nitrogen/generated/ios/c++/HybridNitroInputViewSpecSwift.hpp +++ b/packages/react-native-nitro-input/nitrogen/generated/ios/c++/HybridNitroInputViewSpecSwift.hpp @@ -20,10 +20,10 @@ namespace margelo::nitro::nitroinput { struct NitroInputNotation; } namespace margelo::nitro::nitroinput { enum class NitroInputVariant; } // Forward declaration of `NitroInputLabelBehavior` to properly resolve imports. namespace margelo::nitro::nitroinput { enum class NitroInputLabelBehavior; } -// Forward declaration of `NitroInputAffixAlign` to properly resolve imports. -namespace margelo::nitro::nitroinput { enum class NitroInputAffixAlign; } // Forward declaration of `NitroInputSignPlacement` to properly resolve imports. namespace margelo::nitro::nitroinput { enum class NitroInputSignPlacement; } +// Forward declaration of `NitroInputAffixAlign` to properly resolve imports. +namespace margelo::nitro::nitroinput { enum class NitroInputAffixAlign; } // Forward declaration of `NitroInputEasing` to properly resolve imports. namespace margelo::nitro::nitroinput { enum class NitroInputEasing; } // Forward declaration of `NitroInputEffect` to properly resolve imports. @@ -49,8 +49,8 @@ namespace margelo::nitro::nitroinput { enum class NitroInputKeyboardAppearance; #include #include "NitroInputVariant.hpp" #include "NitroInputLabelBehavior.hpp" -#include "NitroInputAffixAlign.hpp" #include "NitroInputSignPlacement.hpp" +#include "NitroInputAffixAlign.hpp" #include "NitroInputEasing.hpp" #include "NitroInputEffect.hpp" #include "NitroInputTextAlign.hpp" @@ -282,13 +282,6 @@ namespace margelo::nitro::nitroinput { inline void setSuffixFontSize(double suffixFontSize) noexcept override { _swiftPart.setSuffixFontSize(std::forward(suffixFontSize)); } - inline NitroInputAffixAlign getAffixAlign() noexcept override { - auto __result = _swiftPart.getAffixAlign(); - return static_cast(__result); - } - inline void setAffixAlign(NitroInputAffixAlign affixAlign) noexcept override { - _swiftPart.setAffixAlign(static_cast(affixAlign)); - } inline NitroInputSignPlacement getSignPlacement() noexcept override { auto __result = _swiftPart.getSignPlacement(); return static_cast(__result); diff --git a/packages/react-native-nitro-input/nitrogen/generated/ios/c++/views/HybridNitroInputViewComponent.mm b/packages/react-native-nitro-input/nitrogen/generated/ios/c++/views/HybridNitroInputViewComponent.mm index dd6e460c..addecaee 100644 --- a/packages/react-native-nitro-input/nitrogen/generated/ios/c++/views/HybridNitroInputViewComponent.mm +++ b/packages/react-native-nitro-input/nitrogen/generated/ios/c++/views/HybridNitroInputViewComponent.mm @@ -260,12 +260,6 @@ - (void) updateProps:(const std::shared_ptr&)props : !newViewProps.suffixFontSize.hasSameValue(oldViewProps->suffixFontSize)) { swiftPart.setSuffixFontSize(newViewProps.suffixFontSize.get()); } - // affixAlign: enum - if (oldViewProps == nullptr - ? newViewProps.affixAlign.isProvided() - : !newViewProps.affixAlign.hasSameValue(oldViewProps->affixAlign)) { - swiftPart.setAffixAlign(static_cast(newViewProps.affixAlign.get())); - } // signPlacement: enum if (oldViewProps == nullptr ? newViewProps.signPlacement.isProvided() diff --git a/packages/react-native-nitro-input/nitrogen/generated/ios/swift/HybridNitroInputViewSpec.swift b/packages/react-native-nitro-input/nitrogen/generated/ios/swift/HybridNitroInputViewSpec.swift index 67b471ea..3e19b216 100644 --- a/packages/react-native-nitro-input/nitrogen/generated/ios/swift/HybridNitroInputViewSpec.swift +++ b/packages/react-native-nitro-input/nitrogen/generated/ios/swift/HybridNitroInputViewSpec.swift @@ -37,7 +37,6 @@ public protocol HybridNitroInputViewSpec_protocol: HybridObject, HybridView { var suffix: String { get set } var prefixFontSize: Double { get set } var suffixFontSize: Double { get set } - var affixAlign: NitroInputAffixAlign { get set } var signPlacement: NitroInputSignPlacement { get set } var prefixAlign: NitroInputAffixAlign { get set } var suffixAlign: NitroInputAffixAlign { get set } diff --git a/packages/react-native-nitro-input/nitrogen/generated/ios/swift/HybridNitroInputViewSpec_cxx.swift b/packages/react-native-nitro-input/nitrogen/generated/ios/swift/HybridNitroInputViewSpec_cxx.swift index fbb891e8..d175ef52 100644 --- a/packages/react-native-nitro-input/nitrogen/generated/ios/swift/HybridNitroInputViewSpec_cxx.swift +++ b/packages/react-native-nitro-input/nitrogen/generated/ios/swift/HybridNitroInputViewSpec_cxx.swift @@ -424,17 +424,6 @@ open class HybridNitroInputViewSpec_cxx { } } - public final var affixAlign: Int32 { - @inline(__always) - get { - return self.__implementation.affixAlign.rawValue - } - @inline(__always) - set { - self.__implementation.affixAlign = margelo.nitro.nitroinput.NitroInputAffixAlign(rawValue: newValue)! - } - } - public final var signPlacement: Int32 { @inline(__always) get { diff --git a/packages/react-native-nitro-input/nitrogen/generated/ios/swift/NitroInputSubmitBehavior.swift b/packages/react-native-nitro-input/nitrogen/generated/ios/swift/NitroInputSubmitBehavior.swift index 090baea1..ee9ce475 100644 --- a/packages/react-native-nitro-input/nitrogen/generated/ios/swift/NitroInputSubmitBehavior.swift +++ b/packages/react-native-nitro-input/nitrogen/generated/ios/swift/NitroInputSubmitBehavior.swift @@ -21,6 +21,8 @@ public extension NitroInputSubmitBehavior { self = .submit case "blurAndSubmit": self = .blurandsubmit + case "newline": + self = .newline default: return nil } @@ -35,6 +37,8 @@ public extension NitroInputSubmitBehavior { return "submit" case .blurandsubmit: return "blurAndSubmit" + case .newline: + return "newline" } } } diff --git a/packages/react-native-nitro-input/nitrogen/generated/shared/c++/HybridNitroInputViewSpec.cpp b/packages/react-native-nitro-input/nitrogen/generated/shared/c++/HybridNitroInputViewSpec.cpp index 42197e7e..8df6f220 100644 --- a/packages/react-native-nitro-input/nitrogen/generated/shared/c++/HybridNitroInputViewSpec.cpp +++ b/packages/react-native-nitro-input/nitrogen/generated/shared/c++/HybridNitroInputViewSpec.cpp @@ -68,8 +68,6 @@ namespace margelo::nitro::nitroinput { prototype.registerHybridSetter("prefixFontSize", &HybridNitroInputViewSpec::setPrefixFontSize); prototype.registerHybridGetter("suffixFontSize", &HybridNitroInputViewSpec::getSuffixFontSize); prototype.registerHybridSetter("suffixFontSize", &HybridNitroInputViewSpec::setSuffixFontSize); - prototype.registerHybridGetter("affixAlign", &HybridNitroInputViewSpec::getAffixAlign); - prototype.registerHybridSetter("affixAlign", &HybridNitroInputViewSpec::setAffixAlign); prototype.registerHybridGetter("signPlacement", &HybridNitroInputViewSpec::getSignPlacement); prototype.registerHybridSetter("signPlacement", &HybridNitroInputViewSpec::setSignPlacement); prototype.registerHybridGetter("prefixAlign", &HybridNitroInputViewSpec::getPrefixAlign); diff --git a/packages/react-native-nitro-input/nitrogen/generated/shared/c++/HybridNitroInputViewSpec.hpp b/packages/react-native-nitro-input/nitrogen/generated/shared/c++/HybridNitroInputViewSpec.hpp index 158f1232..d47cb5e4 100644 --- a/packages/react-native-nitro-input/nitrogen/generated/shared/c++/HybridNitroInputViewSpec.hpp +++ b/packages/react-native-nitro-input/nitrogen/generated/shared/c++/HybridNitroInputViewSpec.hpp @@ -21,10 +21,10 @@ namespace margelo::nitro::nitroinput { struct NitroInputNotation; } namespace margelo::nitro::nitroinput { enum class NitroInputVariant; } // Forward declaration of `NitroInputLabelBehavior` to properly resolve imports. namespace margelo::nitro::nitroinput { enum class NitroInputLabelBehavior; } -// Forward declaration of `NitroInputAffixAlign` to properly resolve imports. -namespace margelo::nitro::nitroinput { enum class NitroInputAffixAlign; } // Forward declaration of `NitroInputSignPlacement` to properly resolve imports. namespace margelo::nitro::nitroinput { enum class NitroInputSignPlacement; } +// Forward declaration of `NitroInputAffixAlign` to properly resolve imports. +namespace margelo::nitro::nitroinput { enum class NitroInputAffixAlign; } // Forward declaration of `NitroInputEasing` to properly resolve imports. namespace margelo::nitro::nitroinput { enum class NitroInputEasing; } // Forward declaration of `NitroInputEffect` to properly resolve imports. @@ -50,8 +50,8 @@ namespace margelo::nitro::nitroinput { enum class NitroInputKeyboardAppearance; #include #include "NitroInputVariant.hpp" #include "NitroInputLabelBehavior.hpp" -#include "NitroInputAffixAlign.hpp" #include "NitroInputSignPlacement.hpp" +#include "NitroInputAffixAlign.hpp" #include "NitroInputEasing.hpp" #include "NitroInputEffect.hpp" #include "NitroInputTextAlign.hpp" @@ -145,8 +145,6 @@ namespace margelo::nitro::nitroinput { virtual void setPrefixFontSize(double prefixFontSize) = 0; virtual double getSuffixFontSize() = 0; virtual void setSuffixFontSize(double suffixFontSize) = 0; - virtual NitroInputAffixAlign getAffixAlign() = 0; - virtual void setAffixAlign(NitroInputAffixAlign affixAlign) = 0; virtual NitroInputSignPlacement getSignPlacement() = 0; virtual void setSignPlacement(NitroInputSignPlacement signPlacement) = 0; virtual NitroInputAffixAlign getPrefixAlign() = 0; diff --git a/packages/react-native-nitro-input/nitrogen/generated/shared/c++/NitroInputSubmitBehavior.hpp b/packages/react-native-nitro-input/nitrogen/generated/shared/c++/NitroInputSubmitBehavior.hpp index 0fae1f40..8ac20b74 100644 --- a/packages/react-native-nitro-input/nitrogen/generated/shared/c++/NitroInputSubmitBehavior.hpp +++ b/packages/react-native-nitro-input/nitrogen/generated/shared/c++/NitroInputSubmitBehavior.hpp @@ -31,6 +31,7 @@ namespace margelo::nitro::nitroinput { enum class NitroInputSubmitBehavior { SUBMIT SWIFT_NAME(submit) = 0, BLURANDSUBMIT SWIFT_NAME(blurandsubmit) = 1, + NEWLINE SWIFT_NAME(newline) = 2, } CLOSED_ENUM; } // namespace margelo::nitro::nitroinput @@ -45,6 +46,7 @@ namespace margelo::nitro { switch (hashString(unionValue.c_str(), unionValue.size())) { case hashString("submit"): return margelo::nitro::nitroinput::NitroInputSubmitBehavior::SUBMIT; case hashString("blurAndSubmit"): return margelo::nitro::nitroinput::NitroInputSubmitBehavior::BLURANDSUBMIT; + case hashString("newline"): return margelo::nitro::nitroinput::NitroInputSubmitBehavior::NEWLINE; default: [[unlikely]] throw std::invalid_argument("Cannot convert \"" + unionValue + "\" to enum NitroInputSubmitBehavior - invalid value!"); } @@ -53,6 +55,7 @@ namespace margelo::nitro { switch (arg) { case margelo::nitro::nitroinput::NitroInputSubmitBehavior::SUBMIT: return JSIConverter::toJSI(runtime, "submit"); case margelo::nitro::nitroinput::NitroInputSubmitBehavior::BLURANDSUBMIT: return JSIConverter::toJSI(runtime, "blurAndSubmit"); + case margelo::nitro::nitroinput::NitroInputSubmitBehavior::NEWLINE: return JSIConverter::toJSI(runtime, "newline"); default: [[unlikely]] throw std::invalid_argument("Cannot convert NitroInputSubmitBehavior to JS - invalid value: " + std::to_string(static_cast(arg)) + "!"); @@ -66,6 +69,7 @@ namespace margelo::nitro { switch (hashString(unionValue.c_str(), unionValue.size())) { case hashString("submit"): case hashString("blurAndSubmit"): + case hashString("newline"): return true; default: return false; diff --git a/packages/react-native-nitro-input/nitrogen/generated/shared/c++/views/HybridNitroInputViewComponent.cpp b/packages/react-native-nitro-input/nitrogen/generated/shared/c++/views/HybridNitroInputViewComponent.cpp index 6678d315..b9c53292 100644 --- a/packages/react-native-nitro-input/nitrogen/generated/shared/c++/views/HybridNitroInputViewComponent.cpp +++ b/packages/react-native-nitro-input/nitrogen/generated/shared/c++/views/HybridNitroInputViewComponent.cpp @@ -47,7 +47,6 @@ namespace margelo::nitro::nitroinput::views { suffix(nitro::ReactProp::fromRawValue("NitroInputView", "suffix", rawProps, sourceProps.suffix)), prefixFontSize(nitro::ReactProp::fromRawValue("NitroInputView", "prefixFontSize", rawProps, sourceProps.prefixFontSize)), suffixFontSize(nitro::ReactProp::fromRawValue("NitroInputView", "suffixFontSize", rawProps, sourceProps.suffixFontSize)), - affixAlign(nitro::ReactProp::fromRawValue("NitroInputView", "affixAlign", rawProps, sourceProps.affixAlign)), signPlacement(nitro::ReactProp::fromRawValue("NitroInputView", "signPlacement", rawProps, sourceProps.signPlacement)), prefixAlign(nitro::ReactProp::fromRawValue("NitroInputView", "prefixAlign", rawProps, sourceProps.prefixAlign)), suffixAlign(nitro::ReactProp::fromRawValue("NitroInputView", "suffixAlign", rawProps, sourceProps.suffixAlign)), @@ -144,7 +143,6 @@ namespace margelo::nitro::nitroinput::views { case hashString("suffix"): return true; case hashString("prefixFontSize"): return true; case hashString("suffixFontSize"): return true; - case hashString("affixAlign"): return true; case hashString("signPlacement"): return true; case hashString("prefixAlign"): return true; case hashString("suffixAlign"): return true; diff --git a/packages/react-native-nitro-input/nitrogen/generated/shared/c++/views/HybridNitroInputViewComponent.hpp b/packages/react-native-nitro-input/nitrogen/generated/shared/c++/views/HybridNitroInputViewComponent.hpp index e2a2ad51..28ca5de3 100644 --- a/packages/react-native-nitro-input/nitrogen/generated/shared/c++/views/HybridNitroInputViewComponent.hpp +++ b/packages/react-native-nitro-input/nitrogen/generated/shared/c++/views/HybridNitroInputViewComponent.hpp @@ -23,8 +23,8 @@ #include #include "NitroInputVariant.hpp" #include "NitroInputLabelBehavior.hpp" -#include "NitroInputAffixAlign.hpp" #include "NitroInputSignPlacement.hpp" +#include "NitroInputAffixAlign.hpp" #include "NitroInputEasing.hpp" #include "NitroInputEffect.hpp" #include "NitroInputTextAlign.hpp" @@ -86,7 +86,6 @@ namespace margelo::nitro::nitroinput::views { nitro::ReactProp suffix; nitro::ReactProp prefixFontSize; nitro::ReactProp suffixFontSize; - nitro::ReactProp affixAlign; nitro::ReactProp signPlacement; nitro::ReactProp prefixAlign; nitro::ReactProp suffixAlign; @@ -183,7 +182,6 @@ namespace margelo::nitro::nitroinput::views { suffix.hasSameValue(other.suffix) && prefixFontSize.hasSameValue(other.prefixFontSize) && suffixFontSize.hasSameValue(other.suffixFontSize) && - affixAlign.hasSameValue(other.affixAlign) && signPlacement.hasSameValue(other.signPlacement) && prefixAlign.hasSameValue(other.prefixAlign) && suffixAlign.hasSameValue(other.suffixAlign) && @@ -281,7 +279,6 @@ namespace margelo::nitro::nitroinput::views { suffix.isProvided() || prefixFontSize.isProvided() || suffixFontSize.isProvided() || - affixAlign.isProvided() || signPlacement.isProvided() || prefixAlign.isProvided() || suffixAlign.isProvided() || diff --git a/packages/react-native-nitro-input/nitrogen/generated/shared/json/NitroInputViewConfig.json b/packages/react-native-nitro-input/nitrogen/generated/shared/json/NitroInputViewConfig.json index 229a3e30..499709be 100644 --- a/packages/react-native-nitro-input/nitrogen/generated/shared/json/NitroInputViewConfig.json +++ b/packages/react-native-nitro-input/nitrogen/generated/shared/json/NitroInputViewConfig.json @@ -31,7 +31,6 @@ "suffix": true, "prefixFontSize": true, "suffixFontSize": true, - "affixAlign": true, "signPlacement": true, "prefixAlign": true, "suffixAlign": true, diff --git a/packages/react-native-nitro-input/package.json b/packages/react-native-nitro-input/package.json index 708081ba..fb3ec440 100644 --- a/packages/react-native-nitro-input/package.json +++ b/packages/react-native-nitro-input/package.json @@ -2,9 +2,9 @@ "name": "react-native-nitro-input", "version": "0.0.0", "description": "Native text and amount input for React Native with Torph-style text morphing: characters slide, fade and reflow as you type, currency formatting applied natively, no JS round trip. Built with Nitro.", - "main": "lib/index.js", - "module": "lib/index.js", - "types": "lib/index.d.ts", + "main": "lib/commonjs/src/index.js", + "module": "lib/module/src/index.js", + "types": "lib/typescript/src/index.d.ts", "react-native": "src/index.ts", "source": "src/index.ts", "files": [ @@ -34,7 +34,7 @@ ], "scripts": { "typecheck": "tsc --noEmit", - "build": "rm -rf lib && tsc -p tsconfig.build.json", + "build": "rm -rf lib && tsc -p tsconfig.build.json && tsc -p tsconfig.build.cjs.json && echo '{\"type\":\"module\"}' > lib/module/package.json", "clean": "rm -rf android/build lib", "specs": "nitrogen --logLevel=\"debug\"", "prepack": "bun run build", @@ -70,7 +70,9 @@ "@react-native/babel-preset": "0.87.1", "@react-native/jest-preset": "0.87.1", "@types/jest": "^29.5.13", + "@types/node": "^26.6.1", "@types/react": "^19.2.0", + "@types/react-test-renderer": "^19.3.0", "jest": "^29.7.0", "nitrogen": "0.37.1", "react": "19.2.3", @@ -83,7 +85,13 @@ "peerDependencies": { "react": "*", "react-native": ">=0.78.0", - "react-native-nitro-modules": ">=0.37.0" + "react-native-nitro-modules": ">=0.37.0", + "react-native-worklets": "*" + }, + "peerDependenciesMeta": { + "react-native-worklets": { + "optional": true + } }, "prettier": { "quoteProps": "consistent", @@ -95,9 +103,11 @@ }, "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/typescript/src/index.d.ts", "react-native": "./src/index.ts", - "default": "./lib/index.js" + "import": "./lib/module/src/index.js", + "require": "./lib/commonjs/src/index.js", + "default": "./lib/commonjs/src/index.js" }, "./package.json": "./package.json" }, @@ -113,7 +123,7 @@ }, "github": { "release": true, - "releaseName": "morph-input v${version}" + "releaseName": "nitro-input v${version}" }, "hooks": { "before:init": [ diff --git a/packages/react-native-nitro-input/src/NitroInput.tsx b/packages/react-native-nitro-input/src/NitroInput.tsx index b96adc0a..7828f496 100644 --- a/packages/react-native-nitro-input/src/NitroInput.tsx +++ b/packages/react-native-nitro-input/src/NitroInput.tsx @@ -34,6 +34,7 @@ import type { NitroInputReturnKeyType, NitroInputSubmitBehavior, NitroInputTextAlign, + NitroInputTextAlignVertical, NitroInputSignPlacement, NitroInputVariant, NitroInputNotation, @@ -48,6 +49,7 @@ import { registerCallback, registerTransform, unregisterWorklet, + type NitroInputSelection, type NitroInputTransform, } from './worklets' @@ -95,7 +97,6 @@ const textInputRegistry: (TextInputRegistry & Partial) | null } })() -/** Mounted fields, by the host instance React Native's registry stores. */ /** React Native's `inputMode`, the HTML-aligned alias for `keyboardType`. */ export type InputMode = | 'none' @@ -150,17 +151,25 @@ function reactTagOf(host: unknown): number { const EMPTY_NOTATIONS: NitroInputNotation[] = [] +/** Mounted fields, by the host instance React Native's registry stores. */ const mountedFields = new WeakMap() let patchedRegistry = false /** - * `TextInput.State.focusTextInput` / `blurTextInput` — and therefore + * `TextInputState.focusTextInput` / `blurTextInput` — which is how * `Keyboard.dismiss()`, `keyboardShouldPersistTaps` and a ScrollView's - * auto-blur — reach a text input by dispatching a codegen `focus` / `blur` + * auto-blur reach the focused input — dispatch a codegen `focus` / `blur` * *view command*. A Nitro view has no such command on Android, so the call - * would be dropped and the keyboard would stay up. These two functions are - * wrapped once to route a NitroInput to its own native focus/blur; every other - * input is passed straight through untouched. + * would be dropped and the keyboard would stay up. The two functions are + * wrapped once, on the registry module, to route a NitroInput to its own + * native focus/blur; every other input is passed straight through untouched. + * + * The wrap reaches every caller that reads the two off the module at call + * time, which `Keyboard.dismiss()` and `ScrollView` do. It does not reach + * `TextInput.State.focusTextInput` / `blurTextInput`: `TextInput.js` copies + * those references when it is evaluated, before this runs, so a call through + * them still dispatches the view command. On iOS `ios/NitroInputCommands.mm` + * answers that command; on Android it is dropped. */ function patchRegistryOnce() { if (patchedRegistry || textInputRegistry == null) return @@ -219,11 +228,6 @@ export interface NitroInputFocusEvent extends NitroInputTextEvent { nativeEvent: { text: string; target: number; eventCount: number } } -export interface NitroInputSelection { - start: number - end: number -} - export interface NitroInputSelectionEvent { /** The selection, in code points. */ selection: NitroInputSelection @@ -252,11 +256,6 @@ export interface NitroInputProps extends Omit = { normal: 400, regular: 400, @@ -588,34 +605,39 @@ function toProcessedColor(color: ColorValue | undefined): number | undefined { const processed = processColor(color) return typeof processed === 'number' ? processed : undefined } +// shared-helpers:end interface Size { width: number height: number } -/** - * A native single-line input whose text morphs as you type: characters that - * stay glide to their new place, new ones slide or fade in, removed ones leave - * alongside their neighbours. In `'number'` mode the amount is formatted on - * the native side as it is typed (grouping, decimal, currency affixes), with - * the caret kept in place, so there is never an unformatted frame and never a - * round trip through JS. - * - * The view sizes itself to its content unless `style` gives it a width. - */ const warnedIncompatible = new Set() /** Once per offending prop, not once per render. */ function warnIncompatible(what: string): void { if (warnedIncompatible.has(what)) return warnedIncompatible.add(what) console.warn( - `[NitroInput] \`${what}\` is ignored on a multiline field: the glyph ` + - 'engine draws one run on one baseline, and amounts and masks are ' + - 'single-line. Drop one of the two.' + `[NitroInput] \`${what}\` is ignored on a multiline field: a wrapping ` + + 'field is drawn by the system view, and an amount, a mask and their ' + + 'affixes are single-line. Drop one of the two.' ) } +/** + * A native text input: a system field (`UITextField` / `EditText`) that owns + * the keyboard, editing, selection, paste and accessibility, with native + * formatting (`mode="number"`), native masking (`mode="mask"`), an outlined + * or filled frame with a floating label, and optionally the glyph engine + * (`morph`), under which characters that stay glide to their new place, new + * ones slide or fade in and removed ones leave alongside their neighbours. + * In `'number'` mode every edit is formatted on the native side before a + * frame is drawn, with the caret kept in place, so there is never an + * unformatted frame and never a round trip through JS. + * + * Like a `TextInput`, it takes its width from its parent; `autoWidth` + * (`'auto'` in {@link MorphInput}) sizes it to its content instead. + */ export const NitroInput = forwardRef( function NitroInput( { @@ -789,8 +811,14 @@ export const NitroInput = forwardRef( ) // The latest native event count JS has processed: sent back with `text` so // native can tell a stale controlled value (the user typed since) from a - // deliberate change. - const [eventCount, setEventCount] = useState(0) + // deliberate change. It lives in `eventCountRef` and is read from there at + // render; only a controlled field takes the state update that forces one, + // because only a controlled field's `text` prop can ever change. So an + // uncontrolled (or worklet-driven) field never re-renders on a keystroke, + // and a field made controlled later reads the fresh count on that render. + const [, setEventCountState] = useState(0) + const controlledRef = useRef(value != null) + controlledRef.current = value != null // The initial text of an uncontrolled field never changes afterwards, so // native applies it exactly once. const [initialText] = useState(() => defaultValue ?? '') @@ -826,9 +854,13 @@ export const NitroInput = forwardRef( // A field with a width of its own (the usual case) never needs a React // commit per keystroke: only the height (font-dependent) is taken from native. - const flat = StyleSheet.flatten(style) as - | { width?: unknown; flex?: unknown; height?: unknown; direction?: unknown } - | undefined + const flat = useMemo( + () => + StyleSheet.flatten(style) as + | { width?: unknown; flex?: unknown; height?: unknown; direction?: unknown } + | undefined, + [style] + ) // The layout direction, resolved the way React Native resolves it for its // own views: the field's `style.direction` if it says, else the app's. // Fabric does not hand a Hybrid View its resolved direction, so native is @@ -869,9 +901,9 @@ export const NitroInput = forwardRef( const onChangeTextCallback = useMemo( () => callback((text: string, count: number) => { - setEventCount(count) textRef.current = text eventCountRef.current = count + if (controlledRef.current) setEventCountState(count) const handler = latest.current.onChangeText // A worklet handler already ran on the UI thread. if (handler && !isWorklet(handler)) handler(text) @@ -972,6 +1004,8 @@ export const NitroInput = forwardRef( [] ) + // One handle for the component's lifetime: everything it reads goes + // through a ref, so a value change never hands the parent a new object. useImperativeHandle( ref, () => ({ @@ -980,7 +1014,9 @@ export const NitroInput = forwardRef( clear: () => nativeRef.current?.clear(), setText: (text) => nativeRef.current?.replaceText(text), setValue: (next) => nativeRef.current?.setValue(next), - getText: () => nativeRef.current?.currentText() ?? value ?? initialText, + // Before the native view attaches, `textRef` is a controlled field's + // `value` and an uncontrolled one's initial text. + getText: () => nativeRef.current?.currentText() ?? textRef.current, getValue: () => nativeRef.current?.getValue() ?? NaN, // The native view is the truth once there is one - it knows whether it // actually holds first responder. Before it attaches, fall back to the @@ -996,7 +1032,7 @@ export const NitroInput = forwardRef( return nativeRef.current }, }), - [value, initialText] + [] ) // Every native prop is sent with an explicit value: an optional prop that @@ -1016,6 +1052,17 @@ export const NitroInput = forwardRef( () => toProcessedColor(selectionColor) ?? NaN, [selectionColor] ) + const processedLabelColor = useMemo(() => toProcessedColor(labelColor) ?? NaN, [labelColor]) + const processedLabelFocusedColor = useMemo( + () => toProcessedColor(labelFocusedColor) ?? NaN, + [labelFocusedColor] + ) + const processedStrokeColor = useMemo(() => toProcessedColor(strokeColor) ?? NaN, [strokeColor]) + const processedFocusedStrokeColor = useMemo( + () => toProcessedColor(focusedStrokeColor) ?? NaN, + [focusedStrokeColor] + ) + const processedFillColor = useMemo(() => toProcessedColor(fillColor) ?? NaN, [fillColor]) const numericWeight = toNumericWeight(fontWeight) ?? 400 const resolvedFontSize = fontSize ?? 32 const resolvedAffixAlign = affixAlign ?? 'baseline' @@ -1029,9 +1076,27 @@ export const NitroInput = forwardRef( warnIncompatible(`mode="${mode}"`) } if (morph) warnIncompatible('morph') + if (prefix) warnIncompatible('prefix') + if (suffix) warnIncompatible('suffix') } const resolvedMode = isMultiline ? 'text' : (mode ?? 'text') const resolvedMorph = isMultiline ? false : (morph ?? false) + // The affixes are accessory views beside one line of text; a wrapping text + // view has no slot for them, so they go with the mode rather than being + // half-drawn on one platform and not the other. + const resolvedPrefix = isMultiline ? '' : (prefix ?? '') + const resolvedSuffix = isMultiline ? '' : (suffix ?? '') + // A new `maskNotations` literal each render must not re-set the native + // prop, so it is keyed on its contents. Always an array (empty = none): + // removing the prop would reach native as `null`, which Nitro's array + // parser rejects. + const notationsKey = + maskNotations != null && maskNotations.length > 0 ? JSON.stringify(maskNotations) : '' + const stableNotations = useMemo( + () => (notationsKey !== '' && maskNotations ? [...maskNotations] : EMPTY_NOTATIONS), + // eslint-disable-next-line react-hooks/exhaustive-deps + [notationsKey] + ) const resolvedFractionDigits = fractionDigits ?? 2 const resolvedKeyboardType = keyboardType ?? @@ -1087,18 +1152,17 @@ export const NitroInput = forwardRef( style={[autoSize, style]} hybridRef={hybridRef} text={value ?? initialText} - mostRecentEventCount={eventCount} + mostRecentEventCount={eventCountRef.current} mode={resolvedMode} plain={!resolvedMorph} fractionDigits={resolvedFractionDigits} maxIntegerDigits={maxIntegerDigits ?? 15} groupingSeparator={groupingSeparator ?? ','} decimalSeparator={decimalSeparator ?? '.'} - prefix={prefix ?? ''} - suffix={suffix ?? ''} + prefix={resolvedPrefix} + suffix={resolvedSuffix} prefixFontSize={prefixFontSize ?? resolvedFontSize} suffixFontSize={suffixFontSize ?? resolvedFontSize} - affixAlign={resolvedAffixAlign} signPlacement={signPlacement ?? 'beforeAffix'} prefixAlign={prefixAlign ?? resolvedAffixAlign} suffixAlign={suffixAlign ?? resolvedAffixAlign} @@ -1125,16 +1189,16 @@ export const NitroInput = forwardRef( variant={variant ?? 'none'} label={label ?? ''} labelBehavior={labelBehavior ?? 'float'} - labelColor={toProcessedColor(labelColor) ?? NaN} - labelFocusedColor={toProcessedColor(labelFocusedColor) ?? NaN} + labelColor={processedLabelColor} + labelFocusedColor={processedLabelFocusedColor} labelFontSize={labelFontSize ?? 0} - strokeColor={toProcessedColor(strokeColor) ?? NaN} - focusedStrokeColor={toProcessedColor(focusedStrokeColor) ?? NaN} + strokeColor={processedStrokeColor} + focusedStrokeColor={processedFocusedStrokeColor} strokeWidth={strokeWidth ?? 1} cornerRadius={cornerRadius ?? 8} - fillColor={toProcessedColor(fillColor) ?? NaN} + fillColor={processedFillColor} mask={resolvedMode === 'mask' ? (mask ?? '') : ''} - maskNotations={maskNotations ?? EMPTY_NOTATIONS} + maskNotations={stableNotations} maskAutocomplete={maskAutocomplete ?? true} maskAutoSkip={maskAutoSkip ?? false} onChangeMask={onChangeMaskCallback} @@ -1150,7 +1214,18 @@ export const NitroInput = forwardRef( autoFocus={autoFocus ?? false} fieldTestID={viewProps.testID ?? ''} fieldAccessibilityLabel={resolvedAccessibilityLabel ?? ''} - submitBehavior={submitBehavior ?? (blurOnSubmit === false ? 'submit' : 'blurAndSubmit')} + submitBehavior={ + submitBehavior ?? + // As `TextInput` resolves the legacy `blurOnSubmit`: a multiline + // field's return key inserts a line break unless told to blur. + (multiline + ? blurOnSubmit === true + ? 'blurAndSubmit' + : 'newline' + : blurOnSubmit === false + ? 'submit' + : 'blurAndSubmit') + } secureTextEntry={secureTextEntry ?? false} keyboardAppearance={keyboardAppearance ?? 'default'} textContentType={textContentType ?? autoComplete ?? ''} @@ -1193,6 +1268,19 @@ export const NitroInput = forwardRef( * StrictMode, or a concurrent render that loses) would otherwise leave an entry * on the UI runtime that no cleanup ever removes. */ +function useWorkletId unknown>( + fn: T | undefined, + register: (fn: T, id: number) => void +): number { + const id = useMemo(() => (fn ? allocateWorkletId() : 0), [fn]) + useEffect(() => { + if (id === 0 || !fn) return + register(fn, id) + return () => unregisterWorklet(id) + }, [id, fn, register]) + return id +} + /** * The same, for the focus pair: native reports one focus change, so the two * handlers share an id and the wrapper picks between them. @@ -1210,16 +1298,3 @@ function useWorkletPairId unknown>( }, [id, onFocus, onBlur, register]) return id } - -function useWorkletId unknown>( - fn: T | undefined, - register: (fn: T, id: number) => void -): number { - const id = useMemo(() => (fn ? allocateWorkletId() : 0), [fn]) - useEffect(() => { - if (id === 0 || !fn) return - register(fn, id) - return () => unregisterWorklet(id) - }, [id, fn, register]) - return id -} diff --git a/packages/react-native-nitro-input/src/__tests__/MorphInput.test.tsx b/packages/react-native-nitro-input/src/__tests__/MorphInput.test.tsx index 30e729c7..cf119bb8 100644 --- a/packages/react-native-nitro-input/src/__tests__/MorphInput.test.tsx +++ b/packages/react-native-nitro-input/src/__tests__/MorphInput.test.tsx @@ -35,7 +35,8 @@ describe('MorphInput', () => { expect(props.effect).toBe('auto') expect(props.fontSize).toBe(32) expect(props.prefixFontSize).toBe(32) - expect(props.affixAlign).toBe('baseline') + expect(props.prefixAlign).toBe('baseline') + expect(props.suffixAlign).toBe('baseline') expect(props.prefixAlign).toBe('baseline') expect(Number.isNaN(props.color)).toBe(true) expect(Number.isNaN(props.placeholderColor)).toBe(true) diff --git a/packages/react-native-nitro-input/src/__tests__/NitroInput.test.tsx b/packages/react-native-nitro-input/src/__tests__/NitroInput.test.tsx new file mode 100644 index 00000000..8d2c0156 --- /dev/null +++ b/packages/react-native-nitro-input/src/__tests__/NitroInput.test.tsx @@ -0,0 +1,237 @@ +/** + * The wrapper's own resolution rules: what it sends native for the props it + * reshapes, and what it does not do per render. `MorphInput.test.tsx` covers + * the defaults and the callback plumbing; `TextInputParity.test.tsx` compares + * against React Native's `TextInput`. + */ +import React, { createRef } from 'react' +import { I18nManager } from 'react-native' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { NitroInput, type NitroInputHandle } from '../NitroInput' + +function render(element: React.ReactElement): ReactTestRenderer { + let renderer: ReactTestRenderer | undefined + act(() => { + renderer = create(element) + }) + return renderer! +} + +function nativeProps(renderer: ReactTestRenderer): Record { + return renderer.root.findByType('NitroInputView' as never).props as Record +} + +describe('right-to-left', () => { + it('follows the app direction by default', () => { + expect(nativeProps(render()).rightToLeft).toBe(false) + const replaced = jest.replaceProperty(I18nManager, 'isRTL', true) + expect(nativeProps(render()).rightToLeft).toBe(true) + replaced.restore() + expect(nativeProps(render()).rightToLeft).toBe(false) + }) + + it('lets style.direction override the app direction either way', () => { + // The way React Native resolves it for its own views: the view's own + // `direction` wins, and only an unset one falls back to `I18nManager`. + expect(nativeProps(render()).rightToLeft).toBe(true) + const replaced = jest.replaceProperty(I18nManager, 'isRTL', true) + expect(nativeProps(render()).rightToLeft).toBe(false) + expect(nativeProps(render()).rightToLeft).toBe(true) + // Through an array style too, since that is what `StyleSheet.flatten` is for. + expect( + nativeProps(render()).rightToLeft + ).toBe(false) + replaced.restore() + }) + + it('aligns to the start edge unless told otherwise', () => { + // `'auto'` is the start edge of the layout direction, as `TextInput` with + // no `textAlign`; `'left'` and `'right'` are absolute. + expect(nativeProps(render()).textAlign).toBe('auto') + expect(nativeProps(render()).textAlign).toBe('left') + expect(nativeProps(render()).textAlign).toBe('right') + expect(nativeProps(render()).textAlign).toBe('center') + }) +}) + +describe('multiline', () => { + it('forces text mode and drops the mask and the affixes, warning once each', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + const number = nativeProps(render()) + expect(number.multiline).toBe(true) + expect(number.mode).toBe('text') + expect(number.prefix).toBe('') + expect(number.suffix).toBe('') + expect(number.plain).toBe(true) + const masked = nativeProps(render()) + expect(masked.mode).toBe('text') + expect(masked.mask).toBe('') + for (const what of ['mode="number"', 'mode="mask"', 'prefix', 'suffix']) { + expect(warn).toHaveBeenCalledWith(expect.stringContaining(`\`${what}\` is ignored on a multiline field`)) + } + // Once per offending prop, not once per render or per field. + const before = warn.mock.calls.length + render() + expect(warn.mock.calls.length).toBe(before) + warn.mockRestore() + }) + + it('keeps the affixes on a single-line field', () => { + const props = nativeProps(render()) + expect(props.prefix).toBe('$') + expect(props.suffix).toBe(' USD') + }) + + it('takes numberOfLines or its TextInput spelling, rows', () => { + expect(nativeProps(render()).numberOfLines).toBe(0) + expect(nativeProps(render()).numberOfLines).toBe(4) + expect(nativeProps(render()).numberOfLines).toBe(3) + expect(nativeProps(render()).numberOfLines).toBe(4) + expect(nativeProps(render()).textAlignVertical).toBe('bottom') + }) +}) + +describe('submitBehavior', () => { + it('defaults like TextInput: blurAndSubmit on one line, newline on many', () => { + expect(nativeProps(render()).submitBehavior).toBe('blurAndSubmit') + expect(nativeProps(render()).submitBehavior).toBe('newline') + }) + + it('resolves the legacy blurOnSubmit the way TextInput does', () => { + expect(nativeProps(render()).submitBehavior).toBe('submit') + expect(nativeProps(render()).submitBehavior).toBe('blurAndSubmit') + expect(nativeProps(render()).submitBehavior).toBe('blurAndSubmit') + expect(nativeProps(render()).submitBehavior).toBe('newline') + }) + + it('lets an explicit submitBehavior win, including newline', () => { + expect(nativeProps(render()).submitBehavior).toBe( + 'submit' + ) + expect(nativeProps(render()).submitBehavior).toBe( + 'newline' + ) + }) +}) + +describe('autoWidth', () => { + const measured = (renderer: ReactTestRenderer) => { + const onSizeChange = nativeProps(renderer).onSizeChange as { f: (w: number, h: number) => void } + act(() => onSizeChange.f(120, 48)) + return (nativeProps(renderer).style as unknown[])[0] + } + + it('is off by default, so the field takes its width from its parent', () => { + expect(measured(render())).toEqual({ height: 48 }) + }) + + it("'auto' infers content sizing unless the style gives a width or flex", () => { + expect(measured(render())).toEqual({ width: 120, height: 48 }) + expect(measured(render())).toEqual({ height: 48 }) + expect(measured(render())).toEqual({ height: 48 }) + expect(measured(render())).toEqual({ + height: 48, + }) + }) + + it('true always sizes to content', () => { + expect(measured(render())).toEqual({ width: 120, height: 48 }) + }) +}) + +describe('per-render stability', () => { + it('keeps maskNotations by contents, so a fresh but equal literal does not re-set the prop', () => { + const notation = (character: string) => ({ character, characterSet: '0123456789abcdef', isOptional: false }) + const renderer = render() + const first = nativeProps(renderer).maskNotations + expect(first).toEqual([notation('H')]) + act(() => { + renderer.update() + }) + expect(nativeProps(renderer).maskNotations).toBe(first) + // A real change does go through. + act(() => { + renderer.update() + }) + expect(nativeProps(renderer).maskNotations).toEqual([notation('H'), notation('X')]) + // And removing them sends an (always the same) empty array, never `null`. + act(() => { + renderer.update() + }) + const empty = nativeProps(renderer).maskNotations + expect(empty).toEqual([]) + act(() => { + renderer.update() + }) + expect(nativeProps(renderer).maskNotations).toBe(empty) + }) + + it('hands the parent one handle for the field’s lifetime', () => { + const ref = createRef() + const renderer = render( {}} />) + const handle = ref.current + expect(handle).not.toBeNull() + expect(handle!.getText()).toBe('one') + act(() => { + renderer.update( {}} />) + }) + expect(ref.current).toBe(handle) + // ...and it still reads the latest value before the native view attaches. + expect(handle!.getText()).toBe('two') + }) + + it('an uncontrolled handle reads the initial text, then what native reported', () => { + const ref = createRef() + const renderer = render() + expect(ref.current!.getText()).toBe('seed') + const onChangeText = nativeProps(renderer).onChangeText as { f: (t: string, c: number) => void } + act(() => onChangeText.f('seeded', 1)) + expect(ref.current!.getText()).toBe('seeded') + }) +}) + +describe('colours', () => { + const FRAME_COLOURS = [ + 'labelColor', + 'labelFocusedColor', + 'strokeColor', + 'focusedStrokeColor', + 'fillColor', + 'caretColor', + 'selectionColor', + ] as const + + it('converts the label, frame, caret and selection colours to ARGB integers', () => { + const props = nativeProps( + render( + + ) + ) + expect(props.labelColor).toBe(0xff112233) + expect(props.labelFocusedColor).toBe(0xff0000ff) + expect(props.strokeColor).toBe(0xff000000) + expect(props.focusedStrokeColor).toBe(0x80ff0000) + expect(props.fillColor).toBe(0xffffffff) + expect(props.caretColor).toBe(0xff00ff00) + expect(props.selectionColor).toBe(0xffff0000) + }) + + it('sends NaN for an unset colour, which native reads as the platform default', () => { + const props = nativeProps(render()) + for (const key of FRAME_COLOURS) expect(Number.isNaN(props[key])).toBe(true) + }) + + it('sends NaN for a colour it cannot process, rather than undefined', () => { + const props = nativeProps(render()) + expect(Number.isNaN(props.strokeColor)).toBe(true) + expect(Number.isNaN(props.fillColor)).toBe(true) + }) +}) diff --git a/packages/react-native-nitro-input/src/__tests__/ReadmeProps.test.ts b/packages/react-native-nitro-input/src/__tests__/ReadmeProps.test.ts new file mode 100644 index 00000000..4e138acf --- /dev/null +++ b/packages/react-native-nitro-input/src/__tests__/ReadmeProps.test.ts @@ -0,0 +1,51 @@ +/** + * The README's props table is derived from `NitroInputProps`, not maintained + * beside it: every prop the interface declares has to appear in the table, so + * a prop added to the component fails here rather than going undocumented. + */ +import { readFileSync } from 'fs' +import { join } from 'path' + +/** Every prop name declared in an interface block of a `.tsx`. */ +function propsOfInterface(source: string, name: string): string[] { + const match = new RegExp(`^export interface ${name}[^{]*\\{(.*?)^\\}`, 'sm').exec(source) + if (!match) return [] + return [...new Set([...match[1]!.matchAll(/^ {2}([a-zA-Z][a-zA-Z0-9]*)\??:/gm)].map((m) => m[1]!))] +} + +const props = propsOfInterface(readFileSync(join(__dirname, '..', 'NitroInput.tsx'), 'utf8'), 'NitroInputProps') +const readme = readFileSync(join(__dirname, '..', '..', 'README.md'), 'utf8') + +function propsTable(): string { + const start = readme.indexOf('\n## Props') + expect(start).toBeGreaterThanOrEqual(0) + const end = readme.indexOf('\n## ', start + 1) + return readme.slice(start, end === -1 ? undefined : end) +} + +describe('README props table', () => { + const table = propsTable() + // Anything in backticks in the first cell of a table row. + const documented = new Set( + [...table.matchAll(/^\| ([^|]+) \|/gm)].flatMap((row) => [...row[1]!.matchAll(/`([a-zA-Z][a-zA-Z0-9-]*)`/g)].map((m) => m[1]!)) + ) + + it('finds the interface and the table', () => { + expect(props.length).toBeGreaterThan(60) + expect(documented.size).toBeGreaterThan(60) + }) + + it('documents every prop the component declares', () => { + const missing = props.filter((prop) => !documented.has(prop)) + expect(missing).toEqual([]) + }) + + it('documents nothing the component does not declare', () => { + // The view props the field takes through `ViewProps`, named in the table + // because the wrapper resolves or forwards them itself. + const fromViewProps = new Set(['style', 'testID', 'id', 'aria-label', 'nativeID', 'accessibilityLabel']) + const declared = new Set(props) + const stale = [...documented].filter((prop) => !declared.has(prop) && !fromViewProps.has(prop)) + expect(stale).toEqual([]) + }) +}) diff --git a/packages/react-native-nitro-input/src/__tests__/SharedHelpers.test.ts b/packages/react-native-nitro-input/src/__tests__/SharedHelpers.test.ts new file mode 100644 index 00000000..e76f91e1 --- /dev/null +++ b/packages/react-native-nitro-input/src/__tests__/SharedHelpers.test.ts @@ -0,0 +1,35 @@ +/** + * `NitroInput.tsx` and the rolling number's `RollingNumber.tsx` carry the same + * small helpers (font-weight mapping, color processing). The two packages + * publish independently, so they cannot share a module without a third + * package; this keeps the copies from drifting instead. Both blocks sit + * between `// shared-helpers:start` and `// shared-helpers:end`, and the + * sibling runs the mirror image of this test. + */ +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' + +function sharedBlock(path: string): string { + const source = readFileSync(path, 'utf8') + const start = source.indexOf('// shared-helpers:start') + const end = source.indexOf('// shared-helpers:end') + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + // The lines after the marker name the other file; skip them so the two blocks compare. + return source + .slice(start, end) + .split('\n') + .filter((line) => !line.startsWith('// Kept byte-for-byte') && !line.startsWith('// (a test compares')) + .join('\n') +} + +const here = join(__dirname, '..', 'NitroInput.tsx') +const sibling = join(__dirname, '..', '..', '..', 'react-native-nitro-rolling-number', 'src', 'RollingNumber.tsx') + +const describeIfMonorepo = existsSync(sibling) ? describe : describe.skip + +describeIfMonorepo('shared helpers', () => { + it('stay identical between NitroInput.tsx and RollingNumber.tsx', () => { + expect(sharedBlock(here)).toBe(sharedBlock(sibling)) + }) +}) diff --git a/packages/react-native-nitro-input/src/__tests__/TextInputParity.test.tsx b/packages/react-native-nitro-input/src/__tests__/TextInputParity.test.tsx index 1e348d53..3da61173 100644 --- a/packages/react-native-nitro-input/src/__tests__/TextInputParity.test.tsx +++ b/packages/react-native-nitro-input/src/__tests__/TextInputParity.test.tsx @@ -20,7 +20,7 @@ */ import { readFileSync } from 'fs' import { dirname, join } from 'path' -import React, { createRef } from 'react' +import React, { createRef, Profiler } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { NitroInput, type NitroInputHandle } from '../NitroInput' @@ -108,8 +108,9 @@ function both(props: Record = {}) { const rnRef = createRef() const ourRef = createRef() const rn = render() - // Ours sizes to content unless told otherwise; a width makes the two layouts - // comparable and is what a drop-in would be given (see `autoWidth`). + // A width, as a form field is usually given. Not needed for the comparison + // - `NitroInput` takes its width from its parent like a `TextInput` - but it + // keeps the `autoWidth` inference out of the picture. const ours = render() return { rn: { renderer: rn, ref: rnRef, host: () => rnHost(rn), drive: rnDriver(rn) }, @@ -262,6 +263,29 @@ describe('aliases resolve the same way', () => { expect(ours.host().submitBehavior).toBe('submit') }) + it('submitBehavior defaults the same way: blurAndSubmit on one line, newline on many', () => { + const single = both() + expect(single.rn.host().submitBehavior).toBe('blurAndSubmit') + expect(single.ours.host().submitBehavior).toBe('blurAndSubmit') + const multi = both({ multiline: true }) + expect(multi.rn.host().submitBehavior).toBe('newline') + expect(multi.ours.host().submitBehavior).toBe('newline') + }) + + it('blurOnSubmit on a multiline field maps the same way on both', () => { + // `true` is the one value that changes anything on a multiline field: the + // return key blurs instead of inserting a line break. `false` is the default. + const blur = both({ multiline: true, blurOnSubmit: true }) + expect(blur.rn.host().submitBehavior).toBe('blurAndSubmit') + expect(blur.ours.host().submitBehavior).toBe('blurAndSubmit') + const newline = both({ multiline: true, blurOnSubmit: false }) + expect(newline.rn.host().submitBehavior).toBe('newline') + expect(newline.ours.host().submitBehavior).toBe('newline') + const single = both({ blurOnSubmit: true }) + expect(single.rn.host().submitBehavior).toBe('blurAndSubmit') + expect(single.ours.host().submitBehavior).toBe('blurAndSubmit') + }) + it('inputMode maps onto keyboardType the same way', () => { for (const [inputMode, keyboardType] of [ ['text', 'default'], @@ -509,7 +533,10 @@ describe('mostRecentEventCount', () => { }) it('advances to the count the native side reported, on both', () => { - const { rn, ours } = both({ onChangeText: () => {} }) + // A controlled field: the count is what lets native tell a stale `value` + // from a deliberate one. An uncontrolled field of ours keeps it in a ref + // and does not render for it - see the deliberate differences. + const { rn, ours } = both({ value: 'a', onChangeText: () => {} }) rn.drive.type('a', 1) ours.drive.type('a', 1) expect(rn.drive.eventCount()).toBe(1) @@ -664,14 +691,71 @@ describe('the TextInput.State registry', () => { // --------------------------------------------------------------------------- describe('deliberate differences', () => { - it('sizes itself to its content, where TextInput stretches to its parent', () => { - // `TextInput` has no intrinsic width; ours measures its text, which is what - // makes an amount field possible. `style` with a width turns it off, which - // is what a drop-in form field wants - see `autoWidth`. - const auto = ourHost(render()) - const fixed = ourHost(render()) - expect(auto.style).toBeDefined() - expect(JSON.stringify(fixed.style)).toContain('200') + it('sizes itself to its content only when asked, where TextInput never does', () => { + // `TextInput` has no intrinsic width. Ours measures its text, which is what + // makes an amount field possible, but only under `autoWidth`: `false` (the + // default) takes no width of its own, so flexbox stretches the field like + // a `TextInput`; `'auto'` (what `MorphInput` uses) infers it unless `style` + // gives a `width` or `flex`; `true` always sizes to content. The measured + // size is the first entry of the host's `style`, ahead of the caller's. + const measured = (renderer: ReactTestRenderer) => { + const onSizeChange = ourHost(renderer).onSizeChange as { f: (w: number, h: number) => void } + act(() => onSizeChange.f(120, 48)) + return (ourHost(renderer).style as unknown[])[0] + } + expect(measured(render())).toEqual({ height: 48 }) + expect(measured(render())).toEqual({ height: 48 }) + expect(measured(render())).toEqual({ width: 120, height: 48 }) + expect(measured(render())).toEqual({ height: 48 }) + expect(measured(render())).toEqual({ height: 48 }) + expect(measured(render())).toEqual({ width: 120, height: 48 }) + expect(measured(render())).toEqual({ width: 120, height: 48 }) + // A box pinned on both axes never takes the measurement: `style` would win + // anyway, and the state update would be a wasted commit per field. + expect(measured(render())).toEqual({ height: 40 }) + }) + + it('does not re-render an uncontrolled field on a keystroke, where TextInput does', () => { + // React Native's `TextInput` keeps `mostRecentEventCount` in state and sets + // it on every change, so even an uncontrolled field re-renders per key. + // Ours keeps the count in a ref and only takes the state update when + // `value` is given - the one case native needs the count, since only a + // controlled field's `text` prop can change. That is what lets a + // worklet-driven field run nothing on the JS thread while typing. + const rnRenders = jest.fn() + const ourRenders = jest.fn() + const rn = render( + + {}} /> + + ) + const ours = render( + + {}} /> + + ) + expect(ourRenders).toHaveBeenCalledTimes(1) + const rnBefore = rnRenders.mock.calls.length + const ourPropsBefore = ourHost(ours) + rnDriver(rn).type('a', 1) + ourDriver(ours).type('a', 1) + expect(rnRenders.mock.calls.length).toBeGreaterThan(rnBefore) + expect(ourRenders).toHaveBeenCalledTimes(1) + expect(ourHost(ours)).toBe(ourPropsBefore) + + // The count was still tracked: making the field controlled later sends + // the fresh one, so the new `value` is not taken for a stale one. + act(() => { + ours.update( + + {}} /> + + ) + }) + expect(ourHost(ours).mostRecentEventCount).toBe(1) + expect(ourHost(ours).text).toBe('a') + ourDriver(ours).type('ab', 2) + expect(ourHost(ours).mostRecentEventCount).toBe(2) }) it('multiline turns the morph off, because one run cannot wrap', () => { diff --git a/packages/react-native-nitro-input/src/__tests__/Worklets.test.tsx b/packages/react-native-nitro-input/src/__tests__/Worklets.test.tsx new file mode 100644 index 00000000..21e9cf6c --- /dev/null +++ b/packages/react-native-nitro-input/src/__tests__/Worklets.test.tsx @@ -0,0 +1,231 @@ +/** + * The worklet registry (`worklets.ts`) with `react-native-worklets` present. + * + * The other suites run without it and assert that everything degrades to id + * `0` with one warning. This one stands a fake in for the package - the UI + * runtime is this very runtime, so `executeOnUIRuntimeSync` just runs the + * function - and for the native bridge, so the wrappers that native would call + * by id can be called here and their arguments and results checked. + */ +import React from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' + +jest.mock( + 'react-native-worklets', + () => ({ + executeOnUIRuntimeSync: (fn: () => unknown) => () => fn(), + runOnUI: + (fn: (...args: unknown[]) => void) => + (...args: unknown[]) => + fn(...args), + getUIRuntimeHolder: () => ({ holder: true }), + }), + { virtual: true } +) + +// `mock`-prefixed so the hoisted factory below may close over it. +const mockInstall = jest.fn(() => true) +jest.mock('react-native-nitro-modules', () => ({ + getHostComponent: (name: string) => name, + callback: (f: T) => (typeof f === 'function' ? { f } : f), + NitroModules: { + createHybridObject: () => ({ isAvailable: true, install: mockInstall }), + }, +})) + +/* eslint-disable @typescript-eslint/no-var-requires */ +const worklets = require('../worklets') as typeof import('../worklets') +const { NitroInput } = require('../NitroInput') as typeof import('../NitroInput') +/* eslint-enable @typescript-eslint/no-var-requires */ + +type Wrapper = (...args: never[]) => unknown +const registry = () => globalThis.__nitroInputWorklets as Map +const wrapper = (id: number) => registry().get(id) as (...args: unknown[]) => unknown + +function render(element: React.ReactElement): ReactTestRenderer { + let renderer: ReactTestRenderer | undefined + act(() => { + renderer = create(element) + }) + return renderer! +} + +function nativeProps(renderer: ReactTestRenderer): Record { + return renderer.root.findByType('NitroInputView' as never).props as Record +} + +/** A function the worklets Babel plugin would have compiled. */ +const asWorklet = unknown>(fn: T): T => Object.assign(fn, { __workletHash: 1 }) + +describe('installation', () => { + it('installs the UI runtime once and allocates increasing ids', () => { + expect(worklets.ensureWorkletsInstalled()).toBe(true) + expect(worklets.ensureWorkletsInstalled()).toBe(true) + expect(mockInstall).toHaveBeenCalledTimes(1) + expect(mockInstall).toHaveBeenCalledWith({ holder: true }) + expect(registry()).toBeInstanceOf(Map) + const a = worklets.allocateWorkletId() + const b = worklets.allocateWorkletId() + expect(a).toBeGreaterThan(0) + expect(b).toBe(a + 1) + }) + + it('recognises a compiled worklet by its hash', () => { + expect(worklets.isWorklet(asWorklet(() => {}))).toBe(true) + expect(worklets.isWorklet(() => {})).toBe(false) + expect(worklets.isWorklet(undefined)).toBe(false) + }) +}) + +describe('registration', () => { + it('registers a transform whose wrapper returns the new text and a selection', () => { + const id = worklets.allocateWorkletId() + worklets.registerTransform(({ text }) => ({ text: text.toUpperCase() }), id) + // (text, previousText, selectionStart, selectionEnd, previousStart, previousEnd) + expect(wrapper(id)('ab', 'a', 2, 2, 1, 1)).toEqual({ text: 'AB', selectionStart: 2, selectionEnd: 2 }) + }) + + it('a transform returning null keeps the edit', () => { + const id = worklets.allocateWorkletId() + worklets.registerTransform(() => null, id) + expect(wrapper(id)('ab', 'a', 2, 2, 1, 1)).toBeNull() + }) + + it('a transform with no selection keeps the caret relative to the edit', () => { + const id = worklets.allocateWorkletId() + worklets.registerTransform(({ text }) => ({ text: '@' + text }), id) + // Caret at the end stays at the end. + expect(wrapper(id)('ab', 'a', 2, 2, 1, 1)).toEqual({ text: '@ab', selectionStart: 3, selectionEnd: 3 }) + // A caret inside the text moves by the length difference. + expect(wrapper(id)('abc', 'ac', 1, 1, 1, 1)).toEqual({ text: '@abc', selectionStart: 2, selectionEnd: 2 }) + // A range moves as a whole. + expect(wrapper(id)('abcd', 'abd', 1, 3, 1, 2)).toEqual({ text: '@abcd', selectionStart: 2, selectionEnd: 4 }) + }) + + it('a transform may place the selection itself', () => { + const id = worklets.allocateWorkletId() + worklets.registerTransform(() => ({ text: 'xyz', selection: { start: 0, end: 1 } }), id) + expect(wrapper(id)('ab', 'a', 2, 2, 1, 1)).toEqual({ text: 'xyz', selectionStart: 0, selectionEnd: 1 }) + }) + + it('registers a callback as itself', () => { + const id = worklets.allocateWorkletId() + const onChangeText = jest.fn() + worklets.registerCallback(onChangeText, id) + wrapper(id)('abc') + expect(onChangeText).toHaveBeenCalledWith('abc') + }) + + it('registers the focus pair under one id and picks the handler', () => { + const id = worklets.allocateWorkletId() + const onFocus = jest.fn() + const onBlur = jest.fn() + worklets.registerFocusChange(onFocus, onBlur, id) + wrapper(id)(true, 'hello') + wrapper(id)(false, 'hello') + const event = { text: 'hello', target: 0, eventCount: 0, nativeEvent: { text: 'hello', target: 0, eventCount: 0 } } + expect(onFocus).toHaveBeenCalledWith(event) + expect(onBlur).toHaveBeenCalledWith(event) + // Either half may be missing. + const only = worklets.allocateWorkletId() + worklets.registerFocusChange(undefined, onBlur, only) + expect(() => wrapper(only)(true, '')).not.toThrow() + }) + + it('builds the selection, text and key events native sends the scalars of', () => { + const select = jest.fn() + const selectId = worklets.allocateWorkletId() + worklets.registerSelectionChange(select, selectId) + wrapper(selectId)(1, 3) + expect(select).toHaveBeenCalledWith({ + selection: { start: 1, end: 3 }, + start: 1, + end: 3, + target: 0, + nativeEvent: { selection: { start: 1, end: 3 }, target: 0 }, + }) + + const submit = jest.fn() + const submitId = worklets.allocateWorkletId() + worklets.registerTextEvent(submit, submitId) + wrapper(submitId)('done') + expect(submit).toHaveBeenCalledWith({ text: 'done', target: 0, nativeEvent: { text: 'done', target: 0 } }) + + const key = jest.fn() + const keyId = worklets.allocateWorkletId() + worklets.registerKeyPress(key, keyId) + wrapper(keyId)('Backspace') + expect(key).toHaveBeenCalledWith({ + key: 'Backspace', + eventCount: 0, + target: 0, + nativeEvent: { key: 'Backspace', eventCount: 0, target: 0 }, + }) + }) + + it('unregisters by id and ignores id 0', () => { + const id = worklets.allocateWorkletId() + worklets.registerCallback(() => {}, id) + expect(registry().has(id)).toBe(true) + worklets.unregisterWorklet(id) + expect(registry().has(id)).toBe(false) + expect(() => worklets.unregisterWorklet(0)).not.toThrow() + worklets.registerCallback(() => {}, 0) + expect(registry().has(0)).toBe(false) + }) +}) + +describe('through the component', () => { + it('sends the ids of its worklet props and unregisters them on unmount', () => { + const transform = asWorklet(() => null) + const onChangeText = asWorklet(() => {}) + const onFocus = asWorklet(() => {}) + const onKeyPress = asWorklet(() => {}) + const renderer = render( + + ) + const props = nativeProps(renderer) + const ids = [ + props.transformWorklet, + props.onChangeTextWorklet, + props.onFocusChangeWorklet, + props.onKeyPressWorklet, + ] as number[] + for (const id of ids) { + expect(id).toBeGreaterThan(0) + expect(registry().has(id)).toBe(true) + } + expect(new Set(ids).size).toBe(ids.length) + // A handler that is not a worklet gets no id. + expect(props.onChangeValueWorklet).toBe(0) + expect(props.onSelectionChangeWorklet).toBe(0) + act(() => renderer.unmount()) + for (const id of ids) expect(registry().has(id)).toBe(false) + }) + + it('keeps the id while the function stays the same and re-registers when it changes', () => { + const first = asWorklet(() => {}) + const renderer = render() + const id = nativeProps(renderer).onChangeTextWorklet as number + act(() => { + renderer.update() + }) + expect(nativeProps(renderer).onChangeTextWorklet).toBe(id) + const second = asWorklet(() => {}) + act(() => { + renderer.update() + }) + const next = nativeProps(renderer).onChangeTextWorklet as number + expect(next).not.toBe(id) + expect(registry().has(id)).toBe(false) + expect(registry().get(next)).toBe(second) + }) + + it('does not also call a worklet handler on the JS thread', () => { + const onChangeText = asWorklet(jest.fn()) + const renderer = render() + const native = nativeProps(renderer).onChangeText as { f: (t: string, c: number) => void } + act(() => native.f('abc', 1)) + expect(onChangeText).not.toHaveBeenCalled() + }) +}) diff --git a/packages/react-native-nitro-input/src/__tests__/useNitroInputState.test.tsx b/packages/react-native-nitro-input/src/__tests__/useNitroInputState.test.tsx new file mode 100644 index 00000000..f53e4990 --- /dev/null +++ b/packages/react-native-nitro-input/src/__tests__/useNitroInputState.test.tsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react' +import { act, create } from 'react-test-renderer' +import { useNitroInputState, type NitroInputState, type SharedValueLike } from '../useNitroInputState' + +/** Reanimated's `useSharedValue`, reduced to what the hook needs: a stable box. */ +function useFakeSharedValue(initial: T): SharedValueLike { + return useState(() => ({ value: initial }))[0] +} + +function Harness({ onState, tick }: { onState: (state: NitroInputState) => void; tick: number }) { + const state = useNitroInputState(useFakeSharedValue) + onState(state) + return <>{tick} +} + +function mount() { + const states: NitroInputState[] = [] + let renderer!: ReturnType + act(() => { + renderer = create( states.push(s)} tick={0} />) + }) + return { + latest: () => states[states.length - 1]!, + rerender: () => + act(() => { + renderer.update( states.push(s)} tick={states.length} />) + }), + } +} + +describe('useNitroInputState', () => { + it('starts empty, unfocused, with the caret at the start', () => { + const state = mount().latest() + expect(state.text.value).toBe('') + expect(Number.isNaN(state.value.value)).toBe(true) + expect(state.focused.value).toBe(false) + expect(state.selection.value).toEqual({ start: 0, end: 0 }) + }) + + it('writes the field’s events into the shared values', () => { + const { handlers, text, value, focused, selection } = mount().latest() + handlers.onChangeText('1,234') + handlers.onChangeValue(1234) + handlers.onFocus() + handlers.onSelectionChange({ start: 1, end: 3 }) + expect(text.value).toBe('1,234') + expect(value.value).toBe(1234) + expect(focused.value).toBe(true) + expect(selection.value).toEqual({ start: 1, end: 3 }) + handlers.onBlur() + expect(focused.value).toBe(false) + }) + + it('copies the selection rather than keeping the event object', () => { + const { handlers, selection } = mount().latest() + const event = { start: 2, end: 2, extra: true } + handlers.onSelectionChange(event) + expect(selection.value).toEqual({ start: 2, end: 2 }) + expect(selection.value).not.toBe(event) + }) + + it('keeps the handlers and the shared values stable across renders', () => { + // A new handler identity would re-register the worklet on the UI runtime + // every render, which is what the hook exists to avoid. + const harness = mount() + const first = harness.latest() + harness.rerender() + const second = harness.latest() + expect(second.handlers).toBe(first.handlers) + expect(second.text).toBe(first.text) + expect(second.value).toBe(first.value) + expect(second.focused).toBe(first.focused) + expect(second.selection).toBe(first.selection) + }) + + it('spreads onto the field as the five worklet handlers', () => { + const { handlers } = mount().latest() + expect(Object.keys(handlers).sort()).toEqual( + ['onBlur', 'onChangeText', 'onChangeValue', 'onFocus', 'onSelectionChange'].sort() + ) + }) +}) diff --git a/packages/react-native-nitro-input/src/index.ts b/packages/react-native-nitro-input/src/index.ts index eae71306..acad3adc 100644 --- a/packages/react-native-nitro-input/src/index.ts +++ b/packages/react-native-nitro-input/src/index.ts @@ -8,6 +8,8 @@ export { type NitroInputFocusEvent, type NitroInputSelectionEvent, type NitroInputKeyPressEvent, + type InputMode, + type EnterKeyHint, } from './NitroInput' export { MorphInput, @@ -38,7 +40,9 @@ export type { NitroInputEasing, NitroInputEffect, NitroInputTextAlign, + NitroInputTextAlignVertical, NitroInputAffixAlign, + NitroInputSignPlacement, NitroInputKeyboardType, NitroInputReturnKeyType, NitroInputAutoCapitalize, diff --git a/packages/react-native-nitro-input/src/specs/NitroInput.nitro.ts b/packages/react-native-nitro-input/src/specs/NitroInput.nitro.ts index c006283e..ee26fc5c 100644 --- a/packages/react-native-nitro-input/src/specs/NitroInput.nitro.ts +++ b/packages/react-native-nitro-input/src/specs/NitroInput.nitro.ts @@ -109,12 +109,12 @@ export type NitroInputAutoCapitalize = | 'characters' /** - * What the return key does. `'blurAndSubmit'` (the default, and what the field - * did unconditionally before) fires `onSubmitEditing` and dismisses the - * keyboard; `'submit'` fires it and keeps focus, which is what a form that - * moves to the next field needs. + * What the return key does. `'blurAndSubmit'` fires `onSubmitEditing` and + * dismisses the keyboard; `'submit'` fires it and keeps focus, which is what + * a form that moves to the next field needs; `'newline'` inserts a line break + * (multiline fields only, and their default, as with `TextInput`). */ -export type NitroInputSubmitBehavior = 'submit' | 'blurAndSubmit' +export type NitroInputSubmitBehavior = 'submit' | 'blurAndSubmit' | 'newline' /** Light or dark keyboard (iOS). `'default'` follows the system appearance. */ export type NitroInputKeyboardAppearance = 'default' | 'light' | 'dark' @@ -214,13 +214,11 @@ export interface NitroInputProps extends HybridViewProps { prefixFontSize: number /** Font size of `suffix` in points. Defaults to `fontSize`. */ suffixFontSize: number - /** Vertical alignment of prefix and suffix relative to the text. Default: `'baseline'`. */ - affixAlign: NitroInputAffixAlign /** Where a negative amount's sign sits relative to `prefix`. */ signPlacement: NitroInputSignPlacement - /** Alignment of `prefix` only. Defaults to `affixAlign`. */ + /** Vertical alignment of `prefix` relative to the text (the wrapper folds its `affixAlign` into this). */ prefixAlign: NitroInputAffixAlign - /** Alignment of `suffix` only. Defaults to `affixAlign`. */ + /** Vertical alignment of `suffix` relative to the text (the wrapper folds its `affixAlign` into this). */ suffixAlign: NitroInputAffixAlign /** Shown (and morphed away) while the field is empty. Default: `''`. */ placeholder: string @@ -324,7 +322,7 @@ export interface NitroInputProps extends HybridViewProps { fieldTestID: string /** Accessibility label for the hidden system field. Empty leaves it unset. */ fieldAccessibilityLabel: string - /** What the return key does. Default: `'blurAndSubmit'`. */ + /** What the return key does. Default: `'blurAndSubmit'`, `'newline'` when `multiline`. */ submitBehavior: NitroInputSubmitBehavior /** Masks the text with bullets and opts the field out of autocorrect/autofill. Default: `false`. */ secureTextEntry: boolean diff --git a/packages/react-native-nitro-input/tsconfig.build.cjs.json b/packages/react-native-nitro-input/tsconfig.build.cjs.json new file mode 100644 index 00000000..2741a690 --- /dev/null +++ b/packages/react-native-nitro-input/tsconfig.build.cjs.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "module": "commonjs", + "verbatimModuleSyntax": false, + "declaration": false, + "declarationDir": null, + "outDir": "lib/commonjs" + } +} diff --git a/packages/react-native-nitro-input/tsconfig.build.json b/packages/react-native-nitro-input/tsconfig.build.json index 93291705..02eb7177 100644 --- a/packages/react-native-nitro-input/tsconfig.build.json +++ b/packages/react-native-nitro-input/tsconfig.build.json @@ -10,6 +10,9 @@ "composite": false, "incremental": false, "declaration": true, + "declarationDir": "lib/typescript", + "rootDir": ".", + "outDir": "lib/module", "types": [], "noEmit": false } From ce2aa8d4eeba7dee30a8e21c3d92a2a6bbfdf22f Mon Sep 17 00:00:00 2001 From: Ronald Goedeke Date: Mon, 21 Sep 2026 22:23:59 -0300 Subject: [PATCH 3/3] Repo: build step in CI, wasm rebuilt on deploy, the Nitro patch documented The docs workflow rebuilds both engines to WebAssembly before deploying, since a change under cpp/ used to trigger a deploy with a stale build (the committed builds are refreshed here too, and the rolling engine's script links with em++). The package workflow builds `lib/`. The README's test command was `bun test`, which runs Bun's own runner against the Jest files; it is `bun run test`. It also documents the Android view-props patch for Nitro 0.37 that consumers need until the upstream fix ships. The demo gains a multiline field with a `submitBehavior` toggle, the row the device check of that feature used. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/docs.yml | 8 ++++++++ .github/workflows/packages.yml | 3 +++ README.md | 9 ++++++-- bun.lock | 8 ++++++++ docs/input/multiline.mdx | 4 +++- docs/input/props.mdx | 8 +++++--- docs/input/react-native.mdx | 20 +++++++++++------- docs/package.json | 2 +- docs/rolling-number/props.mdx | 2 +- docs/src/engine/morph-engine.js | Bin 159031 -> 161526 bytes docs/src/engine/rolling-engine.js | Bin 115604 -> 103010 bytes example/src/screens/DemoScreen.tsx | 32 +++++++++++++++++++++++++++++ 12 files changed, 81 insertions(+), 15 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d9cfa434..a6f99b31 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -33,6 +33,14 @@ jobs: - name: Install working-directory: docs run: npm ci + # The live demos run the real engines compiled to WebAssembly. Rebuild + # them here so a change to cpp/ never deploys with a stale build. + - uses: mymindstorm/setup-emsdk@v14 + with: + version: latest + - name: Build the engines to WebAssembly + working-directory: docs + run: npm run build:wasm && npm run build:wasm:morph - name: Build working-directory: docs run: npm run build diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 10c8b4bb..8b311762 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -39,6 +39,9 @@ jobs: # The shared engines are host-side clang++ binaries: no toolchain to set up. - name: C++ engines run: bun run test:cpp + # The published lib/: ES modules, CommonJS and the declarations, for both packages. + - name: Build + run: bun run build android: name: Android libraries diff --git a/README.md b/README.md index c9624460..af74564e 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,9 @@ import { NitroInput } from 'react-native-nitro-input' ```sh bun install # hoisted linker, see bunfig.toml bun specs # re-run nitrogen after editing src/specs/*.nitro.ts -bun test # jest tests for the JS wrapper -bun --cwd packages/react-native-nitro-rolling-number run test:cpp # engine tests (same for packages/react-native-nitro-input) +bun run test # jest tests for the JS wrappers (plain `bun test` would run Bun's own runner against them) +bun run test:cpp # C++ engine tests for both packages (host clang++) +bun run build # lib/ for both packages: ES modules, CommonJS and declarations bun example ios # or: bun example android cd docs && npm install && npm start # docs site ``` @@ -124,6 +125,10 @@ Releasing: `bun --cwd packages/ release ` runs the t The example's Android Gradle files point at the workspace root `node_modules`, and Metro watches the whole repo. +## Known issue: view props on Android + +Nitro Modules 0.37 never fills a Hybrid View's raw props on Android from React Native 0.86 on, so `backgroundColor`, `border*`, `opacity`, `transform`, `testID` and the accessibility props you pass to `` or `` are silently ignored there. iOS is unaffected. The fix is filed upstream as [margelo/nitro#1655](https://github.com/margelo/nitro/pull/1655) (issue [#1656](https://github.com/margelo/nitro/issues/1656)); until a Nitro release carries it, apply the patch this repo uses: copy [`patches/react-native-nitro-modules@0.37.1.patch`](patches/react-native-nitro-modules@0.37.1.patch) into your app and register it under `patchedDependencies` in `package.json` (Bun) or with [patch-package](https://github.com/ds300/patch-package) (npm / Yarn). + ## Credits The input's morph is based on [Torph](https://torph.lochie.me) by [Lochie Axon](https://github.com/lochie). Thanks for building it. diff --git a/bun.lock b/bun.lock index 75ada785..ee391113 100644 --- a/bun.lock +++ b/bun.lock @@ -58,7 +58,9 @@ "@react-native/babel-preset": "0.87.1", "@react-native/jest-preset": "0.87.1", "@types/jest": "^29.5.13", + "@types/node": "^26.6.1", "@types/react": "^19.2.0", + "@types/react-test-renderer": "^19.3.0", "jest": "^29.7.0", "nitrogen": "0.37.1", "react": "19.2.3", @@ -72,7 +74,11 @@ "react": "*", "react-native": ">=0.78.0", "react-native-nitro-modules": ">=0.37.0", + "react-native-worklets": "*", }, + "optionalPeers": [ + "react-native-worklets", + ], }, "packages/react-native-nitro-rolling-number": { "name": "react-native-nitro-rolling-number", @@ -81,7 +87,9 @@ "@react-native/babel-preset": "0.87.1", "@react-native/jest-preset": "0.87.1", "@types/jest": "^29.5.13", + "@types/node": "^26.6.1", "@types/react": "^19.2.0", + "@types/react-test-renderer": "^19.3.0", "jest": "^29.7.0", "nitrogen": "0.37.1", "react": "19.2.3", diff --git a/docs/input/multiline.mdx b/docs/input/multiline.mdx index 03d1e525..25df6a06 100644 --- a/docs/input/multiline.mdx +++ b/docs/input/multiline.mdx @@ -19,4 +19,6 @@ description: 'multiline: a field that wraps, grows with its content or scrolls p ## What a multiline field is not -A multiline field is always drawn by the system view and is always `mode="text"`. The glyph engine lays one run out on one baseline, so it cannot morph wrapped text, and an amount or a mask is a single-line idea. `morph`, `mode="number"` and `mode="mask"` are ignored alongside `multiline`, with one warning in development. Everything else applies: a [frame](/input/frames) with a floating label, `maxLength`, autocorrect and the edit callbacks. +A multiline field is always drawn by the system view and is always `mode="text"`. The glyph engine lays one run out on one baseline, so it cannot morph wrapped text, and an amount, a mask and their affixes are single-line ideas. `morph`, `mode="number"`, `mode="mask"`, `prefix` and `suffix` are ignored alongside `multiline`, with one warning each in development. Everything else applies: a [frame](/input/frames) with a floating label, `maxLength`, autocorrect and the edit callbacks. + +The return key inserts a line break, as it does on a multiline `TextInput`: `submitBehavior` defaults to `'newline'` there. Pass `submitBehavior="blurAndSubmit"` (or the older `blurOnSubmit`) to have it fire `onSubmitEditing` and dismiss the keyboard instead, or `'submit'` to fire it and keep focus. diff --git a/docs/input/props.mdx b/docs/input/props.mdx index 221efe32..c8cc67b7 100644 --- a/docs/input/props.mdx +++ b/docs/input/props.mdx @@ -25,17 +25,19 @@ The full table is in the package [README](https://github.com/ronickg/react-nativ | `adjustsFontSizeToFit`, `minimumFontScale`, `allowFontScaling`, `maxFontSizeMultiplier` | `false`, `0.5`, `false`, `0` | Shrinking to fit a width, and following the system text size. | | `cursorColor`, `selectionColor`, `caretHidden` | | The caret and the selection. | | `keyboardType`, `returnKeyType`, `autoCapitalize`, `autoCorrect`, `maxLength`, `editable`, `autoFocus` | | Input traits. | +| `inputMode`, `enterKeyHint` | | React Native's HTML-style aliases for `keyboardType` and `returnKeyType`, mapped with its tables; the explicit prop wins. `inputMode="none"` focuses without a keyboard. | | `transform` | | A worklet mask, run synchronously on the UI thread. | -| `onChangeText`, `onChangeValue`, `onFocus`, `onBlur`, `onSubmitEditing`, `onEndEditing`, `onSelectionChange`, `onKeyPress` | | `TextInput`'s edit callbacks, plus `onChangeValue`. Any of them marked `'worklet'` runs on the UI thread instead of the JS one — see [Worklets](/input/worklets). | +| `onChangeText`, `onChange`, `onChangeValue`, `onFocus`, `onBlur`, `onSubmitEditing`, `onEndEditing`, `onSelectionChange`, `onKeyPress` | | `TextInput`'s edit callbacks, plus `onChangeValue`. Each event carries `nativeEvent` as `TextInput`'s does, with the same fields repeated at the top level. Any of them marked `'worklet'` runs on the UI thread instead of the JS one — see [Worklets](/input/worklets). | | `onChangeMask` | | `'mask'`: the formatted text, the extracted value, the missing tail and whether every mandatory slot is filled. | | `value`, `defaultValue` | | Controlled and uncontrolled text. A `value` with a stale `mostRecentEventCount` is ignored, like `TextInput`. | -| `submitBehavior` | `'blurAndSubmit'` | `'submit'` keeps focus so a form can move to the next field itself. | +| `submitBehavior` | `'blurAndSubmit'`, `'newline'` when `multiline` | `'submit'` keeps focus so a form can move to the next field itself; `'newline'` inserts a line break (`multiline` only). `blurOnSubmit` is the deprecated alias, resolved as `TextInput` resolves it. | | `secureTextEntry` | `false` | Draws bullets; the field keeps the real text for autofill. | | `textContentType` / `autoComplete` | `''` | Autofill: `'username'`, `'password'`, `'oneTimeCode'`, … | | `keyboardAppearance`, `enablesReturnKeyAutomatically`, `showSoftInputOnFocus` | | Keyboard behaviour. | | `selectTextOnFocus`, `clearTextOnFocus`, `contextMenuHidden`, `spellCheck`, `readOnly` | | As on `TextInput`. | | `selection` | | `{ start, end? }` in code points, controlled. | +| `id`, `aria-label`, `testID`, `accessibilityLabel` | | `id` and `aria-label` are resolved like `TextInput`'s and win over the older spellings; `testID` and the label are forwarded to the system field, the element VoiceOver, TalkBack and e2e tools interact with. | -Imperative handle: `focus()`, `blur()`, `clear()`, `setText()`, `setValue()`, `getText()`, `getValue()`, `isFocused()`. +Imperative handle: `focus()`, `blur()`, `clear()`, `setText()`, `setValue()`, `getText()`, `getValue()`, `isFocused()`, `setSelection()`, and `native`, the Nitro object. Hook: `useNitroInputState(useSharedValue)` — the field's `text`, `value`, `focused` and `selection` as shared values, plus the worklet `handlers` that keep them current. See [the field's state as shared values](/input/worklets#the-fields-state-as-shared-values). diff --git a/docs/input/react-native.mdx b/docs/input/react-native.mdx index f7f03f2f..e9a7c464 100644 --- a/docs/input/react-native.mdx +++ b/docs/input/react-native.mdx @@ -7,8 +7,7 @@ description: 'The text-input registry, focus commands, keyboard handling and acc `NitroInput` registers itself in React Native's text-input registry, so the things that act on "the focused input" work on it as they do on `TextInput`: -- `Keyboard.dismiss()`, `TextInput.State.currentlyFocusedInput()` and - `TextInput.State.blurTextInput()` +- `Keyboard.dismiss()` and `TextInput.State.currentlyFocusedInput()` - a `ScrollView`'s `keyboardShouldPersistTaps` / auto-blur - `react-native-keyboard-controller`: the focused-input observer sees it, `KeyboardAwareScrollView` scrolls it into view, and `KeyboardToolbar`'s @@ -18,13 +17,20 @@ things that act on "the focused input" work on it as they do on `TextInput`: React Native focuses an input by dispatching a codegen `focus` / `blur` **view command**. A Nitro view has no such command on Android, so that call would be -dropped and the keyboard would stay up. `NitroInput` therefore wraps -`TextInput.State.focusTextInput` / `blurTextInput` once and routes a -`NitroInput` to its own native focus and blur; every other input is passed -straight through untouched. +dropped and the keyboard would stay up. `NitroInput` therefore wraps the +registry's `focusTextInput` / `blurTextInput` once and routes a `NitroInput` to +its own native focus and blur; every other input is passed straight through +untouched. That reaches everything that reads the two off the registry when it +calls them, which `Keyboard.dismiss()` and `ScrollView` do. -Two consequences worth knowing: +Three consequences worth knowing: +- **`TextInput.State.focusTextInput()` / `blurTextInput()` bypass it.** + `TextInput.js` copies those two references when it loads, before the wrap + runs, so a call through them still dispatches the view command. On iOS the + component answers that command, so both work there; on Android they do not + reach a `NitroInput`. `ref.focus()`, `ref.blur()` and `Keyboard.dismiss()` + are unaffected. - **It is not an optimisation**, but it is faster. Skipping the view command on the way in and the batched event emitter on the way out removes about a frame of waiting at each end, which is where the focus numbers in diff --git a/docs/package.json b/docs/package.json index 6269dacb..7db05d61 100644 --- a/docs/package.json +++ b/docs/package.json @@ -6,7 +6,7 @@ "docusaurus": "docusaurus", "start": "docusaurus start", "build": "docusaurus build", - "build:wasm": "emcc -O3 -std=c++20 --bind -sMODULARIZE=1 -sEXPORT_ES6=1 -sSINGLE_FILE=1 -sENVIRONMENT=web -sEXPORT_NAME=createRollingEngine -sFILESYSTEM=0 -sALLOW_MEMORY_GROWTH=1 --emit-tsd rolling-engine.d.ts -I ../packages/react-native-nitro-rolling-number/cpp ../packages/react-native-nitro-rolling-number/cpp/RollingEngine.cpp wasm/bindings.cpp -o src/engine/rolling-engine.js", + "build:wasm": "em++ -O3 -std=c++20 --bind -sMODULARIZE=1 -sEXPORT_ES6=1 -sSINGLE_FILE=1 -sENVIRONMENT=web -sEXPORT_NAME=createRollingEngine -sFILESYSTEM=0 -sALLOW_MEMORY_GROWTH=1 --emit-tsd rolling-engine.d.ts -I ../packages/react-native-nitro-rolling-number/cpp ../packages/react-native-nitro-rolling-number/cpp/RollingEngine.cpp wasm/bindings.cpp -o src/engine/rolling-engine.js", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", diff --git a/docs/rolling-number/props.mdx b/docs/rolling-number/props.mdx index 629da0d9..10517e70 100644 --- a/docs/rolling-number/props.mdx +++ b/docs/rolling-number/props.mdx @@ -14,7 +14,7 @@ description: Every prop and method of RollingNumber. | Prop | Type | Default | Description | | --- | --- | --- | --- | -| `value` | `number` | – | The number to display. Every change rolls each digit natively. | +| `value` | `number` | – | The number to display. Every change rolls each digit natively. At most 18 digits are shown: `|value| × 10^fractionDigits` is clamped at 10^17, and a JS number carries exact integers only up to 2^53. | | `fractionDigits` | `number` | `0` | Digits after the decimal separator (0–9). | | `minimumIntegerDigits` | `number` | `1` | Zero-pads the integer part (1–15). | | `groupingSeparator` | `string` | `''` | Inserted every three integer digits. | diff --git a/docs/src/engine/morph-engine.js b/docs/src/engine/morph-engine.js index 64d4269dd2621295e27d9004e53f94d79a34e1c1..104d4ae2e2b9450b33e825cec07a2c5f9300315e 100644 GIT binary patch delta 5565 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 aXl>=ptu#P%PU)>~T0m!4W?oLme)eBIg+5aN delta 3055 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 JlCA&se*g)P+EV}k literal 115604 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%0DKMP$) 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 diff --git a/example/src/screens/DemoScreen.tsx b/example/src/screens/DemoScreen.tsx index 9e9472fa..cbcff8c4 100644 --- a/example/src/screens/DemoScreen.tsx +++ b/example/src/screens/DemoScreen.tsx @@ -510,6 +510,9 @@ function MorphInputDemo() { const [maskSel, setMaskSel] = useState('-') const [outlinedText, setOutlinedText] = useState('') const [multilineText, setMultilineText] = useState('') + const [multilineSubmit, setMultilineSubmit] = useState<'submit' | 'blurAndSubmit' | 'newline'>('submit') + const [multilineSubmits, setMultilineSubmits] = useState(0) + const [multilineFocused, setMultilineFocused] = useState(false) const [maskPhone, setMaskPhone] = useState(EMPTY_MASK) const [maskHex, setMaskHex] = useState(EMPTY_MASK) return ( @@ -612,6 +615,35 @@ function MorphInputDemo() { multiline {JSON.stringify(multilineText)} + {/* The return key on a wrapping field: `submit` fires onSubmitEditing and + keeps focus, `blurAndSubmit` also dismisses the keyboard, `newline` + (the default) inserts a line break. */} + setMultilineFocused(true)} + onBlur={() => setMultilineFocused(false)} + onSubmitEditing={() => setMultilineSubmits((n) => n + 1)} + /> + + submits {multilineSubmits} · {multilineFocused ? 'focused' : 'blurred'} + +