├── .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 [](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 |
--------------------------------------------------------------------------------