├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── __tests__ │ ├── __snapshots__ │ │ └── index.test.tsx.snap │ └── index.test.tsx └── index.tsx └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | *.log 3 | npm-debug.log 4 | 5 | # Runtime data 6 | tmp 7 | build 8 | dist 9 | 10 | # Dependency directory 11 | node_modules -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | *.log 3 | npm-debug.log 4 | 5 | # Dependency directory 6 | node_modules 7 | 8 | # Runtime data 9 | tmp 10 | 11 | # src 12 | src 13 | 14 | # Examples (If applicable to your project) 15 | examples -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Remco Pander 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Native Animated Number [![npm version](https://badge.fury.io/js/react-native-animated-number.svg)](https://badge.fury.io/js/react-native-animated-number) 2 | 3 | ## What is this? 4 | This component renders a text that smoothly animates to new values as props change. 5 | 6 | [Example snack](https://snack.expo.io/@rpander93/react-native-animated-number) 7 | 8 | Other implementations exist. This one is different because it uses a TextInput rather than a Text component and uses [setNativeProps](https://facebook.github.io/react-native/docs/direct-manipulation) to update the value. The benefit of this is that it is much more performant than using state to update the value so the experience is much smoother. 9 | 10 | ## How to use 11 | 12 | First install the package with Yarn or npm. 13 | 14 | ``` 15 | yarn add react-native-animated-number 16 | ``` 17 | 18 | Import the component from the package. The "value" prop is the number the component should animate to. 19 | 20 | ```javascript 21 | import AnimatedNumber from "react-native-animated-number"; 22 | 23 | 24 | ``` 25 | 26 | The component accepts all props from TextInput, plus the following: 27 | 28 | ```javascript 29 | interface Props { 30 | /** Formatter function. Defaults to value => value.toString() */ 31 | formatter?: (value: number) => string; 32 | /** The number of steps it should take to update from an old value to a new value. Defaults to 15 */ 33 | steps?: number; 34 | /** The time between each update. Defaults to 17. */ 35 | time?: number; 36 | /** The value the component should animate to. The component will animate to te new value when this prop changes */ 37 | value: number; 38 | } 39 | ``` 40 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["module:metro-react-native-babel-preset"], 3 | }; -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "preset": "react-native" 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-animated-number", 3 | "version": "1.2.0", 4 | "description": "React Native component that animates a number by smoothly changing it between renders", 5 | "main": "build/index.js", 6 | "module": "build/index.es.js", 7 | "jsnext:main": "build/index.es.js", 8 | "sideEffects": false, 9 | "homepage": "https://github.com/rpander93/react-native-animated-number", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/rpander93/react-native-animated-number.git" 13 | }, 14 | "scripts": { 15 | "test": "jest", 16 | "prepare": "npm run build", 17 | "build": "rollup -c" 18 | }, 19 | "keywords": [ 20 | "react", 21 | "native", 22 | "animated", 23 | "number" 24 | ], 25 | "author": "Remco Pander", 26 | "license": "MIT", 27 | "files": [ 28 | "build" 29 | ], 30 | "devDependencies": { 31 | "@babel/plugin-proposal-class-properties": "7.13.0", 32 | "@babel/plugin-proposal-object-rest-spread": "7.13.8", 33 | "@types/jest": "^25.1.0", 34 | "@types/react-native": "0.63.49", 35 | "@types/react-test-renderer": "16.0.3", 36 | "jest": "^26.6.3", 37 | "react": "16.13.1", 38 | "react-native": "0.63.4", 39 | "react-test-renderer": "16.13.1", 40 | "rollup": "2.47.0", 41 | "rollup-plugin-commonjs": "10.1.0", 42 | "rollup-plugin-node-resolve": "5.2.0", 43 | "rollup-plugin-peer-deps-external": "2.2.4", 44 | "rollup-plugin-typescript2": "0.30.0", 45 | "ts-jest": "^26.5.5", 46 | "tslib": "2.2.0", 47 | "typescript": "^4.2.4" 48 | }, 49 | "peerDependencies": { 50 | "react": "*", 51 | "react-native": "*" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "rollup-plugin-typescript2"; 2 | import commonjs from "rollup-plugin-commonjs"; 3 | import external from "rollup-plugin-peer-deps-external"; 4 | import resolve from "rollup-plugin-node-resolve"; 5 | 6 | import pkg from "./package.json"; 7 | 8 | export default { 9 | input: "src/index.tsx", 10 | output: [ 11 | { 12 | file: pkg.main, 13 | format: "cjs", 14 | exports: "named", 15 | sourcemap: true 16 | }, 17 | { 18 | file: pkg.module, 19 | format: "es", 20 | exports: "named", 21 | sourcemap: true 22 | } 23 | ], 24 | plugins: [ 25 | external(), 26 | resolve(), 27 | typescript({ 28 | exclude: "**/__tests__/**", 29 | }), 30 | commonjs({ 31 | include: ["node_modules/**"], 32 | namedExports: { 33 | "node_modules/react/react.js": [ 34 | "Children", 35 | "Component", 36 | "PropTypes", 37 | "createElement" 38 | ] 39 | } 40 | }) 41 | ] 42 | }; 43 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`It should render with the initial value 1`] = ` 4 | 11 | `; 12 | -------------------------------------------------------------------------------- /src/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { TextInput } from "react-native"; 3 | import { create } from "react-test-renderer"; 4 | import AnimatedNumber from "../index"; 5 | 6 | test("It should render with the initial value", () => { 7 | const component = create(); 8 | const instance = component.root; 9 | 10 | expect(instance.findByType(TextInput).props.value).toEqual("100"); 11 | 12 | let tree = component.toJSON(); 13 | expect(tree).toMatchSnapshot(); 14 | }); 15 | 16 | test("It should render with a custom formatter", () => { 17 | const formatter = (value: number) => `USD ${value}`; 18 | 19 | const component = create(); 20 | const instance = component.root; 21 | 22 | expect(instance.findByType(TextInput).props.value).toEqual("USD 100"); 23 | }); 24 | 25 | test("It should update to a new value", () => { 26 | const component = create(); 27 | component.update(); 28 | 29 | const input = component.root.findByType(TextInput); 30 | /** Value should not be changed via props */ 31 | expect(input.props.value).toEqual("100"); 32 | }); 33 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { TextInput, TextInputProps } from 'react-native'; 3 | 4 | interface AnimatedNumberProps 5 | extends Omit { 6 | formatter?: (value: number) => string; 7 | steps?: number; 8 | time?: number; 9 | value: number; 10 | } 11 | 12 | function formatFn(value: number) { 13 | return value.toString(); 14 | } 15 | 16 | export default function AnimatedNumber({ 17 | formatter = formatFn, 18 | steps = 15, 19 | time = 17, 20 | value, 21 | ...restProps 22 | }: AnimatedNumberProps) { 23 | const viewValue = React.useRef(value); 24 | const textInputRef = React.useRef(null); 25 | const timerRef = React.useRef(); 26 | 27 | const maybeClearInterval = () => { 28 | if (undefined !== timerRef.current) { 29 | clearInterval(timerRef.current); 30 | timerRef.current = undefined; 31 | } 32 | }; 33 | 34 | React.useEffect(() => { 35 | return () => maybeClearInterval(); 36 | }, []); 37 | 38 | // Start updating current value whenever `value` changes 39 | React.useEffect(() => { 40 | if (viewValue.current === value) return; 41 | 42 | const minimumStep = value - viewValue.current > 0 ? 1 : -1; 43 | const stepSize = Math.floor((value - viewValue.current) / steps); 44 | 45 | const valuePerStep = 46 | minimumStep > 0 47 | ? Math.max(stepSize, minimumStep) 48 | : Math.min(stepSize, minimumStep); 49 | 50 | // Clamping is required to correct for rounding errors 51 | const clampValue = 52 | 1 === minimumStep 53 | ? Math.min.bind(undefined, value) 54 | : Math.max.bind(undefined, value); 55 | 56 | timerRef.current = setInterval(() => { 57 | viewValue.current = Math.floor( 58 | clampValue(viewValue.current + valuePerStep) 59 | ); 60 | 61 | textInputRef.current?.setNativeProps({ 62 | text: formatter(viewValue.current), 63 | }); 64 | 65 | if ( 66 | (minimumStep === 1 && viewValue.current >= value) || 67 | (minimumStep === -1 && viewValue.current <= value) 68 | ) { 69 | maybeClearInterval(); 70 | } 71 | }, time); 72 | 73 | return () => maybeClearInterval(); 74 | }, [value]); 75 | 76 | return ( 77 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "build", 4 | "module": "esnext", 5 | "target": "esnext", 6 | "lib": ["es6", "es2016", "es2017"], 7 | "sourceMap": true, 8 | "allowJs": false, 9 | "jsx": "react", 10 | "declaration": true, 11 | "moduleResolution": "node", 12 | "esModuleInterop": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true 21 | }, 22 | "include": ["src"], 23 | "exclude": ["node_modules", "build"] 24 | } 25 | --------------------------------------------------------------------------------