├── .eslintrc.js ├── .github └── workflows │ ├── public.yml │ └── release.yml ├── .gitignore ├── .husky └── commit-msg ├── CHANGELOG.md ├── LICENSE ├── README.md ├── babel.config.js ├── commitlint.config.js ├── jest.setup.js ├── package-lock.json ├── package.json ├── src ├── assets │ └── images │ │ ├── demo.gif │ │ └── logo.png ├── components │ ├── Animated │ │ ├── Animated.styles.ts │ │ ├── index.tsx │ │ ├── number.tsx │ │ ├── separator.tsx │ │ ├── sign.tsx │ │ └── text.tsx │ ├── SpinningNumbers │ │ ├── SpinningNumbers.styles.ts │ │ └── index.tsx │ └── TextMeasurment │ │ ├── TextMeasurment.styles.ts │ │ └── index.tsx ├── core │ ├── constants │ │ └── index.ts │ ├── dto │ │ ├── animatedDTO.ts │ │ ├── helpersDTO.ts │ │ └── spinningNumbersDTO.ts │ └── helpers │ │ └── index.ts ├── declarations.d.ts └── index.tsx ├── tests ├── helpers.test.js └── index.test.js └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "root": true, 3 | "env": { 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "parser": "@typescript-eslint/parser", 8 | "parserOptions": { 9 | "ecmaVersion": 12, 10 | "sourceType": "module", 11 | "project": ["./tsconfig.json"], 12 | }, 13 | "plugins": [ 14 | "@typescript-eslint", 15 | "import" 16 | ], 17 | "extends": [ 18 | "airbnb", 19 | "airbnb-typescript" 20 | ], 21 | "rules": { 22 | // React 23 | "react/jsx-props-no-spreading": "off", 24 | "react/prop-types": "off", 25 | "react/function-component-definition": "off", 26 | 27 | // Spaces 28 | 'semi': [ 29 | 'error', 30 | 'always' 31 | ], 32 | 'no-trailing-spaces': [ 33 | 'error', { 34 | 'ignoreComments': true 35 | } 36 | ], 37 | 'space-before-function-paren': [ 38 | 'error', { 39 | 'anonymous': 'always', 40 | 'named': 'never', 41 | 'asyncArrow': 'always' 42 | } 43 | ], 44 | 'space-in-parens': [ 45 | 'error', 'always' 46 | ], 47 | 'space-before-blocks': 'error', 48 | 'no-whitespace-before-property': 'error', 49 | 'newline-before-return': 'error', 50 | 'no-multi-spaces': 'error', 51 | 'arrow-parens': [ 'error', 'always' ], 52 | 'array-bracket-spacing': [ 'error', 'always' ], 53 | 'arrow-spacing': 'error', 54 | 'lines-between-class-members': [ 'error', 'always', { 'exceptAfterSingleLine': true } ], 55 | '@typescript-eslint/lines-between-class-members': [ 'error', 'always', { 'exceptAfterSingleLine': true } ], 56 | 57 | // Basics 58 | 'camelcase': 'error', 59 | 'no-var': 'error', 60 | 'prefer-const': 'error', 61 | 'eqeqeq': [ 'error', 'always' ], 62 | 'no-return-assign': 'error', 63 | 'no-return-await': 'error', 64 | 'no-throw-literal': 'error', 65 | 'new-cap': [ 'error', { 'newIsCap': true } ], 66 | 'no-unneeded-ternary': 'error', 67 | 'no-template-curly-in-string': 'error', 68 | 'template-curly-spacing': 'error', 69 | 'curly': [ 'error', 'all' ], 70 | 'padded-blocks': [ 'error', 'always' ], 71 | 'no-underscore-dangle': ["error", { "allowAfterThis": true }], 72 | 'import/extensions':'off', 73 | 'no-plusplus': 'off', 74 | 'import/prefer-default-export': 'off', 75 | 'no-mixed-operators': 'off', 76 | 'no-param-reassign': 'off', 77 | 'import/no-named-as-default': 'off', 78 | 'import/no-extraneous-dependencies': 'off', 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /.github/workflows/public.yml: -------------------------------------------------------------------------------- 1 | name: NPM Package Publish 2 | on: 3 | release: 4 | types: [created] 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: '16.x' 14 | registry-url: 'https://registry.npmjs.org' 15 | - run: npm ci 16 | - run: npm run build 17 | - run: | 18 | npm version ${{ github.event.release.tag_name }} --no-git-tag-version --allow-same-version && \ 19 | npm publish --access public 20 | env: 21 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 22 | slackNotification: 23 | name: Slack Notification 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Slack Notification 28 | uses: rtCamp/action-slack-notify@v2 29 | env: 30 | SLACK_CHANNEL: frontend 31 | SLACK_COLOR: ${{ job.status }} 32 | SLACK_ICON: https://raw.githubusercontent.com/birdwingo/react-native-spinning-numbers/main/src/assets/images/logo.png 33 | SLACK_MESSAGE: Publish Release ${{ github.event.release.tag_name }} ${{ job.status == 'success' && 'has been successful' || 'has been failed' }} 34 | SLACK_TITLE: 'Spinning numbers publish release :rocket:' 35 | SLACK_USERNAME: NPM 36 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Github Release 2 | on: 3 | pull_request: 4 | types: [closed] 5 | branch: main 6 | jobs: 7 | release: 8 | if: github.event.pull_request.merged == true 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | with: 13 | token: ${{ secrets.AUTH_TOKEN }} 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: '16.x' 17 | registry-url: 'https://registry.npmjs.org' 18 | - run: npm ci 19 | - name: Set up git user for release 20 | run: | 21 | git config --global user.email "actions@github.com" 22 | git config --global user.name "GitHub Actions" 23 | - run: npm run release 24 | - name: Push changes 25 | run: git push --follow-tags origin main 26 | - run: npm run build 27 | - run: npm run test 28 | - name: Get version from package-lock.json 29 | id: get_version 30 | run: echo "::set-output name=version::$(node -p "require('./package-lock.json').version")" 31 | - name: Get changelog 32 | id: get_changelog 33 | run: | 34 | CHANGELOG=$(awk '/^### \[[0-9]+\.[0-9]+\.[0-9]+\]/{if (version!="") {exit}; version=$2} version!="" {print}' CHANGELOG.md) 35 | echo "::set-output name=changelog::${CHANGELOG}" 36 | - name: Create Release 37 | uses: actions/create-release@master 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.AUTH_TOKEN }} 40 | with: 41 | tag_name: "v${{ steps.get_version.outputs.version }}" 42 | release_name: "v${{ steps.get_version.outputs.version }}" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /coverage -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### 1.0.14 (2025-03-31) 6 | 7 | ### 1.0.13 (2025-02-01) 8 | 9 | ### 1.0.12 (2024-08-01) 10 | 11 | ### 1.0.11 (2024-07-30) 12 | 13 | ### 1.0.10 (2024-07-30) 14 | 15 | ### 1.0.9 (2024-03-01) 16 | 17 | ### 1.0.8 (2023-12-27) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * translateX is NaN ([7a4c875](https://github.com/birdwingo/react-native-spinning-numbers/commit/7a4c8754e85769eeb886e1977ffa2f15e79be238)) 23 | 24 | ### 1.0.7 (2023-12-27) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * translateX is NaN ([148d27d](https://github.com/birdwingo/react-native-spinning-numbers/commit/148d27d73e0bb1ff1e8ac50f5ca8126a9c3b1bc8)) 30 | 31 | ### 1.0.6 (2023-12-27) 32 | 33 | ### 1.0.5 (2023-08-21) 34 | 35 | 36 | ### Bug Fixes 37 | 38 | * auto measure ([6378466](https://github.com/birdwingo/react-native-spinning-numbers/commit/6378466f7257f83a770fa1b55215577b790286b4)) 39 | 40 | ### 1.0.4 (2023-08-16) 41 | 42 | ### 1.0.3 (2023-08-16) 43 | 44 | 45 | ### Bug Fixes 46 | 47 | * allowFontScaling to false ([55afb86](https://github.com/birdwingo/react-native-spinning-numbers/commit/55afb8691fadcd540c0c9902893650d4f2ac21dd)) 48 | 49 | ### 1.0.2 (2023-08-16) 50 | 51 | 52 | ### Bug Fixes 53 | 54 | * dev dependencies ([8646dfc](https://github.com/birdwingo/react-native-spinning-numbers/commit/8646dfccc87134f54035ad7d0ce629b677f29571)) 55 | 56 | ### 1.0.1 (2023-08-16) 57 | 58 | 59 | ### Bug Fixes 60 | 61 | * prefix changed to suffix ([f7e82ee](https://github.com/birdwingo/react-native-spinning-numbers/commit/f7e82ee6ff33d6912ebea15c157377ac5ecf92e7)) 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Birdwingo 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 | # @birdwingo/react-native-spinning-numbers 2 | 3 | ![npm downloads](https://img.shields.io/npm/dm/%40birdwingo/react-native-spinning-numbers) 4 | ![npm version](https://img.shields.io/npm/v/%40birdwingo/react-native-spinning-numbers) 5 | ![github release](https://github.com/birdwingo/react-native-spinning-numbers/actions/workflows/release.yml/badge.svg?event=pull_request) 6 | ![npm release](https://github.com/birdwingo/react-native-spinning-numbers/actions/workflows/public.yml/badge.svg?event=release) 7 | 8 | ## Features 🌟 9 | 10 | 🌀 Captivating Spinning Effect: Engage your users with an eye-catching visual experience by employing the spinning numbers animation! Perfect for displaying scores, statistics, and dynamic values. 11 | 12 | 🔢 Customizable Numbers: Tailor the appearance, size, and color of the numbers to match your app's theme. Endless possibilities at your fingertips! 13 | 14 | 🎛️ Dynamic Control: Effortlessly change the values on the fly through simple props. Sync the numbers with real-time data and keep your UI lively and interactive. 15 | 16 | 📱 Cross-Platform Support: Designed with React Native, this component works seamlessly across different platforms, offering a consistent experience on both iOS and Android devices. 17 | 18 | 🎨 Easy Styling: Apply your preferred styles with ease through well-structured customization options. Let your creativity flow and create a unique look! 19 | 20 | ⚙️ Performance Optimized: Efficiently built to minimize performance impact, ensuring a smooth experience even with frequent value changes. 21 | 22 | 🧰 Quick Integration: Get up and running in no time with comprehensive documentation and examples. Integrating spinning numbers into your app has never been easier! 23 | 24 | 🌟 Open Source and Community-Driven: Contributions are welcome! Join the community and help shape the future of this exciting component. 25 | 26 | ## About 27 | 28 | `react-native-spinning-numbers` is a customizable and animated component that offers a highly captivating way to display numerical values within your React Native application. This component combines an elegant visual effect of rotating numbers with the ability to dynamically change values, adding interactivity and intrigue to your user interfaces. It is used in the [Birdwingo mobile app](https://www.birdwingo.com) to show portfolio values and market prices changing in the real time. 29 | 30 | 31 | 32 | ## Installation 33 | 34 | ```bash 35 | npm install react-native-reanimated 36 | npm install @birdwingo/react-native-spinning-numbers 37 | ``` 38 | 39 | ## Usage 40 | 41 | To use the `SpinningNumbers` component, you need to import it in your React Native application and include it in your JSX code. Here's an example of how to use it: 42 | 43 | ```jsx 44 | import React, { useState } from 'react'; 45 | import { View, Text } from 'react-native'; 46 | import SpinningNumbers from '@birdwingo/react-native-spinning-numbers'; 47 | 48 | const YourComponent = () => { 49 | 50 | const [ value, setValue ] = useState( '$1478.78' ); 51 | 52 | return ( 53 | 59 | {value} 60 | 61 | ); 62 | 63 | }; 64 | 65 | export default YourComponent; 66 | ``` 67 | 68 | ## Props 69 | 70 | Name | Type | Default value | Description 71 | -------------------------|-------------------------------|-------------------------|--------------------- 72 | `children` | string | **required** | Text to be rendered 73 | `style` | ViewStyle & TextStyle | | Styles to be applied to text and container 74 | `duration` | number | 1000 | Duration of the animation in ms 75 | `parentheses` | boolean | false | Whether to show parentheses around text 76 | `extendCharacters` | string | '' | A string of characters that could appear in your text. You do not need to use it if your text contains only basic alphabetic characters, numbers or the following characters: `,.-+$%€!?&@#*` 77 | `deps` | any[] | [] | An array of dependencies for useEffect, when those dependencies changed, text will be changed without animation 78 | `autoMeasure` | boolean | false | Whether to auto measure text characters on first render 79 | 80 | ## Sponsor 81 | 82 | **react-native-spinning-numbers** is sponsored by [Birdwingo](https://www.birdwingo.com).\ 83 | Download Birdwingo mobile app to see react-native-spinning-numbers in action! -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | presets: ['module:metro-react-native-babel-preset'], 4 | plugins: ['react-native-reanimated/plugin'], 5 | }; 6 | 7 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {extends: ['@commitlint/config-conventional']} 2 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | 2 | jest.mock('react-native-reanimated', () => { 3 | 4 | const View = require('react-native').View; 5 | const Text = require('react-native').Text; 6 | 7 | return { 8 | Value: jest.fn(), 9 | event: jest.fn(), 10 | add: jest.fn(), 11 | eq: jest.fn(), 12 | set: jest.fn(), 13 | cond: jest.fn(), 14 | interpolate: jest.fn(), 15 | View: (props) => , 16 | Text: (props) => , 17 | createAnimatedComponent: (cb) => cb, 18 | Extrapolate: { CLAMP: jest.fn() }, 19 | Transition: { 20 | Together: 'Together', 21 | Out: 'Out', 22 | In: 'In', 23 | }, 24 | useSharedValue: jest.fn(), 25 | useDerivedValue: (a) => ({ value: a() }), 26 | useAnimatedScrollHandler: () => () => {}, 27 | useAnimatedGestureHandler: () => () => {}, 28 | useAnimatedStyle: (cb) => cb(), 29 | useAnimatedRef: () => ({ current: null }), 30 | useAnimatedReaction: (value, cb) => cb(value(), ''), 31 | useAnimatedProps: (cb) => cb(), 32 | withTiming: (toValue, _, cb) => { 33 | cb && cb(true); 34 | return toValue; 35 | }, 36 | withSpring: (toValue, _, cb) => { 37 | cb && cb(true); 38 | return toValue; 39 | }, 40 | withDecay: (_, cb) => { 41 | cb && cb(true); 42 | return 0; 43 | }, 44 | withDelay: (_, animationValue) => { 45 | return animationValue; 46 | }, 47 | withSequence: (..._animations) => { 48 | return 0; 49 | }, 50 | withRepeat: (animation, _, __, cb) => { 51 | cb(); 52 | return animation; 53 | }, 54 | cancelAnimation: () => {}, 55 | measure: () => ({ 56 | x: 0, 57 | y: 0, 58 | width: 0, 59 | height: 0, 60 | pageX: 0, 61 | pageY: 0, 62 | }), 63 | Easing: { 64 | linear: (cb) => cb(), 65 | ease: (cb) => cb(), 66 | quad: (cb) => cb(), 67 | cubic: (cb) => cb(), 68 | poly: (cb) => cb(), 69 | sin: (cb) => cb(), 70 | circle: (cb) => cb(), 71 | exp: (cb) => cb(), 72 | elastic: (cb) => cb(), 73 | back: (cb) => cb(), 74 | bounce: (cb) => cb(), 75 | bezier: () => ({ factory: (cb) => cb() }), 76 | bezierFn: (cb) => cb(), 77 | steps: (cb) => cb(), 78 | in: (cb) => cb(), 79 | out: (cb) => cb(), 80 | inOut: (cb) => cb(), 81 | }, 82 | Extrapolation: { 83 | EXTEND: 'extend', 84 | CLAMP: 'clamp', 85 | IDENTITY: 'identity', 86 | }, 87 | runOnJS: (fn) => fn, 88 | runOnUI: (fn) => fn, 89 | }; 90 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@birdwingo/react-native-spinning-numbers", 3 | "version": "1.0.14", 4 | "description": "The component offers a highly customizable and captivating way to display numerical values within your React Native application. This component combines an elegant visual effect of rotating numbers with the ability to dynamically change values, adding interactivity and intrigue to your user interfaces.", 5 | "main": "src/index.tsx", 6 | "source": "src/index.tsx", 7 | "scripts": { 8 | "build": "tsc -p tsconfig.json", 9 | "release": "standard-version", 10 | "test": "jest --coverage" 11 | }, 12 | "repository": "https://github.com/birdwingo/react-native-spinning-numbers.git", 13 | "keywords": [ 14 | "react-native", 15 | "android", 16 | "ios", 17 | "react", 18 | "react-native-reanimated", 19 | "reanimated", 20 | "animated", 21 | "animation", 22 | "performance", 23 | "numbers", 24 | "spinning", 25 | "spinning-numbers", 26 | "animated-numbers" 27 | ], 28 | "author": "", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/birdwingo/react-native-spinning-numbers/issues" 32 | }, 33 | "homepage": "https://github.com/birdwingo/react-native-spinning-numbers#readme", 34 | "peerDependencies": { 35 | "react": ">=18.0.0", 36 | "react-native": ">=0.70.0", 37 | "react-native-reanimated": ">=2.12.0" 38 | }, 39 | "devDependencies": { 40 | "@babel/plugin-transform-named-capturing-groups-regex": "^7.20.0", 41 | "@babel/preset-env": "^7.22.9", 42 | "@babel/preset-typescript": "^7.22.5", 43 | "@commitlint/cli": "^17.6.7", 44 | "@commitlint/config-conventional": "^17.6.7", 45 | "@testing-library/jest-native": "^5.4.2", 46 | "@testing-library/react-native": "^12.1.3", 47 | "@tsconfig/react-native": "^3.0.0", 48 | "@types/jest": "^29.5.3", 49 | "@types/react": "^18.2.16", 50 | "eslint": "^8.19.0", 51 | "eslint-config-airbnb": "^19.0.2", 52 | "eslint-config-airbnb-typescript": "^16.1.0", 53 | "husky": "^8.0.3", 54 | "jest": "^29.6.2", 55 | "react-test-renderer": "^18.2.0", 56 | "standard-version": "^9.5.0", 57 | "typescript": "^5.1.6" 58 | }, 59 | "jest": { 60 | "preset": "react-native", 61 | "moduleFileExtensions": [ 62 | "ts", 63 | "tsx", 64 | "js", 65 | "jsx", 66 | "json", 67 | "node" 68 | ], 69 | "testMatch": [ 70 | "/tests/*.test.js" 71 | ], 72 | "setupFilesAfterEnv": [ 73 | "@testing-library/jest-native/extend-expect", 74 | "/jest.setup.js" 75 | ], 76 | "verbose": true 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/assets/images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/birdwingo/react-native-spinning-numbers/b05af5c40c417dc984aab3f996d4950e29fcafe1/src/assets/images/demo.gif -------------------------------------------------------------------------------- /src/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/birdwingo/react-native-spinning-numbers/b05af5c40c417dc984aab3f996d4950e29fcafe1/src/assets/images/logo.png -------------------------------------------------------------------------------- /src/components/Animated/Animated.styles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | 3 | export default StyleSheet.create( { 4 | sign: { 5 | position: 'absolute', 6 | textAlign: 'center', 7 | }, 8 | text: { 9 | overflow: 'visible', 10 | position: 'absolute', 11 | }, 12 | separator: { 13 | position: 'absolute', 14 | overflow: 'hidden', 15 | textAlign: 'center', 16 | }, 17 | measureContainer: { 18 | width: 0, 19 | height: 0, 20 | overflow: 'hidden', 21 | }, 22 | measureText: { 23 | position: 'absolute', 24 | top: 0, 25 | left: 0, 26 | }, 27 | overflowVisible: { 28 | overflow: 'visible', 29 | }, 30 | } ); 31 | -------------------------------------------------------------------------------- /src/components/Animated/index.tsx: -------------------------------------------------------------------------------- 1 | import AnimatedNumber from './number'; 2 | import AnimatedSign from './sign'; 3 | import AnimatedText from './text'; 4 | import AnimatedSeparator from './separator'; 5 | 6 | export { 7 | AnimatedNumber, 8 | AnimatedSign, 9 | AnimatedText, 10 | AnimatedSeparator, 11 | }; 12 | -------------------------------------------------------------------------------- /src/components/Animated/number.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect, memo } from 'react'; 2 | import Animated, { 3 | cancelAnimation, Easing, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming, 4 | } from 'react-native-reanimated'; 5 | import { TextMeasurment } from '../TextMeasurment'; 6 | import { NUMBERS } from '../../core/constants'; 7 | import { AnimatedNumberProps } from '../../core/dto/animatedDTO'; 8 | import AnimatedStyles from './Animated.styles'; 9 | 10 | const AnimatedNumber: FC = ( { 11 | from = 0, to = 0, exponent = 0, style, duration, 12 | } ) => { 13 | 14 | const width = Math.ceil( TextMeasurment.get( '0', style ).width * 2 ); 15 | const animated = useSharedValue( from ); 16 | 17 | const measurements = NUMBERS.split( '\n' ).map( ( n ) => TextMeasurment.get( n, style ).width ); 18 | 19 | const chartWidth = useDerivedValue( () => { 20 | 21 | const offset = Math.min( 0, exponent ); 22 | const showzero = exponent <= 0 || Math.floor( animated.value / ( 10 ** exponent ) ) >= 10; 23 | const val = animated.value / ( 10 ** offset ) 24 | % ( 10 ** ( exponent - offset + 1 ) ) / ( 10 ** ( exponent - offset ) ); 25 | const index = ( showzero && val < 1 ? 1 : 11 ) - val; 26 | const roundedFloor = Math.floor( index ); 27 | const roundedCeil = Math.ceil( index ); 28 | 29 | return { 30 | width: index === Math.round( index ) 31 | ? ( measurements[index] ) 32 | : ( measurements[roundedCeil] || 0 ) * ( index - roundedFloor ) 33 | + ( measurements[roundedFloor] || 0 ) * ( roundedCeil - index ), 34 | index, 35 | }; 36 | 37 | } ); 38 | 39 | const animatedStyles = useAnimatedStyle( () => ( { 40 | width: withTiming( width, { duration: 200 } ), 41 | transform: [ 42 | { translateY: ( -chartWidth.value.index * ( style.lineHeight ?? 0 ) ) || 0 }, 43 | { translateX: ( ( chartWidth.value.width - width ) / 2 ) || 0 } ], 44 | } ) ); 45 | 46 | const animatedStyles2 = useAnimatedStyle( () => ( { 47 | width: chartWidth.value.width, 48 | } ) ); 49 | 50 | useEffect( () => { 51 | 52 | const offset = 10 ** exponent; 53 | 54 | cancelAnimation( animated ); 55 | animated.value = withTiming( 56 | Math.floor( to / offset ) * offset, 57 | { duration, easing: Easing.bezier( 0.42, 0, 0.58, 1 ) }, 58 | ); 59 | 60 | }, [ from, to, duration ] ); 61 | 62 | return ( 63 | 64 | 68 | {NUMBERS} 69 | 70 | 71 | ); 72 | 73 | }; 74 | 75 | export default memo( AnimatedNumber ); 76 | -------------------------------------------------------------------------------- /src/components/Animated/separator.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, memo } from 'react'; 2 | import { View } from 'react-native'; 3 | import Animated, { useSharedValue, useAnimatedStyle } from 'react-native-reanimated'; 4 | import { TextMeasurment } from '../TextMeasurment'; 5 | import { AnimatedSeparatorProps } from '../../core/dto/animatedDTO'; 6 | import AnimatedStyles from './Animated.styles'; 7 | 8 | const AnimatedSeparator: FC = ( { separator, style } ) => { 9 | 10 | const index = useSharedValue( 0 ); 11 | 12 | const animatedStyles = useAnimatedStyle( () => ( { 13 | transform: [ { translateY: -index.value * ( style.lineHeight ?? 0 ) } ], 14 | } ) ); 15 | 16 | return ( 17 | 23 | 32 | {separator} 33 | 34 | 35 | ); 36 | 37 | }; 38 | 39 | export default memo( AnimatedSeparator ); 40 | -------------------------------------------------------------------------------- /src/components/Animated/sign.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect, memo } from 'react'; 2 | import Animated, { 3 | cancelAnimation, Easing, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming, 4 | } from 'react-native-reanimated'; 5 | import { TextMeasurment } from '../TextMeasurment'; 6 | import { SIGNS } from '../../core/constants'; 7 | import { AnimatedSignProps } from '../../core/dto/animatedDTO'; 8 | import AnimatedStyles from './Animated.styles'; 9 | 10 | const AnimatedSign: FC = ( { 11 | from, to, style, duration, 12 | } ) => { 13 | 14 | const width = Math.ceil( TextMeasurment.get( from, style ).width * 2 ); 15 | const animated = useSharedValue( 0 ); 16 | const from2 = useSharedValue( from ); 17 | const to2 = useSharedValue( to ); 18 | 19 | const measurements = SIGNS.split( '\n' ).map( ( n ) => TextMeasurment.get( n, style ).width ); 20 | 21 | const index = useDerivedValue( () => { 22 | 23 | const values: { [key: string]: number } = { '+': 0, '-': 1 }; 24 | 25 | const start = values[from2.value] ?? 2; 26 | const end = values[to2.value] ?? 2; 27 | 28 | return start + ( end - start ) * animated.value; 29 | 30 | }, [ animated ] ); 31 | 32 | const chartWidth = useDerivedValue( () => { 33 | 34 | const roundedFloor = Math.floor( index.value ); 35 | const roundedCeil = Math.ceil( index.value ); 36 | 37 | return index.value === Math.round( index.value ) 38 | ? ( measurements[index.value] ) 39 | : ( measurements[roundedCeil] || 0 ) * ( index.value - roundedFloor ) 40 | + ( measurements[roundedFloor] ) * ( roundedCeil - index.value ); 41 | 42 | } ); 43 | 44 | const animatedStyles = useAnimatedStyle( () => ( { 45 | transform: [ 46 | { translateY: ( -index.value * ( style.lineHeight ?? 0 ) ) || 0 }, 47 | { translateX: ( ( chartWidth.value - width ) / 2 ) || 0 } ], 48 | } ) ); 49 | 50 | const animatedStyles2 = useAnimatedStyle( () => ( { 51 | width: chartWidth.value, 52 | } ) ); 53 | 54 | useEffect( () => { 55 | 56 | if ( from !== to ) { 57 | 58 | cancelAnimation( animated ); 59 | animated.value = 0; 60 | animated.value = withTiming( 1, { duration, easing: Easing.bezier( 0.42, 0, 0.58, 1 ) } ); 61 | from2.value = from; 62 | to2.value = to; 63 | 64 | } 65 | 66 | }, [ from, to, duration ] ); 67 | 68 | return ( 69 | 70 | 74 | {SIGNS} 75 | 76 | 77 | ); 78 | 79 | }; 80 | 81 | export default memo( AnimatedSign ); 82 | -------------------------------------------------------------------------------- /src/components/Animated/text.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FC, useEffect, useState, memo, 3 | } from 'react'; 4 | import { View } from 'react-native'; 5 | import Animated, { 6 | cancelAnimation, useSharedValue, withTiming, useAnimatedStyle, 7 | } from 'react-native-reanimated'; 8 | import { TextMeasurment } from '../TextMeasurment'; 9 | import { AnimatedTextProps } from '../../core/dto/animatedDTO'; 10 | import AnimatedStyles from './Animated.styles'; 11 | 12 | const AnimatedText: FC = ( { 13 | from, to, align, duration, delay = 0, style, 14 | } ) => { 15 | 16 | const index = useSharedValue( 0 ); 17 | 18 | const [ previousText, setPreviousText ] = useState( from !== to ? to : '' ); 19 | const [ currentText, setCurrentText ] = useState( to ); 20 | 21 | const animatedStyles = useAnimatedStyle( () => ( 22 | { transform: [ { translateY: -index.value * ( style.lineHeight ?? 0 ) } ] } ) ); 23 | 24 | const animate = () => { 25 | 26 | cancelAnimation( index ); 27 | setTimeout( () => { 28 | 29 | index.value = withTiming( 1, { duration } ); 30 | 31 | }, delay ); 32 | 33 | }; 34 | 35 | useEffect( () => { 36 | 37 | if ( from !== to ) { 38 | 39 | setPreviousText( from ); 40 | setCurrentText( to ); 41 | 42 | animate(); 43 | 44 | } 45 | 46 | }, [ from, to, duration, delay ] ); 47 | 48 | const previousWidth = previousText.split( '' ).map( ( value ) => TextMeasurment.get( value, style )?.width ?? 0 ).reduce( ( a, b ) => a + b, 0 ); 49 | const currentWidth = currentText.split( '' ).map( ( value ) => TextMeasurment.get( value, style )?.width ?? 0 ).reduce( ( a, b ) => a + b, 0 ); 50 | 51 | return ( 52 | 59 | 68 | {[ previousText, currentText ].join( '\n' ).trim()} 69 | 70 | 71 | ); 72 | 73 | }; 74 | 75 | export default memo( AnimatedText ); 76 | -------------------------------------------------------------------------------- /src/components/SpinningNumbers/SpinningNumbers.styles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | 3 | export default StyleSheet.create( { 4 | container: { 5 | display: 'flex', 6 | flexDirection: 'row', 7 | overflow: 'hidden', 8 | }, 9 | } ); 10 | -------------------------------------------------------------------------------- /src/components/SpinningNumbers/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FC, ReactNode, useEffect, useState, memo, useRef, 3 | } from 'react'; 4 | import { Text, View } from 'react-native'; 5 | import { childrenToText, createNumericAnimation } from '../../core/helpers'; 6 | import { TextMeasurment } from '../TextMeasurment'; 7 | import { SpinningNumbersProps } from '../../core/dto/spinningNumbersDTO'; 8 | import SpinningNumbersStyles from './SpinningNumbers.styles'; 9 | import { 10 | AnimatedNumber, AnimatedSeparator, AnimatedSign, AnimatedText, 11 | } from '../Animated'; 12 | import { CHARS_TO_MEASURE, DURATION } from '../../core/constants'; 13 | 14 | const SpinningNumbers: FC = ( { 15 | children, style = {}, duration = DURATION, parentheses = false, extendCharacters = '', deps = [], autoMeasure = false, 16 | } ) => { 17 | 18 | const { 19 | color, fontFamily = '', fontSize = 34, fontStyle, fontWeight, 20 | fontVariant, letterSpacing, lineHeight = 40, textAlign, textShadowColor, 21 | textShadowOffset, textShadowRadius, textTransform, writingDirection, 22 | ...layoutStyles 23 | } = style; 24 | 25 | const textStyles = { 26 | color, 27 | fontFamily, 28 | fontSize, 29 | fontStyle, 30 | fontWeight, 31 | fontVariant, 32 | letterSpacing, 33 | lineHeight, 34 | textAlign, 35 | textShadowColor, 36 | textShadowOffset, 37 | textShadowRadius, 38 | textTransform, 39 | writingDirection, 40 | }; 41 | 42 | const [ animation, setAnimation ] = useState( createNumericAnimation( '', childrenToText( children ) ) ); 43 | 44 | const measured = useRef( false ); 45 | 46 | useEffect( () => { 47 | 48 | const currentAnimation = createNumericAnimation( 49 | animation.text.current, 50 | childrenToText( children ), 51 | ); 52 | 53 | if ( currentAnimation.changed ) { 54 | 55 | setAnimation( currentAnimation ); 56 | 57 | } 58 | 59 | }, [ children, duration ] ); 60 | 61 | useEffect( () => { 62 | 63 | setAnimation( createNumericAnimation( '', childrenToText( children ) ) ); 64 | 65 | }, deps ); 66 | 67 | useEffect( () => { 68 | 69 | measured.current = false; 70 | 71 | }, [ fontSize, fontFamily, fontWeight ] ); 72 | 73 | const measurementsToRender: ReactNode[] = []; 74 | 75 | if ( ( animation.animable || autoMeasure ) && !measured.current ) { 76 | 77 | Promise.all( `${CHARS_TO_MEASURE}${extendCharacters}`.split( '' ).map( ( c ) => TextMeasurment.measure( c, textStyles, measurementsToRender ) ) ).then( () => { 78 | 79 | measured.current = true; 80 | 81 | } ); 82 | 83 | } 84 | 85 | return ( 86 | 87 | {parentheses && (} 88 | { animation.animable && measured.current 89 | ? ( 90 | <> 91 | { animation.presign && ( 92 | 98 | )} 99 | { animation.prefix && ( 100 | 107 | )} 108 | { animation.sign && ( 109 | 115 | )} 116 | { animation.pattern?.map( ( p, id ) => ( p.separator 117 | ? 118 | : ) )} 119 | { animation.suffix && } 120 | 121 | ) 122 | : { animation.text.current }} 123 | {parentheses && )} 124 | { measurementsToRender } 125 | 126 | ); 127 | 128 | }; 129 | 130 | export default memo( SpinningNumbers ); 131 | -------------------------------------------------------------------------------- /src/components/TextMeasurment/TextMeasurment.styles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | 3 | export default StyleSheet.create( { 4 | measureContainer: { 5 | width: 0, 6 | height: 0, 7 | overflow: 'hidden', 8 | }, 9 | measureText: { 10 | position: 'absolute', 11 | top: 0, 12 | left: 0, 13 | }, 14 | } ); 15 | -------------------------------------------------------------------------------- /src/components/TextMeasurment/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import { 3 | TextStyle, View, Text, LayoutChangeEvent, 4 | } from 'react-native'; 5 | import { fontHash } from '../../core/helpers'; 6 | import TextMeasurmentStyles from './TextMeasurment.styles'; 7 | 8 | export class TextMeasurment { 9 | 10 | static cache = new Map(); 11 | 12 | static get( text: string, style: TextStyle ) { 13 | 14 | if ( !text.length ) { 15 | 16 | return { text, width: 0, height: 20 }; 17 | 18 | } 19 | 20 | return this.cache.get( fontHash( style ) ).get( text ); 21 | 22 | } 23 | 24 | static async measure( text: string, style: TextStyle, toRender: ReactNode[] ) { 25 | 26 | if ( !text.length ) { 27 | 28 | return { text, width: 0, height: 20 }; 29 | 30 | } 31 | 32 | const hash = fontHash( style ); 33 | 34 | if ( !this.cache.get( hash )?.has( text ) ) { 35 | 36 | if ( !this.cache.has( hash ) ) { 37 | 38 | this.cache.set( hash, new Map() ); 39 | 40 | } 41 | 42 | let promiseResolve: ( val: any ) => void; 43 | let measurement: { cnt: number, width: number, height: number }; 44 | 45 | const onLayout = ( e: LayoutChangeEvent, cnt: number ) => { 46 | 47 | const { width, height } = e.nativeEvent.layout; 48 | 49 | if ( !measurement ) { 50 | 51 | measurement = { cnt, width, height }; 52 | 53 | } else { 54 | 55 | const [ m1, m2 ] = [ measurement, { cnt, width, height } ]; 56 | 57 | const value = { 58 | text, 59 | width: ( m1.width - m2.width ) / ( m1.cnt - m2.cnt ) - 0.20, 60 | height: Math.max( m1.height, m2.height ), 61 | }; 62 | 63 | this.cache.get( hash ).set( text, value ); 64 | 65 | promiseResolve?.( value ); 66 | 67 | } 68 | 69 | }; 70 | 71 | toRender.push( 72 | 73 | onLayout( e, 1 )} 75 | testID={`${text}MeasureText`} 76 | style={[ style, TextMeasurmentStyles.measureText ]} 77 | > 78 | {text.repeat( 1 )} 79 | 80 | onLayout( e, 5 )} 82 | testID={`${text}MeasureText5`} 83 | style={[ style, TextMeasurmentStyles.measureText ]} 84 | > 85 | {text.repeat( 5 )} 86 | 87 | , 88 | ); 89 | 90 | return new Promise( ( resolve ) => { 91 | 92 | promiseResolve = resolve; 93 | 94 | } ); 95 | 96 | } 97 | 98 | return this.cache.get( hash ).get( text ); 99 | 100 | } 101 | 102 | } 103 | -------------------------------------------------------------------------------- /src/core/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const NUMBERS = [ 1, 0, 9, 8, 7, 6, 5, 4, 3, 2, 1 ].join( '\n' ); 2 | export const SIGNS = [ '+', '-', '' ].join( '\n' ); 3 | export const NUMBER_RE = /^[0-9]+(\.[0-9]+)?$/; 4 | export const NUMERIC_RE = /^(?[+-]? *)(?[^0-9,.]*)(?[+-]? *)(?[0-9]([0-9,. ]*?[0-9])*?)(?[^0-9,.]*)$/; 5 | export const DECIMAL_SEP_RE = /[0-9](?[,.])[0-9]+$/; 6 | export const THOUSANDS_SEP_RE = /[0-9](?[,. ])[0-9]{3}/; 7 | export const DECIMALS_RE = /^[0-9]+/; 8 | export const FRACTIONS_RE = /(\.[0-9]+)?$/; 9 | 10 | export const CHARS_TO_MEASURE = 'aáäbcčdďeéfghiíjklľĺmnňoóôpqrŕsštťuúvwxyýzžAÁÄBCČDĎEÉFGHIÍJKLĽĹMNŇOÓÔPQRŔSŠTŤUÚVWXYÝZŽ0123456789 ,.-+$%€!?&@#*'; 11 | export const DURATION = 1000; 12 | -------------------------------------------------------------------------------- /src/core/dto/animatedDTO.ts: -------------------------------------------------------------------------------- 1 | import { TextStyle } from 'react-native'; 2 | 3 | export interface AnimatedNumberProps { 4 | from?: number, 5 | to?: number, 6 | exponent?: number, 7 | style: TextStyle, 8 | duration: number, 9 | } 10 | 11 | export interface AnimatedSeparatorProps { 12 | separator: string, 13 | style: TextStyle, 14 | } 15 | 16 | export interface AnimatedSignProps { 17 | from: string, 18 | to: string, 19 | style: TextStyle, 20 | duration: number, 21 | } 22 | 23 | export interface AnimatedTextProps { 24 | from: string, 25 | to: string, 26 | align?: 'left' | 'right' | 'center', 27 | duration: number, 28 | delay?: number, 29 | style: TextStyle, 30 | } 31 | -------------------------------------------------------------------------------- /src/core/dto/helpersDTO.ts: -------------------------------------------------------------------------------- 1 | export interface AnimationProps { 2 | text: { previous: string, current: string }, 3 | changed: boolean, 4 | animable: boolean, 5 | separator?: { decimal?: string, thousands?: string }, 6 | presign?: { from: string, to: string } 7 | prefix?: { from: string, to: string } 8 | suffix?: { from: string, to: string } 9 | sign?: { from: string, to: string } 10 | value?: { from: number, to: number } 11 | decimals?: number, 12 | fractions?: number, 13 | pattern?: { separator?: string, exponent?: number }[] 14 | } 15 | -------------------------------------------------------------------------------- /src/core/dto/spinningNumbersDTO.ts: -------------------------------------------------------------------------------- 1 | import { TextStyle, ViewProps } from 'react-native'; 2 | 3 | export interface SpinningNumbersProps { 4 | children: string, 5 | style?: ViewProps & TextStyle 6 | duration?: number, 7 | parentheses?: boolean, 8 | extendCharacters?: string, 9 | deps?: any[], 10 | autoMeasure?: boolean, 11 | } 12 | -------------------------------------------------------------------------------- /src/core/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { Children } from 'react'; 2 | import { TextStyle } from 'react-native'; 3 | import { 4 | DECIMALS_RE, DECIMAL_SEP_RE, FRACTIONS_RE, NUMBER_RE, NUMERIC_RE, THOUSANDS_SEP_RE, 5 | } from '../constants'; 6 | import { AnimationProps } from '../dto/helpersDTO'; 7 | 8 | export const fontHash = ( style: TextStyle ) => `${style.fontFamily}:${style.fontSize}:${style.fontWeight}`; 9 | 10 | export const decimals = ( val: string ) => val.match( DECIMALS_RE )?.[0]?.length || 0; 11 | 12 | export const fractions = ( val: string ) => Math.max( 13 | 0, 14 | ( val.match( FRACTIONS_RE )?.[0]?.length || 0 ) - 1, 15 | ); 16 | 17 | export const countChars = ( str: string, char: string ) => str.length - str.replaceAll( char, '' ).length; 18 | 19 | export const childrenToText = ( children: string ) => Children.map( children, ( c ) => c.toString() ).reduce( ( s, c ) => s + c, '' ); 20 | 21 | export const createNumericAnimation = ( previous: string, current: string ) => { 22 | 23 | const animation: AnimationProps = { 24 | text: { previous: previous || '', current: current || '' }, 25 | changed: previous !== current, 26 | animable: false, 27 | }; 28 | 29 | if ( previous && current && previous !== current ) { 30 | 31 | animation.changed = true; 32 | 33 | const format = { 34 | previous: previous.match( NUMERIC_RE )?.groups, 35 | current: current.match( NUMERIC_RE )?.groups, 36 | }; 37 | 38 | if ( format.previous && format.current ) { 39 | 40 | animation.animable = true; 41 | 42 | const separator = { 43 | decimal: { 44 | from: format.previous.value.match( DECIMAL_SEP_RE )?.groups?.separator, 45 | to: format.current.value.match( DECIMAL_SEP_RE )?.groups?.separator, 46 | }, 47 | thousands: { 48 | from: format.previous.value.match( THOUSANDS_SEP_RE )?.groups?.separator, 49 | to: format.current.value.match( THOUSANDS_SEP_RE )?.groups?.separator, 50 | }, 51 | }; 52 | const dec = separator.decimal; 53 | const ths = separator.thousands; 54 | 55 | let decimalSeparator = dec.from ?? dec.to; 56 | let thousandsSeparator = ths.from ?? ths.to; 57 | 58 | if ( dec.from !== dec.to ) { 59 | 60 | decimalSeparator = undefined; 61 | animation.animable = true; 62 | 63 | } else if ( ths.from !== ths.to ) { 64 | 65 | if ( thousandsSeparator === decimalSeparator ) { 66 | 67 | thousandsSeparator = undefined; 68 | 69 | } 70 | 71 | } else if ( decimalSeparator === thousandsSeparator && decimalSeparator !== undefined ) { 72 | 73 | decimalSeparator = undefined; 74 | 75 | } 76 | 77 | if ( animation.animable ) { 78 | 79 | if ( decimalSeparator && ( 80 | countChars( format.previous.value, decimalSeparator ) > 1 81 | || countChars( format.current.value, decimalSeparator ) > 1 82 | ) ) { 83 | 84 | animation.animable = false; 85 | 86 | } 87 | 88 | } 89 | 90 | if ( animation.animable ) { 91 | 92 | const previousValue = format.previous.value.replaceAll( thousandsSeparator!, '' ).replace( decimalSeparator!, '.' ); 93 | const currentValue = format.current.value.replaceAll( thousandsSeparator!, '' ).replace( decimalSeparator!, '.' ); 94 | 95 | if ( NUMBER_RE.test( previousValue ) && NUMBER_RE.test( currentValue ) ) { 96 | 97 | animation.separator = { decimal: decimalSeparator, thousands: thousandsSeparator }; 98 | animation.presign = { from: format.previous.presign, to: format.current.presign }; 99 | animation.prefix = { from: format.previous.prefix, to: format.current.prefix }; 100 | animation.suffix = { from: format.previous.suffix, to: format.current.suffix }; 101 | animation.sign = { from: format.previous.sign, to: format.current.sign }; 102 | animation.value = { 103 | from: ( format.previous.sign.includes( '-' ) || format.previous.presign.includes( '-' ) ? -1 : 1 ) * parseFloat( previousValue ), 104 | to: ( format.current.sign.includes( '-' ) || format.current.presign.includes( '-' ) ? -1 : 1 ) * parseFloat( currentValue ), 105 | }; 106 | animation.decimals = Math.max( decimals( previousValue ), decimals( currentValue ) ); 107 | animation.fractions = fractions( currentValue ); 108 | animation.pattern = []; 109 | 110 | for ( let i = 0; i < animation.decimals; ++i ) { 111 | 112 | if ( ( i && i % 3 === 0 ) ) { 113 | 114 | animation.pattern.unshift( { separator: thousandsSeparator, exponent: i } ); 115 | 116 | } 117 | 118 | animation.pattern.unshift( { exponent: i } ); 119 | 120 | } 121 | 122 | if ( animation.fractions ) { 123 | 124 | animation.pattern.push( { separator: decimalSeparator, exponent: 0 } ); 125 | 126 | for ( let i = 0; i < animation.fractions; ++i ) { 127 | 128 | animation.pattern.push( { exponent: -1 - i } ); 129 | 130 | } 131 | 132 | } 133 | 134 | if ( animation.prefix.from !== animation.prefix.to 135 | || animation.suffix.from !== animation.suffix.to ) { 136 | 137 | animation.animable = false; 138 | 139 | } 140 | 141 | } else { 142 | 143 | animation.animable = false; 144 | 145 | } 146 | 147 | } 148 | 149 | } 150 | 151 | } 152 | 153 | return animation; 154 | 155 | }; 156 | -------------------------------------------------------------------------------- /src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png'; 2 | declare module '*.gif'; 3 | 4 | declare interface Keyframe { 5 | composite?: 'accumulate' | 'add' | 'auto' | 'replace'; 6 | easing?: string; 7 | offset?: number | null; 8 | [property: string]: string | number | null | undefined; 9 | } 10 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import SpinningNumbers from './components/SpinningNumbers'; 2 | import { SpinningNumbersProps } from './core/dto/spinningNumbersDTO'; 3 | 4 | export type { SpinningNumbersProps }; 5 | export default SpinningNumbers; 6 | -------------------------------------------------------------------------------- /tests/helpers.test.js: -------------------------------------------------------------------------------- 1 | import { fontHash, decimals } from '../src/core/helpers'; 2 | 3 | describe( 'fontHash helper test', () => { 4 | 5 | it( 'should return a hash', () => { 6 | 7 | const result = fontHash( { 8 | fontFamily: 'Arial', 9 | fontSize: 12, 10 | fontWeight: 800, 11 | } ); 12 | 13 | expect( result ).toBe( 'Arial:12:800' ); 14 | 15 | } ); 16 | 17 | } ); 18 | 19 | describe( 'decimals helper test', () => { 20 | 21 | it( 'should return decimals', () => { 22 | 23 | const result = decimals( '1.23456789' ); 24 | 25 | expect( result ).toBe( 1 ); 26 | 27 | } ); 28 | 29 | it( 'should return 0', () => { 30 | 31 | const result = decimals( '' ); 32 | 33 | expect( result ).toBe( 0 ); 34 | 35 | } ); 36 | 37 | } ); 38 | -------------------------------------------------------------------------------- /tests/index.test.js: -------------------------------------------------------------------------------- 1 | import { act, render } from '@testing-library/react-native'; 2 | import * as Reanimated from 'react-native-reanimated'; 3 | import SpinningNumbers from '../src'; 4 | import { CHARS_TO_MEASURE } from '../src/core/constants'; 5 | import { TextMeasurment } from '../src/components/TextMeasurment'; 6 | import { AnimatedNumber, AnimatedSeparator, AnimatedSign, AnimatedText } from '../src/components/Animated'; 7 | 8 | const sleep = async () => new Promise( ( resolve ) => setTimeout( resolve, 250 ) ); 9 | jest.spyOn( Reanimated, 'useSharedValue' ).mockImplementation( ( value ) => ( { value } ) ); 10 | 11 | SpinningNumbers.defaultProps = { autoMeasure: true }; 12 | 13 | const style = { fontFamily: '', fontSize: 34 }; 14 | 15 | describe( 'Spinning numbers test', () => { 16 | 17 | it( 'Should be correct text with parentheses', () => { 18 | 19 | const text = '14,578'; 20 | const { getByTestId } = render( {text} ); 21 | expect( getByTestId( 'spinningContainer' ) ).toHaveTextContent( text ); 22 | 23 | } ); 24 | 25 | it( 'Should be correct text on rerender', () => { 26 | 27 | const text = '14,578'; 28 | const { getByTestId, rerender } = render( {text} ); 29 | 30 | const text2 = '17,239'; 31 | rerender( {text2} ); 32 | expect( getByTestId( 'spinningContainer' ) ).toHaveTextContent( text2 ); 33 | 34 | } ); 35 | 36 | it( 'Should be correct text when added decimals', () => { 37 | 38 | const text = '14,578'; 39 | const { getByTestId, rerender } = render( {text} ); 40 | 41 | const text2 = '47,956.78'; 42 | rerender( {text2} ); 43 | expect( getByTestId( 'spinningContainer' ) ).toHaveTextContent( text2 ); 44 | 45 | } ); 46 | 47 | it( 'Should be correct text when text is empty', () => { 48 | 49 | const text = '14,578'; 50 | const { getByTestId, rerender } = render( {text} ); 51 | 52 | const text2 = ''; 53 | rerender( {text2} ); 54 | expect( getByTestId( 'spinningContainer' ) ).toHaveTextContent( text2 ); 55 | 56 | } ); 57 | 58 | it( 'Should be correct text when there is no thousand', () => { 59 | 60 | const text = '14,578'; 61 | const { getByTestId, rerender } = render( {text} ); 62 | 63 | const text2 = '78,89'; 64 | rerender( {text2} ); 65 | expect( getByTestId( 'spinningContainer' ) ).toHaveTextContent( text2 ); 66 | 67 | } ); 68 | 69 | it( 'Should be correct text when there is thousand and rest is the same', () => { 70 | 71 | const text = '14,578'; 72 | const { getByTestId, rerender } = render( {text} ); 73 | 74 | const text2 = '1,078.89'; 75 | rerender( {text2} ); 76 | expect( getByTestId( 'spinningContainer' ) ).toHaveTextContent( text2 ); 77 | 78 | } ); 79 | 80 | it( 'Should be correct text when changed thousands', () => { 81 | 82 | const text = '14,578.45'; 83 | const { getByTestId, rerender } = render( {text} ); 84 | 85 | const text2 = '2,078.89'; 86 | rerender( {text2} ); 87 | expect( getByTestId( 'spinningContainer' ) ).toHaveTextContent( text2 ); 88 | 89 | } ); 90 | 91 | it( 'Should work when more decimal points', () => { 92 | 93 | const text = '14,578.457.4878'; 94 | const { getByTestId, rerender } = render( {text} ); 95 | 96 | const text2 = '2,078.89457989.4579'; 97 | rerender( {text2} ); 98 | expect( getByTestId( 'spinningContainer' ) ).toHaveTextContent( text2 ); 99 | 100 | } ); 101 | 102 | it( 'Should work when more decimal points & no thousands', () => { 103 | 104 | const text = '1,578.457.4878'; 105 | const { getByTestId, rerender } = render( {text} ); 106 | 107 | const text2 = '78.89457989.4579'; 108 | rerender( {text2} ); 109 | expect( getByTestId( 'spinningContainer' ) ).toHaveTextContent( text2 ); 110 | 111 | } ); 112 | 113 | it( 'Should work with negative number', () => { 114 | 115 | const text = '-71.45'; 116 | const { getByTestId, rerender } = render( {text} ); 117 | 118 | const text2 = '-78.89'; 119 | rerender( {text2} ); 120 | expect( getByTestId( 'spinningContainer' ) ).toHaveTextContent( text2 ); 121 | 122 | } ); 123 | 124 | it( 'Should work without decimals', () => { 125 | 126 | const text = '-71'; 127 | const { getByTestId, rerender } = render( {text} ); 128 | 129 | const text2 = '-78.89'; 130 | rerender( {text2} ); 131 | expect( getByTestId( 'spinningContainer' ) ).toHaveTextContent( text2 ); 132 | 133 | } ); 134 | 135 | it( 'Should work with weird char', () => { 136 | 137 | const text = '-71'; 138 | const { getByTestId, rerender } = render( {text} ); 139 | 140 | const text2 = '<'; 141 | rerender( {text2} ); 142 | expect( getByTestId( 'spinningContainer' ) ).toHaveTextContent( text2 ); 143 | 144 | } ); 145 | 146 | it( 'Should work when prefix changed to suffix', () => { 147 | 148 | const text = '$10'; 149 | const { getByTestId, rerender } = render( {text} ); 150 | 151 | const text2 = '10€'; 152 | rerender( {text2} ); 153 | expect( getByTestId( 'spinningContainer' ) ).toHaveTextContent( text2 ); 154 | 155 | } ); 156 | 157 | it( 'Should work with no defaultProps', () => { 158 | 159 | SpinningNumbers.defaultProps = {}; 160 | 161 | const text = '$10'; 162 | const { getByTestId, rerender } = render( {text} ); 163 | 164 | const text2 = '10€'; 165 | rerender( {text2} ); 166 | expect( getByTestId( 'spinningContainer' ) ).toHaveTextContent( text2 ); 167 | 168 | } ); 169 | 170 | 171 | it( 'Should measure chars', async () => { 172 | 173 | SpinningNumbers.defaultProps = { autoMeasure: true }; 174 | 175 | const text = '-71,895'; 176 | const { getByTestId, rerender } = render( {text} ); 177 | 178 | act( () => { 179 | 180 | CHARS_TO_MEASURE.split( '' ).forEach( ( char ) => { 181 | 182 | getByTestId( `${char}MeasureText` ).props.onLayout( { nativeEvent: { layout: { width: 10, height: 10 } } } ); 183 | getByTestId( `${char}MeasureText5` ).props.onLayout( { nativeEvent: { layout: { width: 50, height: 10 } } } ); 184 | 185 | } ); 186 | 187 | } ); 188 | 189 | await sleep(); 190 | 191 | const text2 = '+78,745'; 192 | rerender( {text2} ); 193 | expect( getByTestId( 'spinningContainer' ) ).toHaveTextContent( '+ - + - 1 0 9 8 7 6 5 4 3 2 11 0 9 8 7 6 5 4 3 2 1' ); 194 | 195 | } ); 196 | 197 | } ); 198 | 199 | describe( 'TextMeasurment test', () => { 200 | 201 | it( 'Should work with empty text', async () => { 202 | 203 | const result = await TextMeasurment.measure( '' ); 204 | 205 | expect( result ).toEqual( { text: '', width: 0, height: 20 } ); 206 | 207 | } ); 208 | 209 | it( 'Should return mesured value if exists', async () => { 210 | 211 | const result = await TextMeasurment.measure( 'A', style, [] ); 212 | expect( result ).toEqual( { text: 'A', width: 9.8, height: 10 } ); 213 | 214 | } ); 215 | 216 | } ); 217 | 218 | describe( 'AnimatedText test', () => { 219 | 220 | it( 'Should work with empty text', async () => { 221 | 222 | const { getByTestId } = render( ); 223 | 224 | expect( getByTestId( 'animatedText' ) ).toHaveTextContent( 'A' ); 225 | 226 | } ); 227 | 228 | it( 'Should work from text to empty text', async () => { 229 | 230 | const { getByTestId } = render( ); 231 | 232 | expect( getByTestId( 'animatedText' ) ).toHaveTextContent( 'A' ); 233 | 234 | } ); 235 | 236 | it( 'Should work if text is not measured', async () => { 237 | 238 | const { getByTestId } = render( ); 239 | 240 | expect( getByTestId( 'animatedText' ) ).toHaveTextContent( '>' ); 241 | 242 | } ); 243 | 244 | } ); 245 | 246 | describe( 'AnimatedNumber test', () => { 247 | 248 | it( 'Should work with default props', async () => { 249 | 250 | const { getByTestId } = render( ); 251 | 252 | expect( getByTestId( 'animatedNumber' ) ).toHaveTextContent( '0' ); 253 | 254 | } ); 255 | 256 | it( 'Should work without lineHeight', async () => { 257 | 258 | const { getByTestId } = render( ); 259 | 260 | expect( getByTestId( 'animatedNumber' ) ).toHaveTextContent( '0' ); 261 | 262 | } ); 263 | 264 | } ); 265 | 266 | describe( 'AnimatedSeparator test', () => { 267 | 268 | it( 'Should work without lineHeight', async () => { 269 | 270 | const { getByTestId } = render( ); 271 | 272 | expect( getByTestId( 'animatedSeparator' ) ).toHaveTextContent( '.' ); 273 | 274 | } ); 275 | 276 | } ); 277 | 278 | describe( 'AnimatedSign test', () => { 279 | 280 | it( 'Should work with animation', async () => { 281 | 282 | jest.spyOn( Reanimated, 'useSharedValue' ).mockImplementation( ( value ) => ( { value: typeof value === 'number' ? 0.5 : value } ) ); 283 | const { getByTestId } = render( ); 284 | 285 | expect( getByTestId( 'animatedSign' ) ).toHaveTextContent( '+' ); 286 | 287 | } ); 288 | 289 | it( 'Should work with incorrect sign', async () => { 290 | 291 | jest.spyOn( Reanimated, 'useSharedValue' ).mockImplementation( ( value ) => ( { value: typeof value === 'number' ? 0.2 : value } ) ); 292 | const { getByTestId } = render( ); 293 | 294 | expect( getByTestId( 'animatedSign' ) ).toHaveTextContent( '+' ); 295 | 296 | } ); 297 | 298 | } ); 299 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "lib": [ 6 | "ES2021" 7 | ], 8 | "allowJs": true, 9 | "jsx": "react-native", 10 | "declaration": true, 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "declarationDir": "./dist", 14 | "noEmit": true, 15 | "incremental": true, 16 | "isolatedModules": true, 17 | "strict": true, 18 | "noImplicitAny": true, 19 | "moduleResolution": "node", 20 | "baseUrl": "./src", 21 | "paths": { 22 | "~/*": [ 23 | "*" 24 | ] 25 | }, 26 | "types": ["react"], 27 | "allowSyntheticDefaultImports": true, 28 | "esModuleInterop": true, 29 | "skipLibCheck": false, 30 | "resolveJsonModule": true 31 | }, 32 | "include": [ 33 | "src/**/*.ts", 34 | "src/**/*.tsx" 35 | ], 36 | "exclude": [ 37 | "node_modules", 38 | "modules", 39 | "babel.config.js", 40 | "metro.config.js", 41 | "jest.config.js", 42 | "commitlint.config.js", 43 | ] 44 | } --------------------------------------------------------------------------------