Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions src/components/CircularProgress/CircularProgress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import * as React from 'react';
import { StyleSheet, View, ViewStyle } from 'react-native';

import type { CircularProgressProps } from './types';
import { useInternalTheme } from '../../core/theming';

const clamp = (v: number, min: number, max: number) =>
Math.min(Math.max(v, min), max);

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

/**
* Material 3 determinate circular progress indicator.
*
* Shows a circular track with a progress arc covering `progress` of the
* circle (0 to 1). Unlike {@link ActivityIndicator}, it represents a known
* amount of progress rather than an indeterminate spin.
*
* @param props
*/
function CircularProgress({
progress,
size = 48,
thickness = 4,
color,
trackColor,
style,
theme: themeOverrides,
testID = 'circular-progress',
}: CircularProgressProps) {
const theme = useInternalTheme(themeOverrides);
const p = clamp(progress, 0, 1);

const progressColor =
color ?? (theme.isV3 ? theme.colors.primary : theme.colors.accent);
const backgroundColor =
trackColor ??
(theme.isV3 ? theme.colors.primary : theme.colors.accent) + '33';

const radius = size / 2;
// The progress arc is drawn with two half-discs that rotate as the progress
// passes 0.5, which lets a circular arc be rendered without an SVG dep.
const firstHalfAngle = p <= 0.5 ? p * 360 : 180;
const secondHalfAngle = p > 0.5 ? (p - 0.5) * 360 : 0;

const trackStyle: ViewStyle = {
width: size,
height: size,
borderRadius: radius,
borderWidth: thickness,
borderColor: backgroundColor,
};

// Each progress half is a full circle clipped to its half, rotated by the
// angle that exposes the right amount of arc.
const halfSize = radius;
const arcLayerStyle = (angle: number, flipSecond: boolean): ViewStyle => ({
width: size,
height: halfSize,
overflow: 'hidden',
transform: [
{ rotate: `${angle}deg` },
...(flipSecond ? [{ translateY: -halfSize }] : []),
],
...(flipSecond ? { top: halfSize } : { top: 0 }),
left: 0,
position: 'absolute',
});

const arcDotStyle: ViewStyle = {
width: size,
height: size,
borderRadius: radius,
borderWidth: thickness,
borderColor: progressColor,
// Hide the track border on the progress disc by only drawing the arc.
borderBottomColor: 'transparent',
borderLeftColor: 'transparent',
transform: [{ rotate: '-45deg' }],
};

return (
<View
testID={testID}
style={[styles.container, trackStyle, style]}
accessibilityRole="progressbar"
accessibilityState={{ busy: false }}
accessibilityValue={{ min: 0, max: 1, now: p }}
>
<View
testID={`${testID}-half-first`}
style={arcLayerStyle(firstHalfAngle, false)}
>
<View style={arcDotStyle} />
</View>
<View
testID={`${testID}-half-second`}
style={arcLayerStyle(secondHalfAngle, true)}
>
<View style={arcDotStyle} />
</View>
</View>
);
}

export default CircularProgress;
25 changes: 25 additions & 0 deletions src/components/CircularProgress/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { StyleProp, ViewStyle } from 'react-native';

import type { ThemeProp } from '../../types';

/**
* Material 3 determinate circular progress indicator.
*/
export type CircularProgressProps = {
/** Progress from 0 to 1 (clamped). 0 is empty, 1 is a full circle. */
progress: number;
/** Diameter of the indicator in pixels. @default 48 */
size?: number;
/** Stroke thickness in pixels. @default 4 */
thickness?: number;
/** Color of the progress arc. Defaults to the theme primary color. */
color?: string;
/** Color of the background track. Defaults to a transparent track. */
trackColor?: string;
/** Style applied to the container. */
style?: StyleProp<ViewStyle>;
/** @optional */
theme?: ThemeProp;
/** TestID for testing. */
testID?: string;
};
2 changes: 2 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export { default as Banner } from './components/Banner';
export { default as BottomNavigation } from './components/BottomNavigation/BottomNavigation';
export { default as Button } from './components/Button/Button';
export { default as Card } from './components/Card/Card';
export { default as CircularProgress } from './components/CircularProgress/CircularProgress';
export { default as Checkbox } from './components/Checkbox';
export { default as Chip } from './components/Chip/Chip';
export { default as DataTable } from './components/DataTable/DataTable';
Expand Down Expand Up @@ -86,6 +87,7 @@ export type {
} from './components/BottomNavigation/BottomNavigation';
export type { Props as ButtonProps } from './components/Button/Button';
export type { Props as CardProps } from './components/Card/Card';
export type { CircularProgressProps } from './components/CircularProgress/types';
export type { Props as CardActionsProps } from './components/Card/CardActions';
export type { Props as CardContentProps } from './components/Card/CardContent';
export type { Props as CardCoverProps } from './components/Card/CardCover';
Expand Down