Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add a determinate circular progress bar #4509

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions docs/docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ const config = {
CheckboxIOS: 'Checkbox/CheckboxIOS',
CheckboxItem: 'Checkbox/CheckboxItem',
},
CircularProgressBar: 'CircularProgressBar',
Chip: {
Chip: 'Chip/Chip',
},
Expand Down
2 changes: 2 additions & 0 deletions example/src/ExampleList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -70,6 +71,7 @@ export const mainExamples: Record<
checkbox: CheckboxExample,
checkboxItem: CheckboxItemExample,
chip: ChipExample,
circularProgressBar: CircularProgressBarExample,
dataTable: DataTableExample,
dialog: DialogExample,
divider: DividerExample,
Expand Down
74 changes: 74 additions & 0 deletions example/src/Examples/CircularProgressBarExample.tsx
Original file line number Diff line number Diff line change
@@ -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<number>(0.6);
const [text, setText] = React.useState('0.6');
const { isV3 } = useExampleTheme();

return (
<ScreenWrapper style={styles.container}>
<View style={styles.container}>
<TextInput
label="Progress (between 0 and 1)"
value={text}
onChangeText={(text) => setText(text)}
/>
</View>
<View style={styles.row}>
<FAB
size="small"
icon={'play'}
onPress={() => {
const x = Number(text);
!isNaN(x) ? setProgress(x) : null;
}}
/>
</View>

<List.Section title="Default">
<CircularProgressBar progress={progress} />
</List.Section>

<List.Section title="Large">
<CircularProgressBar progress={progress} size="large" />
</List.Section>

<List.Section title="Custom size">
<CircularProgressBar progress={progress} size={100} />
</List.Section>

<List.Section title="Custom color">
<CircularProgressBar
progress={progress}
color={isV3 ? MD3Colors.error20 : MD2Colors.red500}
/>
</List.Section>

<List.Section title="No animation">
<CircularProgressBar progress={progress} animating={false} />
</List.Section>
</ScreenWrapper>
);
};

CircularProgressBarExample.title = 'Circular Progress Bar';

const styles = StyleSheet.create({
container: {
padding: 4,
},
row: {
justifyContent: 'center',
alignItems: 'center',
margin: 10,
},
});

export default CircularProgressBarExample;
276 changes: 276 additions & 0 deletions src/components/CircularProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof View> & {
/**
* 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<ViewStyle>;
/**
* @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 = () => (
* <CircularProgressBar progress={0.75} />
* );
*
* 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<Animated.Value>(
new Animated.Value(0)
);

const prevProgressValue = React.useRef<number>(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 (
<View
style={[styles.container, style]}
{...rest}
accessible
accessibilityRole="progressbar"
accessibilityState={{ busy: animating }}
>
<Animated.View
style={[{ width: size, height: size }]}
collapsable={false}
>
<Animated.View style={[backgroundStyle, layerStyle]} />
{[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 (
<Animated.View key={index} style={[styles.layer, offsetStyle]}>
<Animated.View style={layerStyle}>
<Animated.View style={containerStyle} collapsable={false}>
<Animated.View style={[layerStyle, rotationStyle]}>
<Animated.View style={containerStyle} collapsable={false}>
<Animated.View style={lineStyle} />
</Animated.View>
</Animated.View>
</Animated.View>
</Animated.View>
</Animated.View>
);
})}
</Animated.View>
</View>
);
};

const styles = StyleSheet.create({
container: {
justifyContent: 'center',
alignItems: 'center',
},

layer: {
...StyleSheet.absoluteFillObject,

justifyContent: 'center',
alignItems: 'center',
},
});

export default CircularProgressBar;
Loading