diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 16cd59f009..dca532d528 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -95,6 +95,7 @@ const config = { CheckboxIOS: 'Checkbox/CheckboxIOS', CheckboxItem: 'Checkbox/CheckboxItem', }, + CircularProgressBar: 'CircularProgressBar', Chip: { Chip: 'Chip/Chip', }, diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index 3fc4483c63..971bed5928 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -18,6 +18,7 @@ import CardExample from './Examples/CardExample'; import CheckboxExample from './Examples/CheckboxExample'; import CheckboxItemExample from './Examples/CheckboxItemExample'; import ChipExample from './Examples/ChipExample'; +import CircularProgressBarExample from './Examples/CircularProgressBarExample'; import DataTableExample from './Examples/DataTableExample'; import DialogExample from './Examples/DialogExample'; import DividerExample from './Examples/DividerExample'; @@ -70,6 +71,7 @@ export const mainExamples: Record< checkbox: CheckboxExample, checkboxItem: CheckboxItemExample, chip: ChipExample, + circularProgressBar: CircularProgressBarExample, dataTable: DataTableExample, dialog: DialogExample, divider: DividerExample, diff --git a/example/src/Examples/CircularProgressBarExample.tsx b/example/src/Examples/CircularProgressBarExample.tsx new file mode 100644 index 0000000000..b926a64122 --- /dev/null +++ b/example/src/Examples/CircularProgressBarExample.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { FAB, List, MD2Colors, MD3Colors, TextInput } from 'react-native-paper'; + +import { useExampleTheme } from '..'; +import CircularProgressBar from '../../../src/components/CircularProgressBar'; +import ScreenWrapper from '../ScreenWrapper'; + +const CircularProgressBarExample = () => { + const [progress, setProgress] = React.useState(0.6); + const [text, setText] = React.useState('0.6'); + const { isV3 } = useExampleTheme(); + + return ( + + + setText(text)} + /> + + + { + const x = Number(text); + !isNaN(x) ? setProgress(x) : null; + }} + /> + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +CircularProgressBarExample.title = 'Circular Progress Bar'; + +const styles = StyleSheet.create({ + container: { + padding: 4, + }, + row: { + justifyContent: 'center', + alignItems: 'center', + margin: 10, + }, +}); + +export default CircularProgressBarExample; diff --git a/src/components/CircularProgressBar.tsx b/src/components/CircularProgressBar.tsx new file mode 100644 index 0000000000..18f22c36a1 --- /dev/null +++ b/src/components/CircularProgressBar.tsx @@ -0,0 +1,276 @@ +import * as React from 'react'; +import { + Animated, + StyleProp, + StyleSheet, + View, + ViewStyle, + PixelRatio, +} from 'react-native'; + +import setColor from 'color'; + +import { useInternalTheme } from '../core/theming'; +import type { ThemeProp } from '../types'; + +export type Props = React.ComponentPropsWithRef & { + /** + * Progress value (between 0 and 1). + */ + progress?: number; + /** + * Whether to animate the circular progress bar or not. + */ + animating?: boolean; + /** + * The color of the circular progress bar. + */ + color?: string; + /** + * Size of the circular progress bar. + */ + size?: 'small' | 'large' | number; + style?: StyleProp; + /** + * @optional + */ + theme?: ThemeProp; +}; + +/** + * Circular progress bar is an indicator used to present progress of some activity in the app. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { CircularProgressBar, MD2Colors } from 'react-native-paper'; + * + * const MyComponent = () => ( + * + * ); + * + * export default MyComponent; + * ``` + */ +const CircularProgressBar = ({ + progress = 0, + animating = true, + color: indicatorColor, + size: indicatorSize = 'small', + style, + theme: themeOverrides, + ...rest +}: Props) => { + const theme = useInternalTheme(themeOverrides); + + // Progress must be between 0 and 1 + if (progress < 0) progress = 0; + if (progress > 1) progress = 1; + + const { current: timer } = React.useRef( + new Animated.Value(0) + ); + + const prevProgressValue = React.useRef(0); + + const { scale } = theme.animation; + + React.useEffect(() => { + prevProgressValue.current = progress; + timer.setValue(0); + Animated.timing(timer, { + duration: 200 * scale, + toValue: 1, + useNativeDriver: true, + isInteraction: false, + }).start(); + }, [progress, scale, timer]); + + const color = indicatorColor || theme.colors?.primary; + const tintColor = theme.isV3 + ? theme.colors.surfaceVariant + : setColor(color).alpha(0.38).rgb().string(); + + let size = + typeof indicatorSize === 'string' + ? indicatorSize === 'small' + ? 24 + : 48 + : indicatorSize + ? indicatorSize + : 24; + // Calculate the actual size of the circular progress bar to prevent a bug with clipping containers + const halfSize = PixelRatio.roundToNearestPixel(size / 2); + size = halfSize * 2; + + const layerStyle = { + width: size, + height: size, + }; + + const containerStyle = { + width: halfSize, + height: size, + overflow: 'hidden' as const, + }; + + const backgroundStyle = { + borderColor: tintColor, + borderWidth: size / 10, + borderRadius: size / 2, + }; + + const progressInDegrees = Math.ceil(progress * 360); + const leftRotation = progressInDegrees > 180 ? 180 : progressInDegrees; + const rightRotation = progressInDegrees > 180 ? progressInDegrees - 180 : 0; + + const prevProgressInDegrees = Math.ceil(prevProgressValue.current * 360); + const prevLeftRotation = + prevProgressInDegrees > 180 ? 180 : prevProgressInDegrees; + const prevRightRotation = + prevProgressInDegrees > 180 ? prevProgressInDegrees - 180 : 0; + + const addProgress = progressInDegrees > prevProgressInDegrees; + const noProgress = progressInDegrees - prevProgressInDegrees === 0; + const progressLteFiftyPercent = progressInDegrees <= 180; + const prevProgressGteFiftyPercent = prevProgressInDegrees >= 180; + + /** + * The animation uses a timer which counts from 0 to 1 for each change in progress. + * Since we have 2 half circles rotating, we need to calculate the timing when the other half circle has to start rotating. + * This value is used for the interpolation in the rotation style. + */ + let middle = 0; + + if ( + // There is no progress or the progress does not intersect the 50% mark + noProgress || + (addProgress && prevProgressGteFiftyPercent) || + (!addProgress && !prevProgressGteFiftyPercent) + ) { + middle = 0; + } else if ( + // The progress does not intersect the 50% mark + (addProgress && progressLteFiftyPercent) || + (!addProgress && !progressLteFiftyPercent) + ) { + middle = 1; + } else if ( + // The progress intersects the 50% mark and both half circles need to rotate + addProgress + ) { + middle = + (180 - prevProgressInDegrees) / + (progressInDegrees - prevProgressInDegrees); + } else { + // The progress intersects the 50% mark and both half circles need to rotate + middle = + (prevProgressInDegrees - 180) / + (prevProgressInDegrees - progressInDegrees); + } + + return ( + + + + {[0, 1].map((index) => { + const offsetStyle = index + ? { + transform: [ + { + rotate: `180deg`, + }, + ], + } + : null; + + // The rotation both half circles need to do + const rotationStyle = animating + ? { + transform: [ + { + rotate: timer.interpolate({ + inputRange: [0, middle, 1], + outputRange: index + ? [ + `${prevLeftRotation + 180}deg`, + `${ + (addProgress ? leftRotation : prevLeftRotation) + + 180 + }deg`, + `${leftRotation + 180}deg`, + ] + : [ + `${prevRightRotation + 180}deg`, + `${ + (addProgress + ? prevRightRotation + : rightRotation) + 180 + }deg`, + `${rightRotation + 180}deg`, + ], + }), + }, + ], + } + : { + transform: [ + { + rotate: index + ? `${leftRotation - 180}deg` + : `${rightRotation - 180}deg`, + }, + ], + }; + + const lineStyle = { + width: size, + height: size, + borderColor: color, + borderWidth: size / 10, + borderRadius: size / 2, + }; + + return ( + + + + + + + + + + + + ); + })} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + justifyContent: 'center', + alignItems: 'center', + }, + + layer: { + ...StyleSheet.absoluteFillObject, + + justifyContent: 'center', + alignItems: 'center', + }, +}); + +export default CircularProgressBar; diff --git a/src/components/__tests__/CircularProgressBar.test.tsx b/src/components/__tests__/CircularProgressBar.test.tsx new file mode 100644 index 0000000000..a2f17ada66 --- /dev/null +++ b/src/components/__tests__/CircularProgressBar.test.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; + +import { render } from '@testing-library/react-native'; + +import CircularProgressBar from '../CircularProgressBar'; + +it('renders animated circular progress bar with 100% progress', () => { + const tree = render( + + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders circular progress bar with 100% progress', () => { + const tree = render( + + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders animated circular progress bar and 70% progress', () => { + const tree = render( + + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders circular progress bar with 70% progress', () => { + const tree = render( + + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders animated circular progress bar with 40% progress', () => { + const tree = render( + + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders circular progress bar with 40% progress', () => { + const tree = render( + + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders animated circular progress bar with 0% progress', () => { + const tree = render( + + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders circular progress bar with 0% progress', () => { + const tree = render( + + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders large animated circular progress bar', () => { + const tree = render( + + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders colored animated circular progress bar', () => { + const tree = render( + + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); diff --git a/src/components/__tests__/__snapshots__/CircularProgressBar.test.tsx.snap b/src/components/__tests__/__snapshots__/CircularProgressBar.test.tsx.snap new file mode 100644 index 0000000000..23279ffe24 --- /dev/null +++ b/src/components/__tests__/__snapshots__/CircularProgressBar.test.tsx.snap @@ -0,0 +1,1971 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders animated circular progress bar and 70% progress 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders animated circular progress bar with 0% progress 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders animated circular progress bar with 40% progress 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders animated circular progress bar with 100% progress 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders circular progress bar with 0% progress 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders circular progress bar with 40% progress 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders circular progress bar with 70% progress 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders circular progress bar with 100% progress 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders colored animated circular progress bar 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders large animated circular progress bar 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + +`;