diff --git a/src/components/CircularProgress/CircularProgress.tsx b/src/components/CircularProgress/CircularProgress.tsx new file mode 100644 index 0000000000..7736079744 --- /dev/null +++ b/src/components/CircularProgress/CircularProgress.tsx @@ -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 ( + + + + + + + + + ); +} + +export default CircularProgress; diff --git a/src/components/CircularProgress/types.ts b/src/components/CircularProgress/types.ts new file mode 100644 index 0000000000..cdc64d3b72 --- /dev/null +++ b/src/components/CircularProgress/types.ts @@ -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; + /** @optional */ + theme?: ThemeProp; + /** TestID for testing. */ + testID?: string; +}; diff --git a/src/index.tsx b/src/index.tsx index 1b20528787..c33d773cfb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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'; @@ -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';