From 0e75c3e1767d4ef027dbe5641aebca0ebdcefd42 Mon Sep 17 00:00:00 2001 From: hazrid93 Date: Mon, 20 Jul 2026 10:28:16 +0000 Subject: [PATCH] feat: add determinate circular progress Closes #4209. Adds a static determinate circular progress indicator per the Material 3 progress spec. Accepts a progress value from 0 to 1 and clamps values outside that range. Renders a circular track with the progress drawn as an arc covering the proportion given by the value. Exposes the progressbar accessibility role with min, max, and current value. Configurable size, stroke thickness, and colors for the arc and the background track, defaulting to the theme primary color. The component is exported from the public surface alongside the existing components. --- .../CircularProgress/CircularProgress.tsx | 111 ++++++++++++++++++ src/components/CircularProgress/types.ts | 25 ++++ src/index.tsx | 2 + 3 files changed, 138 insertions(+) create mode 100644 src/components/CircularProgress/CircularProgress.tsx create mode 100644 src/components/CircularProgress/types.ts 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';