diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js
index a4b672640c..29e9f77529 100644
--- a/docs/docusaurus.config.js
+++ b/docs/docusaurus.config.js
@@ -63,7 +63,6 @@ const config = {
docsRootDir: path.join(__dirname, 'docs', 'components'),
libsRootDir: path.join(__dirname, '..', 'src', 'components'),
pages: {
- ActivityIndicator: 'ActivityIndicator',
Appbar: {
Appbar: 'Appbar/Appbar',
AppbarAction: 'Appbar/AppbarAction',
@@ -147,7 +146,14 @@ const config = {
Portal: 'Portal/Portal',
PortalHost: 'Portal/PortalHost',
},
- ProgressBar: 'ProgressBar',
+ CircularProgressIndicator:
+ 'ProgressIndicator/CircularProgressIndicator/CircularProgressIndicator',
+ CircularWavyProgressIndicator:
+ 'ProgressIndicator/CircularWavyProgressIndicator/CircularWavyProgressIndicator',
+ LinearProgressIndicator:
+ 'ProgressIndicator/LinearProgressIndicator/LinearProgressIndicator',
+ LinearWavyProgressIndicator:
+ 'ProgressIndicator/LinearWavyProgressIndicator/LinearWavyProgressIndicator',
RadioButton: {
RadioButton: 'RadioButton/RadioButton',
RadioButtonAndroid: 'RadioButton/RadioButtonAndroid',
@@ -249,7 +255,7 @@ const config = {
},
{
type: 'doc',
- docId: 'components/ActivityIndicator',
+ docId: 'components/Appbar/Appbar',
position: 'left',
label: 'Components',
},
diff --git a/docs/src/components/BannerExample.tsx b/docs/src/components/BannerExample.tsx
index fcb3e0a8e2..e1a6a2611b 100644
--- a/docs/src/components/BannerExample.tsx
+++ b/docs/src/components/BannerExample.tsx
@@ -11,7 +11,7 @@ import {
FAB,
DarkTheme,
LightTheme,
- ProgressBar,
+ LinearProgressIndicator,
PaperProvider,
RadioButton,
Switch,
@@ -89,7 +89,7 @@ const BannerExample = () => {
-
+
Display Large
Display Medium
diff --git a/docs/src/data/screenshots.js b/docs/src/data/screenshots.js
index f25cef6294..198912152c 100644
--- a/docs/src/data/screenshots.js
+++ b/docs/src/data/screenshots.js
@@ -1,5 +1,4 @@
const screenshots = {
- ActivityIndicator: 'screenshots/activity-indicator.gif',
Appbar: 'screenshots/appbar.png',
'Appbar.Action': 'screenshots/appbar-action-android.png',
'Appbar.BackAction': 'screenshots/appbar-backaction-android.png',
@@ -105,7 +104,7 @@ const screenshots = {
},
'Menu.Item': 'screenshots/menu-item.png',
Modal: 'screenshots/modal.gif',
- ProgressBar: 'screenshots/progress-bar.png',
+ LinearProgressIndicator: 'screenshots/progress-bar.png',
RadioButton: {
'Android (enabled)': 'screenshots/radio-enabled.android.png',
'Android (disabled)': 'screenshots/radio-disabled.android.png',
diff --git a/docs/src/data/themeColors.js b/docs/src/data/themeColors.js
index 2e97dd1bce..4248fd2056 100644
--- a/docs/src/data/themeColors.js
+++ b/docs/src/data/themeColors.js
@@ -1,9 +1,4 @@
const themeColors = {
- ActivityIndicator: {
- '-': {
- borderColor: 'theme.colors.primary',
- },
- },
Appbar: {
default: {
backgroundColor: 'theme.colors.surface',
@@ -264,10 +259,10 @@ const themeColors = {
backgroundColor: 'theme.colors.backdrop',
},
},
- ProgressBar: {
+ LinearProgressIndicator: {
'-': {
- tintColor: 'theme.colors.primary',
- trackTintColor: 'theme.colors.surfaceVariant',
+ color: 'theme.colors.primary',
+ trackColor: 'theme.colors.secondaryContainer',
},
},
Searchbar: {
diff --git a/example/package.json b/example/package.json
index 992ead9c23..b8bd97faf3 100644
--- a/example/package.json
+++ b/example/package.json
@@ -32,6 +32,7 @@
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.1",
+ "react-native-svg": "15.15.4",
"react-native-web": "^0.21.0",
"react-native-worklets": "0.8.3"
},
diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx
index 18c958a4bb..1ac1a142d6 100644
--- a/example/src/ExampleList.tsx
+++ b/example/src/ExampleList.tsx
@@ -5,7 +5,6 @@ import { useNavigation } from '@react-navigation/native';
import { Divider, List, useTheme } from 'react-native-paper';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import ActivityIndicatorExample from './Examples/ActivityIndicatorExample';
import AppbarExample from './Examples/AppbarExample';
import AvatarExample from './Examples/AvatarExample';
import BadgeExample from './Examples/BadgeExample';
@@ -28,7 +27,7 @@ import ListAccordionExampleGroup from './Examples/ListAccordionGroupExample';
import ListItemExample from './Examples/ListItemExample';
import ListSectionExample from './Examples/ListSectionExample';
import MenuExample from './Examples/MenuExample';
-import ProgressBarExample from './Examples/ProgressBarExample';
+import ProgressIndicatorExample from './Examples/ProgressIndicatorExample';
import RadioButtonExample from './Examples/RadioButtonExample';
import RadioButtonGroupExample from './Examples/RadioButtonGroupExample';
import RadioButtonItemExample from './Examples/RadioButtonItemExample';
@@ -50,7 +49,6 @@ import TooltipExample from './Examples/TooltipExample';
import TouchableRippleExample from './Examples/TouchableRippleExample';
export const mainExamples = {
- ActivityIndicator: ActivityIndicatorExample,
Appbar: AppbarExample,
Avatar: AvatarExample,
Badge: BadgeExample,
@@ -73,7 +71,7 @@ export const mainExamples = {
ListSection: ListSectionExample,
ListItem: ListItemExample,
Menu: MenuExample,
- Progressbar: ProgressBarExample,
+ ProgressIndicator: ProgressIndicatorExample,
Radio: RadioButtonExample,
RadioGroup: RadioButtonGroupExample,
RadioItem: RadioButtonItemExample,
diff --git a/example/src/Examples/ActivityIndicatorExample.tsx b/example/src/Examples/ActivityIndicatorExample.tsx
deleted file mode 100644
index e5fc90f032..0000000000
--- a/example/src/Examples/ActivityIndicatorExample.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import * as React from 'react';
-import { StyleSheet, View } from 'react-native';
-
-import { ActivityIndicator, FAB, List, Palette } from 'react-native-paper';
-
-import ScreenWrapper from '../ScreenWrapper';
-
-const ActivityIndicatorExample = () => {
- const [animating, setAnimating] = React.useState(true);
-
- return (
-
-
- setAnimating(!animating)}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-ActivityIndicatorExample.title = 'Activity Indicator';
-
-const styles = StyleSheet.create({
- container: {
- padding: 4,
- },
- row: {
- justifyContent: 'center',
- alignItems: 'center',
- margin: 10,
- },
-});
-
-export default ActivityIndicatorExample;
diff --git a/example/src/Examples/Dialogs/DialogWithLoadingIndicator.tsx b/example/src/Examples/Dialogs/DialogWithLoadingIndicator.tsx
index 86cbe67c0d..f75ab40eb0 100644
--- a/example/src/Examples/Dialogs/DialogWithLoadingIndicator.tsx
+++ b/example/src/Examples/Dialogs/DialogWithLoadingIndicator.tsx
@@ -1,12 +1,15 @@
import * as React from 'react';
-import { ActivityIndicator, Platform, StyleSheet, View } from 'react-native';
+import { StyleSheet, View } from 'react-native';
-import { Dialog, Palette, Portal } from 'react-native-paper';
+import {
+ CircularProgressIndicator,
+ Dialog,
+ Palette,
+ Portal,
+} from 'react-native-paper';
import { TextComponent } from './DialogTextComponent';
-const isIOS = Platform.OS === 'ios';
-
const DialogWithLoadingIndicator = ({
visible,
close,
@@ -20,9 +23,9 @@ const DialogWithLoadingIndicator = ({
Progress Dialog
-
Loading.....
diff --git a/example/src/Examples/ProgressBarExample.tsx b/example/src/Examples/ProgressBarExample.tsx
deleted file mode 100644
index 5b59566d6e..0000000000
--- a/example/src/Examples/ProgressBarExample.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import * as React from 'react';
-import { View, StyleSheet, Animated } from 'react-native';
-
-import {
- Button,
- Palette,
- ProgressBar,
- ProgressBarProps,
- Text,
- useTheme,
-} from 'react-native-paper';
-
-import ScreenWrapper from '../ScreenWrapper';
-
-class ClassProgressBar extends React.Component {
- constructor(props: ProgressBarProps) {
- super(props);
- }
-
- render() {
- return ;
- }
-}
-
-const AnimatedProgressBar = Animated.createAnimatedComponent(ClassProgressBar);
-
-const ProgressBarExample = () => {
- const [visible, setVisible] = React.useState(true);
- const [progress, setProgress] = React.useState(0.3);
- const theme = useTheme();
- const { current: progressBarValue } = React.useRef(new Animated.Value(0));
-
- const runCustomAnimation = () => {
- progressBarValue.setValue(0);
- Animated.timing(progressBarValue, {
- toValue: 1,
- duration: 2000,
- useNativeDriver: false,
- }).start();
- };
-
- return (
-
-
-
-
-
-
- Default ProgressBar
-
-
-
-
- Indeterminate ProgressBar
-
-
-
-
- ProgressBar with custom color
-
-
-
-
-
- ProgressBar with custom background color
-
-
-
-
-
- ProgressBar with custom height
-
-
-
-
- ProgressBar with animated value
-
-
-
-
-
- ProgressBar with custom percentage height
-
-
-
-
- );
-};
-
-ProgressBarExample.title = 'Progress Bar';
-
-const styles = StyleSheet.create({
- container: {
- padding: 16,
- },
- row: {
- marginVertical: 10,
- },
- fullRow: {
- height: '100%',
- width: '100%',
- },
- customHeight: {
- height: 20,
- },
- customPercentageHeight: {
- height: '50%',
- },
- progressBar: {
- height: 15,
- },
-});
-
-export default ProgressBarExample;
diff --git a/example/src/Examples/ProgressIndicatorExample.tsx b/example/src/Examples/ProgressIndicatorExample.tsx
new file mode 100644
index 0000000000..37d4ec5c61
--- /dev/null
+++ b/example/src/Examples/ProgressIndicatorExample.tsx
@@ -0,0 +1,278 @@
+import * as React from 'react';
+import { ScrollView, StyleSheet, View } from 'react-native';
+
+import {
+ Chip,
+ CircularProgressIndicator,
+ CircularWavyProgressIndicator,
+ FAB,
+ LinearProgressIndicator,
+ LinearWavyProgressIndicator,
+ List,
+ Switch,
+ Text,
+ useTheme,
+} from 'react-native-paper';
+import {
+ cancelAnimation,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { scheduleOnRN } from 'react-native-worklets';
+
+import ScreenWrapper from '../ScreenWrapper';
+
+const sizes = ['small', 'medium', 'large'] as const;
+const shapes = ['flat', 'wavy'] as const;
+
+type Size = (typeof sizes)[number];
+type Shape = (typeof shapes)[number];
+
+// Linear keeps the same thickness scale; circular grows its diameter on top of that. Flat and
+// wavy circular have different baselines (40 vs 48dp), so each shape has its own size scale.
+const sizeMap: Record<
+ Size,
+ { thickness: number; flatSize: number; wavySize: number }
+> = {
+ small: { thickness: 4, flatSize: 40, wavySize: 48 },
+ medium: { thickness: 6, flatSize: 44, wavySize: 52 },
+ large: { thickness: 8, flatSize: 48, wavySize: 56 },
+};
+
+type ChipRowProps = {
+ label: string;
+ options: readonly T[];
+ value: T;
+ onChange: (value: T) => void;
+};
+
+const ChipRow = ({
+ label,
+ options,
+ value,
+ onChange,
+}: ChipRowProps) => (
+
+
+ {label}
+
+
+ {options.map((option) => (
+ onChange(option)}
+ >
+ {option}
+
+ ))}
+
+
+);
+
+const ProgressIndicatorExample = () => {
+ const [determinate, setDeterminate] = React.useState(true);
+ const [size, setSize] = React.useState('small');
+ const [shape, setShape] = React.useState('flat');
+ const [running, setRunning] = React.useState(false);
+ const progress = useSharedValue(0);
+
+ const { colors } = useTheme();
+ const insets = useSafeAreaInsets();
+ const indeterminate = !determinate;
+
+ const { thickness, flatSize, wavySize } = sizeMap[size];
+ const wavy = shape === 'wavy';
+ const Linear = wavy ? LinearWavyProgressIndicator : LinearProgressIndicator;
+ const Circular = wavy
+ ? CircularWavyProgressIndicator
+ : CircularProgressIndicator;
+ const circularSize = wavy ? wavySize : flatSize;
+
+ const linearWavyHeight = Math.max(10, thickness + 2 * 3);
+ const linearPad = wavy ? 0 : (linearWavyHeight - thickness) / 2;
+ const circularPad = wavy ? 0 : (wavySize - flatSize) / 2;
+
+ const fabPadding = 16;
+ const FILL_DURATION = 3000;
+
+ const start = () => {
+ const from = progress.value >= 1 ? 0 : progress.value;
+ progress.value = from;
+ progress.value = withTiming(
+ 1,
+ { duration: FILL_DURATION * (1 - from) },
+ (finished) => {
+ if (finished) {
+ scheduleOnRN(setRunning, false);
+ }
+ }
+ );
+ setRunning(true);
+ };
+
+ const pause = () => {
+ cancelAnimation(progress);
+ setRunning(false);
+ };
+
+ const handleFabPress = () => {
+ if (running) {
+ pause();
+ } else {
+ start();
+ }
+ };
+
+ const handleDeterminateChange = (next: boolean) => {
+ setDeterminate(next);
+ if (next) {
+ start();
+ } else {
+ cancelAnimation(progress);
+ setRunning(false);
+ }
+ };
+
+ React.useEffect(() => () => cancelAnimation(progress), [progress]);
+
+ return (
+
+
+ (
+
+ )}
+ />
+
+
+
+
+ Linear
+
+
+
+
+
+
+
+
+
+
+
+
+ Circular
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+ProgressIndicatorExample.title = 'Progress Indicator';
+
+const styles = StyleSheet.create({
+ screen: {
+ flex: 1,
+ },
+ container: {
+ paddingVertical: 16,
+ },
+ chipRow: {
+ paddingVertical: 4,
+ },
+ chipRowLabel: {
+ paddingHorizontal: 16,
+ paddingBottom: 6,
+ },
+ chipRowContent: {
+ paddingHorizontal: 16,
+ gap: 8,
+ },
+ row: {
+ paddingHorizontal: 16,
+ marginVertical: 16,
+ gap: 12,
+ },
+ circularRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 24,
+ },
+ fabContainer: {
+ position: 'absolute',
+ flexDirection: 'row',
+ justifyContent: 'center',
+ },
+});
+
+export default ProgressIndicatorExample;
diff --git a/jest/testSetup.js b/jest/testSetup.js
index c00e611084..30a790b274 100644
--- a/jest/testSetup.js
+++ b/jest/testSetup.js
@@ -12,6 +12,34 @@ jest.mock('react-native-reanimated', () =>
require('react-native-reanimated/mock')
);
+jest.mock('react-native-svg', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+
+ const makeComponent = (name) => {
+ const Component = ({ children, ...props }) =>
+ React.createElement(View, props, children);
+ Component.displayName = name;
+ return Component;
+ };
+
+ const Svg = makeComponent('Svg');
+
+ return {
+ __esModule: true,
+ default: Svg,
+ Svg,
+ Circle: makeComponent('Circle'),
+ Path: makeComponent('Path'),
+ G: makeComponent('G'),
+ Line: makeComponent('Line'),
+ Rect: makeComponent('Rect'),
+ Defs: makeComponent('Defs'),
+ LinearGradient: makeComponent('LinearGradient'),
+ Stop: makeComponent('Stop'),
+ };
+});
+
jest.mock('@react-native-vector-icons/material-design-icons', () => {
const React = require('react');
const { Text } = require('react-native');
diff --git a/package.json b/package.json
index 34ae7e3d9e..a2d67d44e9 100644
--- a/package.json
+++ b/package.json
@@ -96,6 +96,7 @@
"react-native-builder-bob": "^0.41.0",
"react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "5.7.0",
+ "react-native-svg": "15.15.4",
"react-native-worklets": "0.8.3",
"react-test-renderer": "19.2.3",
"release-it": "^13.4.0",
@@ -107,6 +108,7 @@
"react-native": "*",
"react-native-reanimated": ">=4.3.0",
"react-native-safe-area-context": "*",
+ "react-native-svg": ">=15.0.0",
"react-native-worklets": ">=0.8.1"
},
"husky": {
@@ -136,7 +138,7 @@
"__fixtures__\\/[^/]+\\/(output|error)\\.js"
],
"transformIgnorePatterns": [
- "node_modules/(?!(@react-native|react-native|@react-navigation|react-native-reanimated|react-native-worklets|react-native-safe-area-context|nanoid|query-string|decode-uri-component|filter-obj|split-on-first|escape-string-regexp)/)"
+ "node_modules/(?!(@react-native|react-native|@react-navigation|react-native-reanimated|react-native-worklets|react-native-safe-area-context|react-native-svg|nanoid|query-string|decode-uri-component|filter-obj|split-on-first|escape-string-regexp)/)"
]
},
"greenkeeper": {
diff --git a/src/components/ActivityIndicator.tsx b/src/components/ActivityIndicator.tsx
deleted file mode 100644
index 95da56ae0c..0000000000
--- a/src/components/ActivityIndicator.tsx
+++ /dev/null
@@ -1,255 +0,0 @@
-import * as React from 'react';
-import {
- Animated,
- ColorValue,
- Easing,
- Platform,
- StyleProp,
- StyleSheet,
- View,
- ViewStyle,
-} from 'react-native';
-
-import { useInternalTheme } from '../core/theming';
-import type { ThemeProp } from '../types';
-
-export type Props = React.ComponentPropsWithRef & {
- /**
- * Whether to show the indicator or hide it.
- */
- animating?: boolean;
- /**
- * The color of the spinner.
- */
- color?: ColorValue;
- /**
- * Size of the indicator.
- */
- size?: 'small' | 'large' | number;
- /**
- * Whether the indicator should hide when not animating.
- */
- hidesWhenStopped?: boolean;
- style?: StyleProp;
- /**
- * @optional
- */
- theme?: ThemeProp;
-};
-
-const DURATION = 2400;
-
-/**
- * Activity indicator is used to present progress of some activity in the app.
- * It can be used as a drop-in replacement for the ActivityIndicator shipped with React Native.
- *
- * ## Usage
- * ```js
- * import * as React from 'react';
- * import { ActivityIndicator, Palette } from 'react-native-paper';
- *
- * const MyComponent = () => (
- *
- * );
- *
- * export default MyComponent;
- * ```
- */
-const ActivityIndicator = ({
- animating = true,
- color: indicatorColor,
- hidesWhenStopped = true,
- size: indicatorSize = 'small',
- style,
- theme: themeOverrides,
- ...rest
-}: Props) => {
- const theme = useInternalTheme(themeOverrides);
- const { current: timer } = React.useRef(
- new Animated.Value(0)
- );
- const { current: fade } = React.useRef(
- new Animated.Value(!animating && hidesWhenStopped ? 0 : 1)
- );
-
- const rotation = React.useRef(
- undefined
- );
-
- const {
- animation: { scale },
- } = theme;
-
- const startRotation = React.useCallback(() => {
- // Show indicator
- Animated.timing(fade, {
- duration: 200 * scale,
- toValue: 1,
- isInteraction: false,
- useNativeDriver: true,
- }).start();
-
- // Circular animation in loop
- if (rotation.current) {
- timer.setValue(0);
- // $FlowFixMe
- Animated.loop(rotation.current).start();
- }
- }, [scale, fade, timer]);
-
- const stopRotation = () => {
- if (rotation.current) {
- rotation.current.stop();
- }
- };
-
- React.useEffect(() => {
- if (rotation.current === undefined) {
- // Circular animation in loop
- rotation.current = Animated.timing(timer, {
- duration: DURATION,
- easing: Easing.linear,
- // Animated.loop does not work if useNativeDriver is true on web
- useNativeDriver: Platform.OS !== 'web',
- toValue: 1,
- isInteraction: false,
- });
- }
-
- if (animating) {
- startRotation();
- } else if (hidesWhenStopped) {
- // Hide indicator first and then stop rotation
- Animated.timing(fade, {
- duration: 200 * scale,
- toValue: 0,
- useNativeDriver: true,
- isInteraction: false,
- }).start(stopRotation);
- } else {
- stopRotation();
- }
- }, [animating, fade, hidesWhenStopped, startRotation, scale, timer]);
-
- const color = indicatorColor || theme.colors?.primary;
- const size =
- typeof indicatorSize === 'string'
- ? indicatorSize === 'small'
- ? 24
- : 48
- : indicatorSize
- ? indicatorSize
- : 24;
-
- const frames = (60 * DURATION) / 1000;
- const easing = Easing.bezier(0.4, 0.0, 0.7, 1.0);
- const containerStyle = {
- width: size,
- height: size / 2,
- overflow: 'hidden' as const,
- };
-
- return (
-
-
- {[0, 1].map((index) => {
- // Thanks to https://github.com/n4kz/react-native-indicators for the great work
- const inputRange = Array.from(
- new Array(frames),
- (_, frameIndex) => frameIndex / (frames - 1)
- );
- const outputRange = Array.from(new Array(frames), (_, frameIndex) => {
- let progress = (2 * frameIndex) / (frames - 1);
- const rotation = index ? +(360 - 15) : -(180 - 15);
-
- if (progress > 1.0) {
- progress = 2.0 - progress;
- }
-
- const direction = index ? -1 : +1;
-
- return `${direction * (180 - 30) * easing(progress) + rotation}deg`;
- });
-
- const layerStyle = {
- width: size,
- height: size,
- transform: [
- {
- rotate: timer.interpolate({
- inputRange: [0, 1],
- outputRange: [`${0 + 30 + 15}deg`, `${2 * 360 + 30 + 15}deg`],
- }),
- },
- ],
- };
-
- const viewportStyle = {
- width: size,
- height: size,
- transform: [
- {
- translateY: index ? -size / 2 : 0,
- },
- {
- rotate: timer.interpolate({ inputRange, outputRange }),
- },
- ],
- };
-
- const offsetStyle = index ? { top: size / 2 } : null;
-
- 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.absoluteFill,
-
- justifyContent: 'center',
- alignItems: 'center',
- },
-});
-
-export default ActivityIndicator;
diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx
index 17d40fb035..f2aaa9818a 100644
--- a/src/components/Button/Button.tsx
+++ b/src/components/Button/Button.tsx
@@ -23,8 +23,8 @@ import type { $Omit, Theme, ThemeProp } from '../../types';
import { forwardRef } from '../../utils/forwardRef';
import hasTouchHandler from '../../utils/hasTouchHandler';
import { splitStyles } from '../../utils/splitStyles';
-import ActivityIndicator from '../ActivityIndicator';
import Icon, { IconSource } from '../Icon';
+import CircularProgressIndicator from '../ProgressIndicator/CircularProgressIndicator';
import Surface from '../Surface';
import TouchableRipple, {
Props as TouchableRippleProps,
@@ -387,8 +387,10 @@ const Button = (
) : null}
{loading ? (
- (
>
{loading ? (
-
+
) : (
)}
diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx
deleted file mode 100644
index bf1941490f..0000000000
--- a/src/components/ProgressBar.tsx
+++ /dev/null
@@ -1,289 +0,0 @@
-import * as React from 'react';
-import {
- Animated,
- LayoutChangeEvent,
- Platform,
- StyleProp,
- StyleSheet,
- View,
- ViewStyle,
-} from 'react-native';
-
-import { useLocale } from '../core/locale';
-import { useInternalTheme } from '../core/theming';
-import type { ThemeProp } from '../types';
-
-export type Props = React.ComponentPropsWithRef & {
- /**
- * Animated value (between 0 and 1). This tells the progress bar to rely on this value to animate it.
- * Note: It should not be used in parallel with the `progress` prop.
- */
- animatedValue?: number;
- /**
- * Progress value (between 0 and 1).
- * Note: It should not be used in parallel with the `animatedValue` prop.
- */
- progress?: number;
- /**
- * Color of the progress bar. The background color will be calculated based on this but you can change it by passing `backgroundColor` to `style` prop.
- */
- color?: string;
- /**
- * If the progress bar will show indeterminate progress.
- */
- indeterminate?: boolean;
- /**
- * Whether to show the ProgressBar (true, the default) or hide it (false).
- */
- visible?: boolean;
- /**
- * Style of filled part of the ProgresBar.
- */
- fillStyle?: Animated.WithAnimatedValue>;
- style?: StyleProp;
- /**
- * @optional
- */
- theme?: ThemeProp;
- /**
- * testID to be used on tests.
- */
- testID?: string;
-};
-
-const INDETERMINATE_DURATION = 2000;
-const INDETERMINATE_MAX_WIDTH = 0.6;
-
-/**
- * Progress bar is an indicator used to present progress of some activity in the app.
- *
- * ## Usage
- * ```js
- * import * as React from 'react';
- * import { ProgressBar, Palette } from 'react-native-paper';
- *
- * const MyComponent = () => (
- *
- * );
- *
- * export default MyComponent;
- * ```
- */
-const ProgressBar = ({
- color,
- indeterminate,
- progress = 0,
- visible = true,
- theme: themeOverrides,
- animatedValue,
- style,
- fillStyle,
- testID = 'progress-bar',
- ...rest
-}: Props) => {
- const isWeb = Platform.OS === 'web';
- const theme = useInternalTheme(themeOverrides);
- const { direction } = useLocale();
- const isRTL = direction === 'rtl';
- const { current: timer } = React.useRef(
- new Animated.Value(0)
- );
- const { current: fade } = React.useRef(new Animated.Value(0));
- const passedAnimatedValue =
- React.useRef(animatedValue);
- const [width, setWidth] = React.useState(0);
- const [prevWidth, setPrevWidth] = React.useState(0);
-
- const indeterminateAnimation =
- React.useRef(null);
-
- const { scale } = theme.animation;
-
- React.useEffect(() => {
- passedAnimatedValue.current = animatedValue;
- });
-
- const startAnimation = React.useCallback(() => {
- // Show progress bar
- Animated.timing(fade, {
- duration: 200 * scale,
- toValue: 1,
- useNativeDriver: true,
- isInteraction: false,
- }).start();
-
- /**
- * We shouldn't add @param animatedValue to the
- * deps array, to avoid the unnecessary loop.
- * We can only check if the prop is passed initially,
- * and we do early return.
- */
- const externalAnimation =
- typeof passedAnimatedValue.current !== 'undefined' &&
- passedAnimatedValue.current >= 0;
-
- if (externalAnimation) {
- return;
- }
-
- // Animate progress bar
- if (indeterminate) {
- if (!indeterminateAnimation.current) {
- indeterminateAnimation.current = Animated.timing(timer, {
- duration: INDETERMINATE_DURATION,
- toValue: 1,
- // Animated.loop does not work if useNativeDriver is true on web
- useNativeDriver: !isWeb,
- isInteraction: false,
- });
- }
-
- // Reset timer to the beginning
- timer.setValue(0);
-
- Animated.loop(indeterminateAnimation.current).start();
- } else {
- Animated.timing(timer, {
- duration: 200 * scale,
- toValue: progress ? progress : 0,
- useNativeDriver: true,
- isInteraction: false,
- }).start();
- }
- }, [fade, scale, indeterminate, timer, progress, isWeb]);
-
- const stopAnimation = React.useCallback(() => {
- // Stop indeterminate animation
- if (indeterminateAnimation.current) {
- indeterminateAnimation.current.stop();
- }
-
- Animated.timing(fade, {
- duration: 200 * scale,
- toValue: 0,
- useNativeDriver: true,
- isInteraction: false,
- }).start();
- }, [fade, scale]);
-
- React.useEffect(() => {
- if (visible) startAnimation();
- else stopAnimation();
- }, [visible, startAnimation, stopAnimation]);
-
- React.useEffect(() => {
- if (animatedValue && animatedValue >= 0) {
- timer.setValue(animatedValue);
- }
- }, [animatedValue, timer]);
-
- React.useEffect(() => {
- // Start animation the very first time when previously the width was unclear
- if (visible && prevWidth === 0) {
- startAnimation();
- }
- }, [prevWidth, startAnimation, visible]);
-
- const onLayout = (event: LayoutChangeEvent) => {
- setPrevWidth(width);
- setWidth(event.nativeEvent.layout.width);
- };
-
- const tintColor = color || theme.colors?.primary;
- const trackTintColor = theme.colors.surfaceVariant;
-
- return (
-
-
- {width ? (
-
- ) : null}
-
-
- );
-};
-
-const styles = StyleSheet.create({
- container: {
- height: 4,
- overflow: 'hidden',
- },
- webContainer: {
- width: '100%',
- height: '100%',
- },
- progressBar: {
- flex: 1,
- },
-});
-
-export default ProgressBar;
diff --git a/src/components/ProgressIndicator/CircularProgressIndicator/CircularProgressIndicator.tsx b/src/components/ProgressIndicator/CircularProgressIndicator/CircularProgressIndicator.tsx
new file mode 100644
index 0000000000..518b552102
--- /dev/null
+++ b/src/components/ProgressIndicator/CircularProgressIndicator/CircularProgressIndicator.tsx
@@ -0,0 +1,231 @@
+import * as React from 'react';
+import {
+ type ColorValue,
+ type StyleProp,
+ StyleSheet,
+ View,
+ type ViewStyle,
+} from 'react-native';
+
+import Animated, {
+ type SharedValue,
+ useAnimatedProps,
+ useAnimatedStyle,
+ useDerivedValue,
+} from 'react-native-reanimated';
+import Svg, { Circle } from 'react-native-svg';
+
+import { CircularProgressIndicatorTokens as T } from './tokens';
+import { useInternalTheme } from '../../../core/theming';
+import type { ThemeProp } from '../../../types';
+import {
+ CIRCULAR_INDETERMINATE_DURATION as CYCLE,
+ circularArc,
+} from '../circularIndeterminateSpecs';
+import { useIndeterminatePhase, useProgressAccessibility } from '../hooks';
+import { getProgressIndicatorColors } from '../utils';
+
+export type Props = {
+ /**
+ * Progress between 0 and 1 (out-of-range values are clamped). Pass a number to set it
+ * directly, or a Reanimated `SharedValue` to animate it on the UI thread.
+ */
+ progress?: number | SharedValue;
+ /**
+ * Show progress of an unknown duration. While set, `progress` is ignored.
+ */
+ indeterminate?: boolean;
+ /**
+ * Color of the active indicator. Defaults to `theme.colors.primary`.
+ */
+ color?: ColorValue;
+ /**
+ * Color of the track. Defaults to `theme.colors.secondaryContainer`. The track is hidden
+ * while indeterminate.
+ */
+ trackColor?: ColorValue;
+ /**
+ * Indicator diameter
+ */
+ size?: number;
+ /**
+ * Indicator stroke thickness
+ */
+ thickness?: number;
+ style?: StyleProp;
+ /**
+ * @optional
+ */
+ theme?: ThemeProp;
+ /**
+ * testID to be used on tests.
+ */
+ testID?: string;
+ /**
+ * Label describing what is loading, e.g. "Loading news article".
+ */
+ accessibilityLabel?: string;
+};
+
+const AnimatedCircle = Animated.createAnimatedComponent(Circle);
+
+/**
+ * Material Design 3 circular progress indicator. Supports determinate and indeterminate modes.
+ *
+ * ## Usage
+ * ```js
+ * import * as React from 'react';
+ * import { CircularProgressIndicator } from 'react-native-paper';
+ *
+ * const MyComponent = () => (
+ *
+ * );
+ *
+ * export default MyComponent;
+ * ```
+ */
+const CircularProgressIndicator = ({
+ progress = 0,
+ indeterminate = false,
+ color,
+ trackColor,
+ size = T.size,
+ thickness = T.activeThickness,
+ style,
+ theme: themeOverrides,
+ testID = 'circular-progress-indicator',
+ accessibilityLabel,
+}: Props) => {
+ const theme = useInternalTheme(themeOverrides);
+
+ const { activeColor, trackColor: resolvedTrackColor } =
+ getProgressIndicatorColors({ theme, color, trackColor });
+
+ const center = size / 2;
+ const radius = (size - thickness) / 2;
+ const circumference = 2 * Math.PI * radius;
+ const capAngle = (thickness / 2 / circumference) * 360;
+ const gapAngle = (T.trackActiveSpace / circumference) * 360;
+
+ const { progressPercent, busy } = useProgressAccessibility(
+ progress,
+ indeterminate
+ );
+
+ const phase = useIndeterminatePhase(indeterminate, CYCLE);
+
+ const arc = useDerivedValue(() => {
+ if (indeterminate) {
+ const { startAngle, sweepAngle } = circularArc(phase.value * CYCLE);
+ const sweepLen = Math.max((sweepAngle / 360) * circumference, 0.01);
+ return { rotate: startAngle, sweepLen };
+ }
+ const p = Math.min(
+ 1,
+ Math.max(0, typeof progress === 'number' ? progress : progress.value)
+ );
+ return { rotate: -90, sweepLen: p * circumference };
+ }, [indeterminate, progress, circumference]);
+
+ const activeRotation = useAnimatedStyle(
+ () => ({ transform: [{ rotate: `${arc.value.rotate}deg` }] }),
+ []
+ );
+
+ // dasharray [C, C] makes the visible length = C - dashoffset, anchored at the stroke start.
+ const activeProps = useAnimatedProps(
+ () => ({ strokeDashoffset: circumference - arc.value.sweepLen }),
+ [circumference]
+ );
+
+ const trackRotation = useAnimatedStyle(() => {
+ const p = Math.min(
+ 1,
+ Math.max(0, typeof progress === 'number' ? progress : progress.value)
+ );
+ return {
+ transform: [{ rotate: `${-90 + p * 360 + 2 * capAngle + gapAngle}deg` }],
+ };
+ }, [progress, capAngle, gapAngle]);
+
+ const trackProps = useAnimatedProps(() => {
+ const p = Math.min(
+ 1,
+ Math.max(0, typeof progress === 'number' ? progress : progress.value)
+ );
+ const trackLenAngle =
+ p <= 0 ? 360 : 360 - p * 360 - 4 * capAngle - 2 * gapAngle;
+ const trackLen = Math.max(0, (trackLenAngle / 360) * circumference);
+ return {
+ strokeDashoffset: circumference - trackLen,
+ strokeOpacity: trackLenAngle <= 0 ? 0 : 1,
+ };
+ }, [progress, circumference, capAngle, gapAngle]);
+
+ const circleProps = {
+ cx: center,
+ cy: center,
+ r: radius,
+ fill: 'none',
+ strokeWidth: thickness,
+ strokeLinecap: 'round' as const,
+ strokeDasharray: [circumference, circumference],
+ };
+
+ return (
+
+ {!indeterminate ? (
+
+
+
+ ) : null}
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ layer: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+});
+
+export default CircularProgressIndicator;
diff --git a/src/components/ProgressIndicator/CircularProgressIndicator/__tests__/CircularProgressIndicator.test.tsx b/src/components/ProgressIndicator/CircularProgressIndicator/__tests__/CircularProgressIndicator.test.tsx
new file mode 100644
index 0000000000..cd4c9a5691
--- /dev/null
+++ b/src/components/ProgressIndicator/CircularProgressIndicator/__tests__/CircularProgressIndicator.test.tsx
@@ -0,0 +1,75 @@
+import * as React from 'react';
+
+import { render } from '../../../../test-utils';
+import CircularProgressIndicator from '../CircularProgressIndicator';
+
+const a11yRole = 'progressbar';
+
+// One smoke snapshot; behavior is checked with the assertions below.
+it('renders determinate progress', () => {
+ const tree = render().toJSON();
+
+ expect(tree).toMatchSnapshot();
+});
+
+it('resolves active and track colors', () => {
+ const tree = render(
+
+ );
+
+ expect(tree.getByTestId('pi-active').props.stroke).toBe('red');
+ expect(tree.getByTestId('pi-track').props.stroke).toBe('blue');
+});
+
+it('renders the active stroke and no track when indeterminate', () => {
+ const tree = render();
+
+ expect(tree.getByTestId('pi-active')).toBeTruthy();
+ expect(tree.queryByTestId('pi-track')).toBeNull();
+ expect(tree.getByRole(a11yRole).props.accessibilityValue).toEqual({});
+});
+
+it('reports the determinate value', () => {
+ const tree = render();
+
+ expect(tree.getByRole(a11yRole).props.accessibilityValue).toEqual({
+ min: 0,
+ max: 100,
+ now: 42,
+ });
+});
+
+it('is busy below 100% and not busy at 100% (determinate)', () => {
+ const partial = render();
+ expect(partial.getByRole(a11yRole).props.accessibilityState).toEqual({
+ busy: true,
+ });
+
+ const complete = render();
+ expect(complete.getByRole(a11yRole).props.accessibilityState).toEqual({
+ busy: false,
+ });
+});
+
+it('is busy when indeterminate', () => {
+ const tree = render();
+
+ expect(tree.getByRole(a11yRole).props.accessibilityState).toEqual({
+ busy: true,
+ });
+});
+
+it('shows the track again when toggling indeterminate -> determinate', () => {
+ const tree = render();
+ expect(tree.queryByTestId('pi-track')).toBeNull();
+
+ tree.rerender();
+
+ expect(tree.getByTestId('pi-track')).toBeTruthy();
+ expect(tree.getByTestId('pi-active')).toBeTruthy();
+});
diff --git a/src/components/ProgressIndicator/CircularProgressIndicator/__tests__/__snapshots__/CircularProgressIndicator.test.tsx.snap b/src/components/ProgressIndicator/CircularProgressIndicator/__tests__/__snapshots__/CircularProgressIndicator.test.tsx.snap
new file mode 100644
index 0000000000..1155c28e61
--- /dev/null
+++ b/src/components/ProgressIndicator/CircularProgressIndicator/__tests__/__snapshots__/CircularProgressIndicator.test.tsx.snap
@@ -0,0 +1,128 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders determinate progress 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/components/ProgressIndicator/CircularProgressIndicator/index.tsx b/src/components/ProgressIndicator/CircularProgressIndicator/index.tsx
new file mode 100644
index 0000000000..6b2d771532
--- /dev/null
+++ b/src/components/ProgressIndicator/CircularProgressIndicator/index.tsx
@@ -0,0 +1,2 @@
+export { default } from './CircularProgressIndicator';
+export type { Props } from './CircularProgressIndicator';
diff --git a/src/components/ProgressIndicator/CircularProgressIndicator/tokens.ts b/src/components/ProgressIndicator/CircularProgressIndicator/tokens.ts
new file mode 100644
index 0000000000..66c386265c
--- /dev/null
+++ b/src/components/ProgressIndicator/CircularProgressIndicator/tokens.ts
@@ -0,0 +1,6 @@
+export const CircularProgressIndicatorTokens = {
+ size: 40,
+ activeThickness: 4,
+ trackThickness: 4,
+ trackActiveSpace: 4,
+} as const;
diff --git a/src/components/ProgressIndicator/CircularWavyProgressIndicator/CircularWavyProgressIndicator.tsx b/src/components/ProgressIndicator/CircularWavyProgressIndicator/CircularWavyProgressIndicator.tsx
new file mode 100644
index 0000000000..9857171bcf
--- /dev/null
+++ b/src/components/ProgressIndicator/CircularWavyProgressIndicator/CircularWavyProgressIndicator.tsx
@@ -0,0 +1,305 @@
+import * as React from 'react';
+import {
+ type ColorValue,
+ type StyleProp,
+ View,
+ type ViewStyle,
+} from 'react-native';
+
+import Animated, {
+ Easing,
+ type SharedValue,
+ useAnimatedProps,
+ useAnimatedReaction,
+ useSharedValue,
+ withRepeat,
+ withTiming,
+ cancelAnimation,
+} from 'react-native-reanimated';
+import Svg, { Path } from 'react-native-svg';
+
+import { CircularWavyProgressIndicatorTokens as T } from './tokens';
+import { useInternalTheme } from '../../../core/theming';
+import { useReduceMotion } from '../../../theme/accessibility/ReduceMotionContext';
+import { motionEasing } from '../../../theme/tokens/sys/motion';
+import type { ThemeProp } from '../../../types';
+import {
+ CIRCULAR_INDETERMINATE_DURATION as CYCLE,
+ circularArc,
+} from '../circularIndeterminateSpecs';
+import { useIndeterminatePhase, useProgressAccessibility } from '../hooks';
+import { getProgressIndicatorColors } from '../utils';
+import { buildArcWavePath, NO_DRAW_PATH } from '../wavePath';
+
+export type Props = {
+ /**
+ * Progress between 0 and 1 (out-of-range values are clamped). Pass a number to set it
+ * directly, or a Reanimated `SharedValue` to animate it on the UI thread.
+ */
+ progress?: number | SharedValue;
+ /**
+ * Show progress of an unknown duration. While set, `progress` is ignored.
+ */
+ indeterminate?: boolean;
+ /**
+ * Color of the active indicator. Defaults to `theme.colors.primary`.
+ */
+ color?: ColorValue;
+ /**
+ * Color of the track. Defaults to `theme.colors.secondaryContainer`. The track is hidden
+ * while indeterminate.
+ */
+ trackColor?: ColorValue;
+ /**
+ * Indicator diameter.
+ */
+ size?: number;
+ /**
+ * Indicator stroke thickness.
+ */
+ thickness?: number;
+ /**
+ * Wave height in dp. The wave flattens near the start and end of the progress.
+ */
+ amplitude?: number;
+ /**
+ * Distance in dp between two wave peaks. Snapped to fit a whole number of waves around the ring.
+ */
+ wavelength?: number;
+ /**
+ * Wave travel speed in dp per second. Defaults to one wavelength per second.
+ */
+ waveSpeed?: number;
+ style?: StyleProp;
+ /**
+ * @optional
+ */
+ theme?: ThemeProp;
+ /**
+ * testID to be used on tests.
+ */
+ testID?: string;
+ /**
+ * Label describing what is loading, e.g. "Loading news article".
+ */
+ accessibilityLabel?: string;
+};
+
+const AnimatedPath = Animated.createAnimatedComponent(Path);
+
+const [STD_X1, STD_Y1, STD_X2, STD_Y2] = motionEasing.standard;
+const [ACC_X1, ACC_Y1, ACC_X2, ACC_Y2] = motionEasing.emphasizedAccelerate;
+
+const AMPLITUDE_DURATION = 500;
+
+/**
+ * Material Design 3 wavy circular progress indicator. The active indicator is drawn as a wavy
+ * arc that flattens near the start and end of the progress. Supports determinate and
+ * indeterminate modes.
+ *
+ * ## Usage
+ * ```js
+ * import * as React from 'react';
+ * import { CircularWavyProgressIndicator } from 'react-native-paper';
+ *
+ * const MyComponent = () => (
+ *
+ * );
+ *
+ * export default MyComponent;
+ * ```
+ */
+const CircularWavyProgressIndicator = ({
+ progress = 0,
+ indeterminate = false,
+ color,
+ trackColor,
+ size = T.size,
+ thickness = T.activeThickness,
+ amplitude,
+ wavelength,
+ waveSpeed,
+ style,
+ theme: themeOverrides,
+ testID = 'circular-wavy-progress-indicator',
+ accessibilityLabel,
+}: Props) => {
+ const theme = useInternalTheme(themeOverrides);
+ const reduceMotion = useReduceMotion();
+
+ const { activeColor, trackColor: resolvedTrackColor } =
+ getProgressIndicatorColors({ theme, color, trackColor });
+
+ const { progressPercent, busy } = useProgressAccessibility(
+ progress,
+ indeterminate
+ );
+
+ const center = size / 2;
+ // Amplitude is constant; the centerline radius is inset by it so the wave peaks always fit
+ // inside the box.
+ const maxAmplitude = amplitude ?? T.amplitude;
+ const radius = (size - thickness) / 2 - maxAmplitude;
+ const circumference = 2 * Math.PI * radius;
+ const capAngle = (thickness / 2 / circumference) * 360;
+ const gapAngle = (T.trackActiveSpace / circumference) * 360;
+
+ const waveLength = wavelength ?? T.wavelength;
+ // Snap to a whole number of waves so the pattern joins seamlessly around the ring.
+ const waveCount = Math.max(5, Math.round(circumference / waveLength));
+ const waveSpeedValue = waveSpeed ?? waveLength;
+
+ const phase = useIndeterminatePhase(indeterminate, CYCLE);
+ const waveOffset = useSharedValue(0);
+ const amp = useSharedValue(0);
+
+ // Slide the wave pattern at a steady speed; one wavelength per `duration`.
+ React.useEffect(() => {
+ if (reduceMotion) {
+ cancelAnimation(waveOffset);
+ waveOffset.value = 0;
+ return;
+ }
+ const duration = Math.max(50, (waveLength / waveSpeedValue) * 1000);
+ waveOffset.value = 0;
+ waveOffset.value = withRepeat(
+ withTiming(1, { duration, easing: Easing.linear }),
+ -1,
+ false
+ );
+ return () => cancelAnimation(waveOffset);
+ }, [reduceMotion, waveLength, waveSpeedValue, waveOffset]);
+
+ // Grow the wave once progress is underway and flatten it near the ends.
+ useAnimatedReaction(
+ () => {
+ if (reduceMotion) return 0;
+ if (indeterminate) return 1;
+ const p = Math.min(
+ 1,
+ Math.max(0, typeof progress === 'number' ? progress : progress.value)
+ );
+ return p > 0.1 && p < 0.95 ? 1 : 0;
+ },
+ (on, previous) => {
+ if (on === previous) return;
+ amp.value = withTiming(on ? maxAmplitude : 0, {
+ duration: AMPLITUDE_DURATION,
+ easing: on
+ ? Easing.bezier(STD_X1, STD_Y1, STD_X2, STD_Y2)
+ : Easing.bezier(ACC_X1, ACC_Y1, ACC_X2, ACC_Y2),
+ });
+ },
+ [reduceMotion, indeterminate, progress, maxAmplitude]
+ );
+
+ const activeProps = useAnimatedProps(() => {
+ const wavePhase = waveOffset.value * 2 * Math.PI;
+ if (indeterminate) {
+ const { startAngle, sweepAngle } = circularArc(phase.value * CYCLE);
+ return {
+ d: buildArcWavePath(
+ center,
+ center,
+ radius,
+ startAngle,
+ sweepAngle,
+ amp.value,
+ waveCount,
+ wavePhase
+ ),
+ };
+ }
+ const p = Math.min(
+ 1,
+ Math.max(0, typeof progress === 'number' ? progress : progress.value)
+ );
+ return {
+ d: buildArcWavePath(
+ center,
+ center,
+ radius,
+ -90,
+ p * 360,
+ amp.value,
+ waveCount,
+ wavePhase
+ ),
+ };
+ }, [indeterminate, progress, center, radius, waveCount]);
+
+ // The track is the arc left by the active indicator: it starts a gap past the active head and
+ // runs back to a gap before its tail, with rounded ends. It follows the active arc in both
+ // determinate and indeterminate modes, so it shrinks as the active grows.
+ const trackProps = useAnimatedProps(() => {
+ let activeStart;
+ let activeSweep;
+ if (indeterminate) {
+ const { startAngle, sweepAngle } = circularArc(phase.value * CYCLE);
+ activeStart = startAngle;
+ activeSweep = sweepAngle;
+ } else {
+ const p = Math.min(
+ 1,
+ Math.max(0, typeof progress === 'number' ? progress : progress.value)
+ );
+ activeStart = -90;
+ activeSweep = p * 360;
+ }
+ if (activeSweep <= 0) {
+ return {
+ d: buildArcWavePath(center, center, radius, -90, 360, 0, waveCount, 0),
+ };
+ }
+ const trackSweep = 360 - activeSweep - 4 * capAngle - 2 * gapAngle;
+ if (trackSweep <= 0) return { d: NO_DRAW_PATH };
+ const trackStart = activeStart + activeSweep + 2 * capAngle + gapAngle;
+ return {
+ d: buildArcWavePath(
+ center,
+ center,
+ radius,
+ trackStart,
+ trackSweep,
+ 0,
+ waveCount,
+ 0
+ ),
+ };
+ }, [indeterminate, progress, center, radius, capAngle, gapAngle, waveCount]);
+
+ return (
+
+
+
+ );
+};
+
+export default CircularWavyProgressIndicator;
diff --git a/src/components/ProgressIndicator/CircularWavyProgressIndicator/__tests__/CircularWavyProgressIndicator.test.tsx b/src/components/ProgressIndicator/CircularWavyProgressIndicator/__tests__/CircularWavyProgressIndicator.test.tsx
new file mode 100644
index 0000000000..70f9597701
--- /dev/null
+++ b/src/components/ProgressIndicator/CircularWavyProgressIndicator/__tests__/CircularWavyProgressIndicator.test.tsx
@@ -0,0 +1,81 @@
+import * as React from 'react';
+
+import { render } from '../../../../test-utils';
+import CircularWavyProgressIndicator from '../CircularWavyProgressIndicator';
+
+const a11yRole = 'progressbar';
+
+// One smoke snapshot; behavior is checked with the assertions below.
+it('renders determinate progress', () => {
+ const tree = render(
+
+ ).toJSON();
+
+ expect(tree).toMatchSnapshot();
+});
+
+it('resolves active and track colors', () => {
+ const tree = render(
+
+ );
+
+ expect(tree.getByTestId('pi-active').props.stroke).toBe('red');
+ expect(tree.getByTestId('pi-track').props.stroke).toBe('blue');
+});
+
+it('renders the active stroke and a track when indeterminate', () => {
+ const tree = render(
+
+ );
+
+ expect(tree.getByTestId('pi-active')).toBeTruthy();
+ expect(tree.getByTestId('pi-track')).toBeTruthy();
+ expect(tree.getByRole(a11yRole).props.accessibilityValue).toEqual({});
+});
+
+it('reports the determinate value', () => {
+ const tree = render();
+
+ expect(tree.getByRole(a11yRole).props.accessibilityValue).toEqual({
+ min: 0,
+ max: 100,
+ now: 42,
+ });
+});
+
+it('is busy below 100% and not busy at 100% (determinate)', () => {
+ const partial = render();
+ expect(partial.getByRole(a11yRole).props.accessibilityState).toEqual({
+ busy: true,
+ });
+
+ const complete = render();
+ expect(complete.getByRole(a11yRole).props.accessibilityState).toEqual({
+ busy: false,
+ });
+});
+
+it('is busy when indeterminate', () => {
+ const tree = render();
+
+ expect(tree.getByRole(a11yRole).props.accessibilityState).toEqual({
+ busy: true,
+ });
+});
+
+it('keeps the track when toggling indeterminate -> determinate', () => {
+ const tree = render(
+
+ );
+ expect(tree.getByTestId('pi-track')).toBeTruthy();
+
+ tree.rerender();
+
+ expect(tree.getByTestId('pi-track')).toBeTruthy();
+ expect(tree.getByTestId('pi-active')).toBeTruthy();
+});
diff --git a/src/components/ProgressIndicator/CircularWavyProgressIndicator/__tests__/__snapshots__/CircularWavyProgressIndicator.test.tsx.snap b/src/components/ProgressIndicator/CircularWavyProgressIndicator/__tests__/__snapshots__/CircularWavyProgressIndicator.test.tsx.snap
new file mode 100644
index 0000000000..c03187e73d
--- /dev/null
+++ b/src/components/ProgressIndicator/CircularWavyProgressIndicator/__tests__/__snapshots__/CircularWavyProgressIndicator.test.tsx.snap
@@ -0,0 +1,60 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders determinate progress 1`] = `
+
+
+
+
+
+
+`;
diff --git a/src/components/ProgressIndicator/CircularWavyProgressIndicator/index.tsx b/src/components/ProgressIndicator/CircularWavyProgressIndicator/index.tsx
new file mode 100644
index 0000000000..edc7b18526
--- /dev/null
+++ b/src/components/ProgressIndicator/CircularWavyProgressIndicator/index.tsx
@@ -0,0 +1,2 @@
+export { default } from './CircularWavyProgressIndicator';
+export type { Props } from './CircularWavyProgressIndicator';
diff --git a/src/components/ProgressIndicator/CircularWavyProgressIndicator/tokens.ts b/src/components/ProgressIndicator/CircularWavyProgressIndicator/tokens.ts
new file mode 100644
index 0000000000..83248409ae
--- /dev/null
+++ b/src/components/ProgressIndicator/CircularWavyProgressIndicator/tokens.ts
@@ -0,0 +1,8 @@
+export const CircularWavyProgressIndicatorTokens = {
+ size: 48,
+ activeThickness: 4,
+ trackThickness: 4,
+ trackActiveSpace: 4,
+ amplitude: 1.6,
+ wavelength: 15,
+} as const;
diff --git a/src/components/ProgressIndicator/LinearProgressIndicator/LinearProgressIndicator.tsx b/src/components/ProgressIndicator/LinearProgressIndicator/LinearProgressIndicator.tsx
new file mode 100644
index 0000000000..4e6422e520
--- /dev/null
+++ b/src/components/ProgressIndicator/LinearProgressIndicator/LinearProgressIndicator.tsx
@@ -0,0 +1,355 @@
+import * as React from 'react';
+import {
+ type ColorValue,
+ type LayoutChangeEvent,
+ Platform,
+ type StyleProp,
+ StyleSheet,
+ View,
+ type ViewStyle,
+} from 'react-native';
+
+import Animated, {
+ type SharedValue,
+ useAnimatedStyle,
+} from 'react-native-reanimated';
+
+import { LinearProgressIndicatorTokens as T } from './tokens';
+import { useLocale } from '../../../core/locale';
+import { useInternalTheme } from '../../../core/theming';
+import type { ThemeProp } from '../../../types';
+import { useIndeterminatePhase, useProgressAccessibility } from '../hooks';
+import {
+ LINEAR_INDETERMINATE_DURATION as CYCLE,
+ LINEAR_INDETERMINATE_LINES as LINES,
+ lineFraction,
+} from '../indeterminateSpecs';
+import { getProgressIndicatorColors } from '../utils';
+
+export type Props = {
+ /**
+ * Progress between 0 and 1 (out-of-range values are clamped). Pass a number to set it
+ * directly, or a Reanimated `SharedValue` to animate it on the UI thread.
+ */
+ progress?: number | SharedValue;
+ /**
+ * Show progress of an unknown duration. While set, `progress` is ignored.
+ */
+ indeterminate?: boolean;
+ /**
+ * Color of the active indicator and the stop dot. Defaults to `theme.colors.primary`.
+ */
+ color?: ColorValue;
+ /**
+ * Color of the track. Defaults to `theme.colors.secondaryContainer`.
+ */
+ trackColor?: ColorValue;
+ thickness?: number;
+ style?: StyleProp;
+ /**
+ * @optional
+ */
+ theme?: ThemeProp;
+ /**
+ * testID to be used on tests.
+ */
+ testID?: string;
+ /**
+ * Label describing what is loading, e.g. "Loading news article".
+ */
+ accessibilityLabel?: string;
+};
+
+/**
+ * Material Design 3 linear progress indicator. Supports determinate and indeterminate modes.
+ *
+ * ## Usage
+ * ```js
+ * import * as React from 'react';
+ * import { LinearProgressIndicator } from 'react-native-paper';
+ *
+ * const MyComponent = () => (
+ *
+ * );
+ *
+ * export default MyComponent;
+ * ```
+ */
+const LinearProgressIndicator = ({
+ progress = 0,
+ indeterminate = false,
+ color,
+ trackColor,
+ thickness = T.activeThickness,
+ style,
+ theme: themeOverrides,
+ testID = 'linear-progress-indicator',
+ accessibilityLabel,
+}: Props) => {
+ const theme = useInternalTheme(themeOverrides);
+ const { direction } = useLocale();
+
+ const xSign = direction === 'ltr' ? 1 : -1;
+ const leadingAnchor: ViewStyle =
+ Platform.OS === 'web' && direction === 'rtl' ? { right: 0 } : { left: 0 };
+
+ const {
+ activeColor,
+ trackColor: resolvedTrackColor,
+ stopColor,
+ } = getProgressIndicatorColors({ theme, color, trackColor });
+
+ const [width, setWidth] = React.useState(0);
+
+ const { progressPercent, busy } = useProgressAccessibility(
+ progress,
+ indeterminate
+ );
+
+ const radius = thickness / 2;
+ const stopSize = Math.min(thickness, T.stopSize);
+ const stopOffset = Math.min(
+ (thickness - stopSize) / 2,
+ T.stopTrailingSpaceMax
+ );
+ const gap = T.trackActiveSpace;
+
+ const phase = useIndeterminatePhase(indeterminate, CYCLE);
+
+ const onLayout = (event: LayoutChangeEvent) => {
+ setWidth(event.nativeEvent.layout.width);
+ };
+
+ const activeDeterminateStyle = useAnimatedStyle(() => {
+ const p = Math.min(
+ 1,
+ Math.max(0, typeof progress === 'number' ? progress : progress.value)
+ );
+ const w = p <= 0 ? 0 : Math.max(p * width, thickness);
+ return { width: w };
+ }, [progress, width, thickness]);
+
+ const trackDeterminateStyle = useAnimatedStyle(() => {
+ const p = Math.min(
+ 1,
+ Math.max(0, typeof progress === 'number' ? progress : progress.value)
+ );
+ const head = p * width;
+ const activeWidth = p <= 0 ? 0 : Math.max(head, thickness);
+ const gapPx = activeWidth > 0 ? Math.min(gap, head) : 0;
+ const offset = Math.min(activeWidth + gapPx, width);
+ return {
+ transform: [{ translateX: xSign * offset }],
+ width: Math.max(0, width - offset),
+ };
+ }, [progress, width, thickness, gap, xSign]);
+
+ const segment1Style = useAnimatedStyle(() => {
+ const elapsed = phase.value * CYCLE;
+ const tail =
+ lineFraction(elapsed, LINES.firstTail.delay, LINES.firstTail.duration) *
+ width;
+ const head =
+ lineFraction(elapsed, LINES.firstHead.delay, LINES.firstHead.duration) *
+ width;
+ return {
+ transform: [{ translateX: xSign * tail }],
+ width: Math.max(0, head - tail),
+ };
+ }, [width, xSign]);
+
+ const segment2Style = useAnimatedStyle(() => {
+ const elapsed = phase.value * CYCLE;
+ const tail =
+ lineFraction(elapsed, LINES.secondTail.delay, LINES.secondTail.duration) *
+ width;
+ const head =
+ lineFraction(elapsed, LINES.secondHead.delay, LINES.secondHead.duration) *
+ width;
+ return {
+ transform: [{ translateX: xSign * tail }],
+ width: Math.max(0, head - tail),
+ };
+ }, [width, xSign]);
+
+ const trackRightStyle = useAnimatedStyle(() => {
+ const elapsed = phase.value * CYCLE;
+ const firstHead =
+ lineFraction(elapsed, LINES.firstHead.delay, LINES.firstHead.duration) *
+ width;
+ const offset = firstHead > 0 ? firstHead + gap : 0;
+ return {
+ transform: [{ translateX: xSign * offset }],
+ width: Math.max(0, width - offset),
+ };
+ }, [width, gap, xSign]);
+
+ const trackMiddleStyle = useAnimatedStyle(() => {
+ const elapsed = phase.value * CYCLE;
+ const secondHead =
+ lineFraction(elapsed, LINES.secondHead.delay, LINES.secondHead.duration) *
+ width;
+ const firstTail =
+ lineFraction(elapsed, LINES.firstTail.delay, LINES.firstTail.duration) *
+ width;
+ const offset = secondHead > 0 ? secondHead + gap : 0;
+ const end = firstTail < width ? firstTail - gap : width;
+ return {
+ transform: [{ translateX: xSign * offset }],
+ width: Math.max(0, end - offset),
+ };
+ }, [width, gap, xSign]);
+
+ const trackLeftStyle = useAnimatedStyle(() => {
+ const elapsed = phase.value * CYCLE;
+ const secondTail =
+ lineFraction(elapsed, LINES.secondTail.delay, LINES.secondTail.duration) *
+ width;
+ return { width: Math.max(0, secondTail - gap) };
+ }, [width, gap]);
+
+ const lineStyle = { height: thickness, borderRadius: radius };
+
+ let body: React.ReactNode;
+ if (!indeterminate) {
+ body = (
+ <>
+
+
+ {width > 0 ? (
+
+ ) : null}
+ >
+ );
+ } else {
+ body = (
+ <>
+
+
+
+
+
+ >
+ );
+ }
+
+ return (
+
+ {body}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ width: '100%',
+ justifyContent: 'center',
+ overflow: 'visible',
+ },
+ inner: {
+ flex: 1,
+ justifyContent: 'center',
+ },
+ positioned: {
+ position: 'absolute',
+ },
+ stop: {
+ position: 'absolute',
+ },
+});
+
+export default LinearProgressIndicator;
diff --git a/src/components/ProgressIndicator/LinearProgressIndicator/__tests__/LinearProgressIndicator.test.tsx b/src/components/ProgressIndicator/LinearProgressIndicator/__tests__/LinearProgressIndicator.test.tsx
new file mode 100644
index 0000000000..4cfc6521e8
--- /dev/null
+++ b/src/components/ProgressIndicator/LinearProgressIndicator/__tests__/LinearProgressIndicator.test.tsx
@@ -0,0 +1,115 @@
+import * as React from 'react';
+
+import { act } from '@testing-library/react-native';
+
+import { render } from '../../../../test-utils';
+import LinearProgressIndicator from '../LinearProgressIndicator';
+
+const a11yRole = 'progressbar';
+
+const layoutEvent = {
+ nativeEvent: { layout: { width: 100 } },
+};
+
+const triggerLayout = async (
+ tree: ReturnType
+): Promise => {
+ await act(async () => {
+ tree.getByRole(a11yRole).props.onLayout(layoutEvent);
+ });
+};
+
+// One smoke snapshot; behavior is checked with the assertions below.
+it('renders determinate progress', async () => {
+ const tree = render();
+ await triggerLayout(tree);
+
+ expect(tree.toJSON()).toMatchSnapshot();
+});
+
+it('resolves active, track and stop colors', async () => {
+ const tree = render(
+
+ );
+ await triggerLayout(tree);
+
+ expect(tree.getByTestId('pi-active')).toHaveStyle({ backgroundColor: 'red' });
+ expect(tree.getByTestId('pi-track')).toHaveStyle({ backgroundColor: 'blue' });
+ expect(tree.getByTestId('pi-stop')).toHaveStyle({ backgroundColor: 'red' });
+});
+
+it('lays out the determinate active, track and stop dot', async () => {
+ const tree = render();
+ await triggerLayout(tree);
+
+ // width 100, progress 0.5: active 50, then a 4dp gap, then the track. Segments anchor at the
+ // leading edge and shift with translateX (flipped for RTL).
+ expect(tree.getByTestId('pi-active')).toHaveStyle({ left: 0, width: 50 });
+ expect(tree.getByTestId('pi-track')).toHaveStyle({
+ transform: [{ translateX: 54 }],
+ width: 46,
+ });
+ expect(tree.getByTestId('pi-stop')).toHaveStyle({ right: 0 });
+});
+
+it('renders separate track segments and no stop dot when indeterminate', async () => {
+ const tree = render();
+ await triggerLayout(tree);
+
+ expect(tree.getByTestId('pi-track')).toBeTruthy();
+ expect(tree.getByTestId('pi-active')).toBeTruthy();
+ expect(tree.queryByTestId('pi-stop')).toBeNull();
+ expect(tree.getByRole(a11yRole).props.accessibilityValue).toEqual({});
+});
+
+it('reports the determinate value', async () => {
+ const tree = render();
+ await triggerLayout(tree);
+
+ expect(tree.getByRole(a11yRole).props.accessibilityValue).toEqual({
+ min: 0,
+ max: 100,
+ now: 42,
+ });
+});
+
+it('is busy below 100% and not busy at 100% (determinate)', async () => {
+ const partial = render();
+ await triggerLayout(partial);
+ expect(partial.getByRole(a11yRole).props.accessibilityState).toEqual({
+ busy: true,
+ });
+
+ const complete = render();
+ await triggerLayout(complete);
+ expect(complete.getByRole(a11yRole).props.accessibilityState).toEqual({
+ busy: false,
+ });
+});
+
+it('is busy when indeterminate', async () => {
+ const tree = render();
+ await triggerLayout(tree);
+
+ expect(tree.getByRole(a11yRole).props.accessibilityState).toEqual({
+ busy: true,
+ });
+});
+
+it('resets the active indicator layout when toggling indeterminate -> determinate', async () => {
+ const tree = render();
+ await triggerLayout(tree);
+ expect(tree.queryByTestId('pi-stop')).toBeNull();
+
+ tree.rerender();
+ await triggerLayout(tree);
+
+ // Active must start at the leading edge after the toggle.
+ expect(tree.getByTestId('pi-active')).toHaveStyle({ left: 0, width: 30 });
+ expect(tree.getByTestId('pi-stop')).toBeTruthy();
+});
diff --git a/src/components/ProgressIndicator/LinearProgressIndicator/__tests__/__snapshots__/LinearProgressIndicator.test.tsx.snap b/src/components/ProgressIndicator/LinearProgressIndicator/__tests__/__snapshots__/LinearProgressIndicator.test.tsx.snap
new file mode 100644
index 0000000000..76f8358653
--- /dev/null
+++ b/src/components/ProgressIndicator/LinearProgressIndicator/__tests__/__snapshots__/LinearProgressIndicator.test.tsx.snap
@@ -0,0 +1,116 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders determinate progress 1`] = `
+
+
+
+
+
+
+
+`;
diff --git a/src/components/ProgressIndicator/LinearProgressIndicator/index.tsx b/src/components/ProgressIndicator/LinearProgressIndicator/index.tsx
new file mode 100644
index 0000000000..41f4853923
--- /dev/null
+++ b/src/components/ProgressIndicator/LinearProgressIndicator/index.tsx
@@ -0,0 +1,2 @@
+export { default } from './LinearProgressIndicator';
+export type { Props } from './LinearProgressIndicator';
diff --git a/src/components/ProgressIndicator/LinearProgressIndicator/tokens.ts b/src/components/ProgressIndicator/LinearProgressIndicator/tokens.ts
new file mode 100644
index 0000000000..39df2c49c0
--- /dev/null
+++ b/src/components/ProgressIndicator/LinearProgressIndicator/tokens.ts
@@ -0,0 +1,8 @@
+export const LinearProgressIndicatorTokens = {
+ height: 4,
+ activeThickness: 4,
+ trackThickness: 4,
+ stopSize: 4,
+ trackActiveSpace: 4,
+ stopTrailingSpaceMax: 6,
+} as const;
diff --git a/src/components/ProgressIndicator/LinearWavyProgressIndicator/LinearWavyProgressIndicator.tsx b/src/components/ProgressIndicator/LinearWavyProgressIndicator/LinearWavyProgressIndicator.tsx
new file mode 100644
index 0000000000..218264f196
--- /dev/null
+++ b/src/components/ProgressIndicator/LinearWavyProgressIndicator/LinearWavyProgressIndicator.tsx
@@ -0,0 +1,390 @@
+import * as React from 'react';
+import {
+ type ColorValue,
+ type LayoutChangeEvent,
+ StyleSheet,
+ View,
+ type StyleProp,
+ type ViewStyle,
+} from 'react-native';
+
+import Animated, {
+ Easing,
+ type SharedValue,
+ useAnimatedProps,
+ useAnimatedReaction,
+ useSharedValue,
+ withRepeat,
+ withTiming,
+ cancelAnimation,
+} from 'react-native-reanimated';
+import Svg, { Circle, G, Path } from 'react-native-svg';
+
+import { LinearWavyProgressIndicatorTokens as T } from './tokens';
+import { useLocale } from '../../../core/locale';
+import { useInternalTheme } from '../../../core/theming';
+import { useReduceMotion } from '../../../theme/accessibility/ReduceMotionContext';
+import { motionEasing } from '../../../theme/tokens/sys/motion';
+import type { ThemeProp } from '../../../types';
+import { useIndeterminatePhase, useProgressAccessibility } from '../hooks';
+import {
+ LINEAR_INDETERMINATE_DURATION as CYCLE,
+ LINEAR_INDETERMINATE_LINES as LINES,
+ lineFraction,
+} from '../indeterminateSpecs';
+import { getProgressIndicatorColors } from '../utils';
+import { buildLinearWavePath, buildStraightPath } from '../wavePath';
+
+export type Props = {
+ /**
+ * Progress between 0 and 1 (out-of-range values are clamped). Pass a number to set it
+ * directly, or a Reanimated `SharedValue` to animate it on the UI thread.
+ */
+ progress?: number | SharedValue;
+ /**
+ * Show progress of an unknown duration. While set, `progress` is ignored.
+ */
+ indeterminate?: boolean;
+ /**
+ * Color of the active indicator and the stop dot. Defaults to `theme.colors.primary`.
+ */
+ color?: ColorValue;
+ /**
+ * Color of the track. Defaults to `theme.colors.secondaryContainer`.
+ */
+ trackColor?: ColorValue;
+ /**
+ * Stroke thickness of the active indicator and the track.
+ */
+ thickness?: number;
+ /**
+ * Wave height in dp. The wave flattens near the start and end of the progress.
+ */
+ amplitude?: number;
+ /**
+ * Distance in dp between two wave peaks. Defaults to 40 (determinate) or 20 (indeterminate).
+ */
+ wavelength?: number;
+ /**
+ * Wave travel speed in dp per second. Defaults to one wavelength per second.
+ */
+ waveSpeed?: number;
+ style?: StyleProp;
+ /**
+ * @optional
+ */
+ theme?: ThemeProp;
+ /**
+ * testID to be used on tests.
+ */
+ testID?: string;
+ /**
+ * Label describing what is loading, e.g. "Loading news article".
+ */
+ accessibilityLabel?: string;
+};
+
+const AnimatedPath = Animated.createAnimatedComponent(Path);
+const AnimatedCircle = Animated.createAnimatedComponent(Circle);
+
+const [STD_X1, STD_Y1, STD_X2, STD_Y2] = motionEasing.standard;
+const [ACC_X1, ACC_Y1, ACC_X2, ACC_Y2] = motionEasing.emphasizedAccelerate;
+
+const AMPLITUDE_DURATION = 500;
+
+/**
+ * Material Design 3 wavy linear progress indicator. The active indicator is drawn as a moving
+ * sine wave that flattens near the start and end of the progress. Supports determinate and
+ * indeterminate modes.
+ *
+ * ## Usage
+ * ```js
+ * import * as React from 'react';
+ * import { LinearWavyProgressIndicator } from 'react-native-paper';
+ *
+ * const MyComponent = () => (
+ *
+ * );
+ *
+ * export default MyComponent;
+ * ```
+ */
+const LinearWavyProgressIndicator = ({
+ progress = 0,
+ indeterminate = false,
+ color,
+ trackColor,
+ thickness = T.activeThickness,
+ amplitude,
+ wavelength,
+ waveSpeed,
+ style,
+ theme: themeOverrides,
+ testID = 'linear-wavy-progress-indicator',
+ accessibilityLabel,
+}: Props) => {
+ const theme = useInternalTheme(themeOverrides);
+ const { direction } = useLocale();
+ const reduceMotion = useReduceMotion();
+
+ const {
+ activeColor,
+ trackColor: resolvedTrackColor,
+ stopColor,
+ } = getProgressIndicatorColors({ theme, color, trackColor });
+
+ const [width, setWidth] = React.useState(0);
+
+ const { progressPercent, busy } = useProgressAccessibility(
+ progress,
+ indeterminate
+ );
+
+ const capWidth = thickness / 2;
+ // Amplitude is constant; the container grows so the wave plus stroke always fits
+ // (height = thickness + 2 * amplitude), matching the MD3 spec.
+ const maxAmplitude = amplitude ?? T.amplitude;
+ const containerHeight = Math.max(T.height, thickness + 2 * maxAmplitude);
+ const centerY = containerHeight / 2;
+ const gap = T.trackActiveSpace;
+ const stopSize = Math.min(thickness, T.stopSize);
+
+ const waveLength =
+ wavelength ??
+ (indeterminate ? T.wavelengthIndeterminate : T.wavelengthDeterminate);
+ const waveSpeedValue = waveSpeed ?? waveLength;
+
+ const phase = useIndeterminatePhase(indeterminate, CYCLE);
+ const waveOffset = useSharedValue(0);
+ const amp = useSharedValue(0);
+
+ // Slide the wave pattern at a steady speed; one wavelength per `duration`.
+ React.useEffect(() => {
+ if (reduceMotion) {
+ cancelAnimation(waveOffset);
+ waveOffset.value = 0;
+ return;
+ }
+ const duration = Math.max(50, (waveLength / waveSpeedValue) * 1000);
+ waveOffset.value = 0;
+ waveOffset.value = withRepeat(
+ withTiming(1, { duration, easing: Easing.linear }),
+ -1,
+ false
+ );
+ return () => cancelAnimation(waveOffset);
+ }, [reduceMotion, waveLength, waveSpeedValue, waveOffset]);
+
+ // Grow the wave once progress is underway and flatten it near the ends.
+ useAnimatedReaction(
+ () => {
+ if (reduceMotion) return 0;
+ if (indeterminate) return 1;
+ const p = Math.min(
+ 1,
+ Math.max(0, typeof progress === 'number' ? progress : progress.value)
+ );
+ return p > 0.1 && p < 0.95 ? 1 : 0;
+ },
+ (on, previous) => {
+ if (on === previous) return;
+ amp.value = withTiming(on ? maxAmplitude : 0, {
+ duration: AMPLITUDE_DURATION,
+ easing: on
+ ? Easing.bezier(STD_X1, STD_Y1, STD_X2, STD_Y2)
+ : Easing.bezier(ACC_X1, ACC_Y1, ACC_X2, ACC_Y2),
+ });
+ },
+ [reduceMotion, indeterminate, progress, maxAmplitude]
+ );
+
+ const onLayout = (event: LayoutChangeEvent) => {
+ setWidth(event.nativeEvent.layout.width);
+ };
+
+ const activeProps = useAnimatedProps(() => {
+ const shift = waveOffset.value * waveLength;
+ if (indeterminate) {
+ const elapsed = phase.value * CYCLE;
+ const min = capWidth;
+ const max = width - capWidth;
+ const clip = (value: number) => Math.min(max, Math.max(min, value));
+ const s1Tail = clip(
+ lineFraction(elapsed, LINES.firstTail.delay, LINES.firstTail.duration) *
+ width
+ );
+ const s1Head = clip(
+ lineFraction(elapsed, LINES.firstHead.delay, LINES.firstHead.duration) *
+ width
+ );
+ const s2Tail = clip(
+ lineFraction(
+ elapsed,
+ LINES.secondTail.delay,
+ LINES.secondTail.duration
+ ) * width
+ );
+ const s2Head = clip(
+ lineFraction(
+ elapsed,
+ LINES.secondHead.delay,
+ LINES.secondHead.duration
+ ) * width
+ );
+ return {
+ d:
+ buildLinearWavePath(
+ s1Tail,
+ s1Head,
+ centerY,
+ amp.value,
+ waveLength,
+ shift
+ ) +
+ buildLinearWavePath(
+ s2Tail,
+ s2Head,
+ centerY,
+ amp.value,
+ waveLength,
+ shift
+ ),
+ };
+ }
+ const p = Math.min(
+ 1,
+ Math.max(0, typeof progress === 'number' ? progress : progress.value)
+ );
+ const head = capWidth + p * (width - 2 * capWidth);
+ return {
+ d: buildLinearWavePath(
+ capWidth,
+ head,
+ centerY,
+ amp.value,
+ waveLength,
+ shift
+ ),
+ };
+ }, [indeterminate, progress, width, centerY, capWidth, waveLength]);
+
+ const trackProps = useAnimatedProps(() => {
+ const min = capWidth;
+ const max = width - capWidth;
+ const clip = (value: number) => Math.min(max, Math.max(min, value));
+ if (indeterminate) {
+ const elapsed = phase.value * CYCLE;
+ const firstHead =
+ lineFraction(elapsed, LINES.firstHead.delay, LINES.firstHead.duration) *
+ width;
+ const firstTail =
+ lineFraction(elapsed, LINES.firstTail.delay, LINES.firstTail.duration) *
+ width;
+ const secondHead =
+ lineFraction(
+ elapsed,
+ LINES.secondHead.delay,
+ LINES.secondHead.duration
+ ) * width;
+ const secondTail =
+ lineFraction(
+ elapsed,
+ LINES.secondTail.delay,
+ LINES.secondTail.duration
+ ) * width;
+ const right = buildStraightPath(
+ clip(firstHead > 0 ? firstHead + gap : min),
+ max,
+ centerY
+ );
+ const middle = buildStraightPath(
+ clip(secondHead > 0 ? secondHead + gap : min),
+ clip(firstTail < width ? firstTail - gap : max),
+ centerY
+ );
+ const left = buildStraightPath(min, clip(secondTail - gap), centerY);
+ return { d: left + middle + right };
+ }
+ const p = Math.min(
+ 1,
+ Math.max(0, typeof progress === 'number' ? progress : progress.value)
+ );
+ if (p <= 0) return { d: buildStraightPath(min, max, centerY) };
+ const head = capWidth + p * (width - 2 * capWidth);
+ const trackStart = head + gap + 2 * capWidth;
+ return { d: buildStraightPath(clip(trackStart), max, centerY) };
+ }, [indeterminate, progress, width, centerY, capWidth, gap]);
+
+ const stopProps = useAnimatedProps(() => {
+ const p = Math.min(
+ 1,
+ Math.max(0, typeof progress === 'number' ? progress : progress.value)
+ );
+ const head = capWidth + p * (width - 2 * capWidth);
+ const stopCenter = width - capWidth;
+ const overlap = Math.max(0, head + capWidth - (stopCenter - stopSize / 2));
+ return { r: Math.max(0, stopSize / 2 - overlap) };
+ }, [progress, width, capWidth, stopSize]);
+
+ const mirror =
+ direction === 'rtl' && width > 0
+ ? `translate(${width}, 0) scale(-1, 1)`
+ : undefined;
+
+ return (
+
+ {width > 0 ? (
+
+ ) : null}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ width: '100%',
+ justifyContent: 'center',
+ overflow: 'visible',
+ },
+});
+
+export default LinearWavyProgressIndicator;
diff --git a/src/components/ProgressIndicator/LinearWavyProgressIndicator/__tests__/LinearWavyProgressIndicator.test.tsx b/src/components/ProgressIndicator/LinearWavyProgressIndicator/__tests__/LinearWavyProgressIndicator.test.tsx
new file mode 100644
index 0000000000..0fbd7b9c02
--- /dev/null
+++ b/src/components/ProgressIndicator/LinearWavyProgressIndicator/__tests__/LinearWavyProgressIndicator.test.tsx
@@ -0,0 +1,102 @@
+import * as React from 'react';
+
+import { act } from '@testing-library/react-native';
+
+import { render } from '../../../../test-utils';
+import LinearWavyProgressIndicator from '../LinearWavyProgressIndicator';
+
+const a11yRole = 'progressbar';
+
+const layoutEvent = {
+ nativeEvent: { layout: { width: 100 } },
+};
+
+const triggerLayout = async (
+ tree: ReturnType
+): Promise => {
+ await act(async () => {
+ tree.getByRole(a11yRole).props.onLayout(layoutEvent);
+ });
+};
+
+it('renders determinate progress', async () => {
+ const tree = render();
+ await triggerLayout(tree);
+
+ expect(tree.toJSON()).toMatchSnapshot();
+});
+
+it('resolves active, track and stop colors', async () => {
+ const tree = render(
+
+ );
+ await triggerLayout(tree);
+
+ expect(tree.getByTestId('pi-active').props.stroke).toBe('red');
+ expect(tree.getByTestId('pi-track').props.stroke).toBe('blue');
+ expect(tree.getByTestId('pi-stop').props.fill).toBe('red');
+});
+
+it('renders the active and track strokes and no stop dot when indeterminate', async () => {
+ const tree = render(
+
+ );
+ await triggerLayout(tree);
+
+ expect(tree.getByTestId('pi-track')).toBeTruthy();
+ expect(tree.getByTestId('pi-active')).toBeTruthy();
+ expect(tree.queryByTestId('pi-stop')).toBeNull();
+ expect(tree.getByRole(a11yRole).props.accessibilityValue).toEqual({});
+});
+
+it('reports the determinate value', async () => {
+ const tree = render();
+ await triggerLayout(tree);
+
+ expect(tree.getByRole(a11yRole).props.accessibilityValue).toEqual({
+ min: 0,
+ max: 100,
+ now: 42,
+ });
+});
+
+it('is busy below 100% and not busy at 100% (determinate)', async () => {
+ const partial = render();
+ await triggerLayout(partial);
+ expect(partial.getByRole(a11yRole).props.accessibilityState).toEqual({
+ busy: true,
+ });
+
+ const complete = render();
+ await triggerLayout(complete);
+ expect(complete.getByRole(a11yRole).props.accessibilityState).toEqual({
+ busy: false,
+ });
+});
+
+it('is busy when indeterminate', async () => {
+ const tree = render();
+ await triggerLayout(tree);
+
+ expect(tree.getByRole(a11yRole).props.accessibilityState).toEqual({
+ busy: true,
+ });
+});
+
+it('shows the stop dot again when toggling indeterminate -> determinate', async () => {
+ const tree = render(
+
+ );
+ await triggerLayout(tree);
+ expect(tree.queryByTestId('pi-stop')).toBeNull();
+
+ tree.rerender();
+ await triggerLayout(tree);
+
+ expect(tree.getByTestId('pi-stop')).toBeTruthy();
+});
diff --git a/src/components/ProgressIndicator/LinearWavyProgressIndicator/__tests__/__snapshots__/LinearWavyProgressIndicator.test.tsx.snap b/src/components/ProgressIndicator/LinearWavyProgressIndicator/__tests__/__snapshots__/LinearWavyProgressIndicator.test.tsx.snap
new file mode 100644
index 0000000000..abf82c58a2
--- /dev/null
+++ b/src/components/ProgressIndicator/LinearWavyProgressIndicator/__tests__/__snapshots__/LinearWavyProgressIndicator.test.tsx.snap
@@ -0,0 +1,78 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders determinate progress 1`] = `
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/components/ProgressIndicator/LinearWavyProgressIndicator/index.tsx b/src/components/ProgressIndicator/LinearWavyProgressIndicator/index.tsx
new file mode 100644
index 0000000000..73e705f283
--- /dev/null
+++ b/src/components/ProgressIndicator/LinearWavyProgressIndicator/index.tsx
@@ -0,0 +1,2 @@
+export { default } from './LinearWavyProgressIndicator';
+export type { Props } from './LinearWavyProgressIndicator';
diff --git a/src/components/ProgressIndicator/LinearWavyProgressIndicator/tokens.ts b/src/components/ProgressIndicator/LinearWavyProgressIndicator/tokens.ts
new file mode 100644
index 0000000000..5c196f7db5
--- /dev/null
+++ b/src/components/ProgressIndicator/LinearWavyProgressIndicator/tokens.ts
@@ -0,0 +1,10 @@
+export const LinearWavyProgressIndicatorTokens = {
+ height: 10,
+ activeThickness: 4,
+ trackThickness: 4,
+ stopSize: 4,
+ trackActiveSpace: 4,
+ amplitude: 3,
+ wavelengthDeterminate: 40,
+ wavelengthIndeterminate: 20,
+} as const;
diff --git a/src/components/ProgressIndicator/circularIndeterminateSpecs.ts b/src/components/ProgressIndicator/circularIndeterminateSpecs.ts
new file mode 100644
index 0000000000..edd905a791
--- /dev/null
+++ b/src/components/ProgressIndicator/circularIndeterminateSpecs.ts
@@ -0,0 +1,72 @@
+/**
+ * Timing for the indeterminate circular spinner. Three motions share one 6000ms clock:
+ * - global rotation: linear 0 -> 1080deg,
+ * - extra rotation: four 90deg steps (each a 300ms move, then a pause), 0 -> 360deg,
+ * - sweep length: grows 0.1 -> 0.87 of a turn, then shrinks back.
+ * Total rotation per clock is 1080 + 360 = 1440deg (four full turns), so the loop has no visible
+ * seam. Everything reads the same `elapsed`, so the spin and the grow/shrink stay in sync.
+ */
+import { Easing } from 'react-native-reanimated';
+
+import { motionEasing } from '../../theme/tokens/sys/motion';
+
+export const CIRCULAR_INDETERMINATE_DURATION = 6000;
+
+const GLOBAL_ROTATION_TARGET = 1080;
+const ADDITIONAL_ROTATION_TARGET = 360;
+
+const STEP_MOVE_DURATION = 300;
+const STEP_INTERVAL = 1500;
+const STEP_DEGREES = 90;
+const STEP_COUNT = ADDITIONAL_ROTATION_TARGET / STEP_DEGREES; // 4
+
+const MIN_PROGRESS = 0.1;
+const MAX_PROGRESS = 0.87;
+
+const [STD_X1, STD_Y1, STD_X2, STD_Y2] = motionEasing.standard;
+
+const clamp01 = (value: number) => {
+ 'worklet';
+ return Math.min(1, Math.max(0, value));
+};
+
+const easeStandard = (x: number) => {
+ 'worklet';
+ return Easing.bezierFn(STD_X1, STD_Y1, STD_X2, STD_Y2)(x);
+};
+
+const globalRotation = (elapsed: number) => {
+ 'worklet';
+ return (elapsed / CIRCULAR_INDETERMINATE_DURATION) * GLOBAL_ROTATION_TARGET;
+};
+
+const additionalRotation = (elapsed: number) => {
+ 'worklet';
+ const step = Math.min(Math.floor(elapsed / STEP_INTERVAL), STEP_COUNT - 1);
+ const local = elapsed - step * STEP_INTERVAL;
+ const moved = clamp01(local / STEP_MOVE_DURATION);
+ return (step + moved) * STEP_DEGREES;
+};
+
+const progressFraction = (elapsed: number) => {
+ 'worklet';
+ const half = CIRCULAR_INDETERMINATE_DURATION / 2;
+ const span = MAX_PROGRESS - MIN_PROGRESS;
+ if (elapsed <= half) {
+ return MIN_PROGRESS + span * (elapsed / half);
+ }
+ return MAX_PROGRESS - span * easeStandard((elapsed - half) / half);
+};
+
+/**
+ * Arc shape at `elapsed` ms into the clock. Returns the start angle (degrees, clockwise) and the
+ * visible sweep length in degrees. The arc spins, so its start angle is just the rotation.
+ */
+export function circularArc(elapsed: number) {
+ 'worklet';
+ const t = elapsed % CIRCULAR_INDETERMINATE_DURATION;
+ return {
+ startAngle: globalRotation(t) + additionalRotation(t),
+ sweepAngle: progressFraction(t) * 360,
+ };
+}
diff --git a/src/components/ProgressIndicator/hooks.ts b/src/components/ProgressIndicator/hooks.ts
new file mode 100644
index 0000000000..7c8400221c
--- /dev/null
+++ b/src/components/ProgressIndicator/hooks.ts
@@ -0,0 +1,78 @@
+import * as React from 'react';
+
+import {
+ cancelAnimation,
+ Easing,
+ type SharedValue,
+ useAnimatedReaction,
+ useSharedValue,
+ withRepeat,
+ withTiming,
+} from 'react-native-reanimated';
+import { scheduleOnRN } from 'react-native-worklets';
+
+const clamp = (value: number) => Math.min(1, Math.max(0, value));
+
+/**
+ * One clock that drives every indeterminate animation. Returns a `phase` SharedValue that
+ * loops `0 -> 1` over `duration` ms (linear) while `indeterminate`, and holds `0` otherwise.
+ * Deriving every edge/angle from a single clock keeps the moving pieces in sync.
+ */
+export const useIndeterminatePhase = (
+ indeterminate: boolean,
+ duration: number
+): SharedValue => {
+ const phase = useSharedValue(0);
+
+ React.useEffect(() => {
+ if (!indeterminate) {
+ cancelAnimation(phase);
+ phase.value = 0;
+ return;
+ }
+
+ phase.value = 0;
+ phase.value = withRepeat(
+ withTiming(1, { duration, easing: Easing.linear }),
+ -1,
+ false
+ );
+
+ return () => cancelAnimation(phase);
+ }, [indeterminate, duration, phase]);
+
+ return phase;
+};
+
+/**
+ * Whole-percent progress and busy state for the accessibility value. A numeric `progress`
+ * maps directly; an animated SharedValue is mirrored to React state via a determinate-gated
+ * reaction so it stays correct without re-rendering every frame.
+ */
+export const useProgressAccessibility = (
+ progress: number | SharedValue,
+ indeterminate: boolean
+): { progressPercent: number; busy: boolean } => {
+ const numberProgress = typeof progress === 'number' ? clamp(progress) : null;
+ const [animatedPercent, setAnimatedPercent] = React.useState(0);
+ const progressPercent =
+ numberProgress !== null
+ ? Math.round(numberProgress * 100)
+ : animatedPercent;
+ const busy = indeterminate || progressPercent < 100;
+
+ useAnimatedReaction(
+ () => {
+ if (indeterminate || typeof progress === 'number') return -1;
+ return Math.round(Math.min(1, Math.max(0, progress.value)) * 100);
+ },
+ (current, previous) => {
+ if (current !== -1 && current !== previous) {
+ scheduleOnRN(setAnimatedPercent, current);
+ }
+ },
+ [progress, indeterminate]
+ );
+
+ return { progressPercent, busy };
+};
diff --git a/src/components/ProgressIndicator/indeterminateSpecs.ts b/src/components/ProgressIndicator/indeterminateSpecs.ts
new file mode 100644
index 0000000000..2ed2bfd513
--- /dev/null
+++ b/src/components/ProgressIndicator/indeterminateSpecs.ts
@@ -0,0 +1,37 @@
+/**
+ * MD3 timing for the indeterminate linear progress animation. Shared by the linear
+ * progress indicators.
+ *
+ * One 1750ms cycle moves two lines. Each line has a head and a tail edge. An edge waits
+ * for its `delay`, eases from 0 to 1 over its `duration`, then stays at 1 until the cycle
+ * repeats. The head is always ahead of the tail, so a line covers the range [tail, head].
+ */
+import { Easing } from 'react-native-reanimated';
+
+import { motionEasing } from '../../theme/tokens/sys/motion';
+
+export const LINEAR_INDETERMINATE_DURATION = 1750;
+
+export const LINEAR_INDETERMINATE_LINES = {
+ firstHead: { delay: 0, duration: 1000 },
+ firstTail: { delay: 250, duration: 1000 },
+ secondHead: { delay: 650, duration: 850 },
+ secondTail: { delay: 900, duration: 850 },
+} as const;
+
+const [ACCEL_X1, ACCEL_Y1, ACCEL_X2, ACCEL_Y2] =
+ motionEasing.emphasizedAccelerate;
+
+/**
+ * Eased 0..1 position of one line edge, `elapsed` ms into the
+ * {@link LINEAR_INDETERMINATE_DURATION} cycle. All edges read the same `elapsed` (one clock),
+ * which keeps them in sync. Separate timers would drift by a frame at the cycle boundary and
+ * flash a full-width segment.
+ */
+export function lineFraction(elapsed: number, delay: number, duration: number) {
+ 'worklet';
+ if (elapsed <= delay) return 0;
+ const local = (elapsed - delay) / duration;
+ if (local >= 1) return 1;
+ return Easing.bezierFn(ACCEL_X1, ACCEL_Y1, ACCEL_X2, ACCEL_Y2)(local);
+}
diff --git a/src/components/ProgressIndicator/utils.ts b/src/components/ProgressIndicator/utils.ts
new file mode 100644
index 0000000000..51803aa64b
--- /dev/null
+++ b/src/components/ProgressIndicator/utils.ts
@@ -0,0 +1,31 @@
+import type { ColorValue } from 'react-native';
+
+import type { InternalTheme } from '../../types';
+
+export type ProgressIndicatorColors = {
+ activeColor: ColorValue;
+ trackColor: ColorValue;
+ stopColor: ColorValue;
+};
+
+/**
+ * Resolves the colors shared by every progress indicator. A passed `color` / `trackColor`
+ * wins; otherwise the default MD3 roles apply: `primary` for the active and stop parts,
+ * `secondaryContainer` for the track.
+ */
+export const getProgressIndicatorColors = ({
+ theme,
+ color,
+ trackColor,
+}: {
+ theme: InternalTheme;
+ color?: ColorValue;
+ trackColor?: ColorValue;
+}): ProgressIndicatorColors => {
+ const activeColor = color || theme.colors.primary;
+ return {
+ activeColor,
+ trackColor: trackColor || theme.colors.secondaryContainer,
+ stopColor: activeColor,
+ };
+};
diff --git a/src/components/ProgressIndicator/wavePath.ts b/src/components/ProgressIndicator/wavePath.ts
new file mode 100644
index 0000000000..8417e5e9e4
--- /dev/null
+++ b/src/components/ProgressIndicator/wavePath.ts
@@ -0,0 +1,154 @@
+/**
+ * Worklet path builders for the wavy progress indicators. They run inside `useAnimatedProps`
+ * on the UI thread, so they must stay self-contained (no outside captures beyond their args)
+ * and return an SVG `d` string. Coordinates are rounded to 2dp to keep the string small.
+ */
+
+const round = (value: number) => {
+ 'worklet';
+ return Math.round(value * 100) / 100;
+};
+
+export const NO_DRAW_PATH = 'M0,0';
+
+/**
+ * Straight horizontal segment from `x0` to `x1` at height `y`. Used for the track and as the
+ * flat fallback when the wave amplitude is 0.
+ */
+export function buildStraightPath(x0: number, x1: number, y: number): string {
+ 'worklet';
+ if (x1 <= x0) return NO_DRAW_PATH;
+ return `M${round(x0)},${round(y)}L${round(x1)},${round(y)}`;
+}
+
+/**
+ * Wavy segment from `tailX` to `headX`, centered on `centerY`. The wave is a chain of quadratic
+ * curves, two per `wavelength`: anchors sit on the centerline every half wavelength and control
+ * points sit at the quarter points, with the bump direction flipping each half wave. A quadratic
+ * peaks at half its control height, so the control offset is `2 * amplitude` to make the visible
+ * peak equal `amplitude`.
+ *
+ * The wave slides by `waveShift` (px): the anchor grid is `(x + waveShift) / halfWave`, so the
+ * pattern moves under a fixed window. Only the visible `[tailX, headX]` range is emitted; the
+ * boundary cells are cut precisely. Because each cell's control point sits at the midpoint in x,
+ * x is linear in the curve parameter, so a target x maps to a parameter by simple interpolation.
+ *
+ * `amplitude <= 0` returns a straight line (track / wave switched off / reduce motion).
+ */
+export function buildLinearWavePath(
+ tailX: number,
+ headX: number,
+ centerY: number,
+ amplitude: number,
+ wavelength: number,
+ waveShift: number
+): string {
+ 'worklet';
+ if (headX <= tailX) return NO_DRAW_PATH;
+ if (amplitude <= 0) return buildStraightPath(tailX, headX, centerY);
+
+ const halfWave = wavelength / 2;
+ const controlY = amplitude * 2;
+
+ let cell = Math.floor((tailX + waveShift) / halfWave);
+ let x0 = cell * halfWave - waveShift;
+ let d = '';
+ let started = false;
+
+ while (x0 < headX) {
+ const x2 = x0 + halfWave;
+ // Bump direction alternates per cell; negative is up in the y-down SVG space.
+ const sign = (((cell % 2) + 2) % 2 === 0 ? -1 : 1) * controlY;
+ const cx = x0 + halfWave / 2;
+ const cy = centerY + sign;
+
+ // Clip this cell to the visible window via the linear x->parameter mapping.
+ const t0 = Math.max(0, (tailX - x0) / halfWave);
+ const t1 = Math.min(1, (headX - x0) / halfWave);
+
+ // Quadratic with P0=(x0,centerY), P1=(cx,cy), P2=(x2,centerY). Endpoints come from the
+ // curve at t0/t1; the sub-segment control point comes from the blossom of (t0, t1).
+ const a0 = 1 - t0;
+ const a1 = 1 - t1;
+ const startX = a0 * a0 * x0 + 2 * a0 * t0 * cx + t0 * t0 * x2;
+ const startY = a0 * a0 * centerY + 2 * a0 * t0 * cy + t0 * t0 * centerY;
+ const endX = a1 * a1 * x0 + 2 * a1 * t1 * cx + t1 * t1 * x2;
+ const endY = a1 * a1 * centerY + 2 * a1 * t1 * cy + t1 * t1 * centerY;
+ const w0 = a0 * a1;
+ const w1 = a0 * t1 + t0 * a1;
+ const w2 = t0 * t1;
+ const ctrlX = w0 * x0 + w1 * cx + w2 * x2;
+ const ctrlY = w0 * centerY + w1 * cy + w2 * centerY;
+
+ if (!started) {
+ d += `M${round(startX)},${round(startY)}`;
+ started = true;
+ }
+ d += `Q${round(ctrlX)},${round(ctrlY)} ${round(endX)},${round(endY)}`;
+
+ cell += 1;
+ x0 = x2;
+ }
+
+ return d;
+}
+
+const SAMPLES_PER_WAVE = 16;
+
+/**
+ * Wavy arc centered on `(cx, cy)`, running clockwise from `startAngleDeg` for `sweepDeg` degrees.
+ * The wave is a closed polar sinusoid `r = radius + amplitude * sin(waveCount * theta + phase)`,
+ * sampled as a polyline. `waveCount` must be an integer so the pattern joins seamlessly around the
+ * full circle. Motion comes from advancing `phase`.
+ *
+ * `amplitude <= 0` returns a plain arc (track / wave switched off / reduce motion).
+ */
+export function buildArcWavePath(
+ cx: number,
+ cy: number,
+ radius: number,
+ startAngleDeg: number,
+ sweepDeg: number,
+ amplitude: number,
+ waveCount: number,
+ phase: number
+): string {
+ 'worklet';
+ if (sweepDeg <= 0) return NO_DRAW_PATH;
+ // Stop just short of a full turn: a 360deg arc would close on itself (the open path's round caps
+ // would snap to a seamless join, and a single SVG arc with coincident endpoints draws nothing).
+ // The round caps cover the hairline gap, so the ring still reads as complete, with no jump.
+ const sweep = Math.min(sweepDeg, 359.5);
+
+ const toRad = Math.PI / 180;
+ const startRad = startAngleDeg * toRad;
+ const sweepRad = sweep * toRad;
+
+ if (amplitude <= 0) {
+ const x0 = cx + radius * Math.cos(startRad);
+ const y0 = cy + radius * Math.sin(startRad);
+ const r = round(radius);
+ const endRad = startRad + sweepRad;
+ const x1 = cx + radius * Math.cos(endRad);
+ const y1 = cy + radius * Math.sin(endRad);
+ const largeArc = sweep > 180 ? 1 : 0;
+ return `M${round(x0)},${round(y0)}A${r},${r} 0 ${largeArc} 1 ${round(
+ x1
+ )},${round(y1)}`;
+ }
+
+ const steps = Math.max(
+ 2,
+ Math.ceil((sweepRad / (2 * Math.PI)) * waveCount * SAMPLES_PER_WAVE)
+ );
+ const step = sweepRad / steps;
+ let d = '';
+ for (let i = 0; i <= steps; i++) {
+ const theta = startRad + i * step;
+ const r = radius + amplitude * Math.sin(waveCount * theta + phase);
+ const x = cx + r * Math.cos(theta);
+ const y = cy + r * Math.sin(theta);
+ d += `${i === 0 ? 'M' : 'L'}${round(x)},${round(y)}`;
+ }
+ return d;
+}
diff --git a/src/components/Searchbar.tsx b/src/components/Searchbar.tsx
index 975af841a6..91d98bf598 100644
--- a/src/components/Searchbar.tsx
+++ b/src/components/Searchbar.tsx
@@ -13,11 +13,11 @@ import {
ViewStyle,
} from 'react-native';
-import ActivityIndicator from './ActivityIndicator';
import Divider from './Divider';
import type { IconSource } from './Icon';
import IconButton from './IconButton/IconButton';
import MaterialCommunityIcon from './MaterialCommunityIcon';
+import CircularProgressIndicator from './ProgressIndicator/CircularProgressIndicator';
import Surface from './Surface';
import { useLocale } from '../core/locale';
import { useInternalTheme } from '../core/theming';
@@ -301,8 +301,9 @@ const Searchbar = forwardRef(
{...rest}
/>
{loading ? (
-
) : (
diff --git a/src/components/__tests__/ActivityIndicator.test.tsx b/src/components/__tests__/ActivityIndicator.test.tsx
deleted file mode 100644
index 1b276f9234..0000000000
--- a/src/components/__tests__/ActivityIndicator.test.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import * as React from 'react';
-
-import { render } from '../../test-utils';
-import ActivityIndicator from '../ActivityIndicator';
-
-it('renders indicator', () => {
- const tree = render().toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('renders hidden indicator', () => {
- const tree = render(
-
- ).toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('renders large indicator', () => {
- const tree = render().toJSON();
-
- expect(tree).toMatchSnapshot();
-});
-
-it('renders colored indicator', () => {
- const tree = render().toJSON();
-
- expect(tree).toMatchSnapshot();
-});
diff --git a/src/components/__tests__/ProgressBar.test.tsx b/src/components/__tests__/ProgressBar.test.tsx
deleted file mode 100644
index 0a3445b601..0000000000
--- a/src/components/__tests__/ProgressBar.test.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import * as React from 'react';
-import { Animated, Platform, StyleSheet } from 'react-native';
-
-import { act } from '@testing-library/react-native';
-
-import { render } from '../../test-utils';
-import ProgressBar, { Props } from '../ProgressBar';
-
-const layoutEvent = {
- nativeEvent: {
- layout: {
- width: 100,
- },
- },
-};
-
-const styles = StyleSheet.create({
- fill: {
- borderRadius: 4,
- },
-});
-
-const a11yRole = 'progressbar';
-const triggerLayout = async (
- tree: ReturnType
-): Promise => {
- await act(async () => {
- tree.getByRole(a11yRole).props.onLayout(layoutEvent);
- });
-};
-
-class ClassProgressBar extends React.Component {
- render() {
- return ;
- }
-}
-
-const AnimatedProgressBar = Animated.createAnimatedComponent(ClassProgressBar);
-
-afterEach(() => {
- Platform.OS = 'ios';
-});
-
-it('renders progress bar with animated value', async () => {
- const tree = render();
- await triggerLayout(tree);
-
- tree.update();
-
- expect(tree.getByRole(a11yRole)).toBeTruthy();
-});
-
-it('renders progress bar with specific progress', async () => {
- const tree = render();
- await triggerLayout(tree);
-
- expect(tree.toJSON()).toMatchSnapshot();
-});
-
-it('renders hidden progress bar', async () => {
- const tree = render();
- await triggerLayout(tree);
-
- expect(tree.toJSON()).toMatchSnapshot();
-});
-
-it('renders colored progress bar', async () => {
- const tree = render();
- await triggerLayout(tree);
-
- expect(tree.toJSON()).toMatchSnapshot();
-});
-
-it('renders indeterminate progress bar', async () => {
- const tree = render();
- await triggerLayout(tree);
-
- expect(tree.toJSON()).toMatchSnapshot();
-});
-
-it('renders progress bar with full height on web', () => {
- Platform.OS = 'web';
- const tree = render();
-
- expect(tree.getByRole(a11yRole)).toHaveStyle({
- width: '100%',
- height: '100%',
- });
-});
-
-it('renders progress bar with custom style of filled part', async () => {
- const tree = render(
-
- );
- await triggerLayout(tree);
-
- expect(tree.getByTestId('progress-bar-fill')).toHaveStyle({
- borderRadius: 4,
- });
-});
diff --git a/src/components/__tests__/Searchbar.test.tsx b/src/components/__tests__/Searchbar.test.tsx
index 255225f7ae..16417b1c03 100644
--- a/src/components/__tests__/Searchbar.test.tsx
+++ b/src/components/__tests__/Searchbar.test.tsx
@@ -21,22 +21,22 @@ it('renders with text', () => {
expect(tree).toMatchSnapshot();
});
-it('activity indicator snapshot test', () => {
+it('loading indicator snapshot test', () => {
const tree = render().toJSON();
expect(tree).toMatchSnapshot();
});
-it('renders with ActivityIndicator', () => {
+it('renders the loading indicator', () => {
const tree = render();
- expect(tree.getByTestId('activity-indicator')).toBeTruthy();
+ expect(tree.getByTestId('loading-indicator')).toBeTruthy();
});
-it('renders without ActivityIndicator', () => {
+it('renders without the loading indicator', () => {
const { getByTestId } = render();
- expect(() => getByTestId('activity-indicator')).toThrow();
+ expect(() => getByTestId('loading-indicator')).toThrow();
});
it('renders clear icon with custom color', () => {
diff --git a/src/components/__tests__/__snapshots__/ActivityIndicator.test.tsx.snap b/src/components/__tests__/__snapshots__/ActivityIndicator.test.tsx.snap
deleted file mode 100644
index 43b4eac37e..0000000000
--- a/src/components/__tests__/__snapshots__/ActivityIndicator.test.tsx.snap
+++ /dev/null
@@ -1,793 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders colored indicator 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`renders hidden indicator 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`renders indicator 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`renders large indicator 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
diff --git a/src/components/__tests__/__snapshots__/Button.test.tsx.snap b/src/components/__tests__/__snapshots__/Button.test.tsx.snap
index 992205a103..0ea807ab17 100644
--- a/src/components/__tests__/__snapshots__/Button.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/Button.test.tsx.snap
@@ -1576,12 +1576,13 @@ exports[`renders loading button 1`] = `
"busy": true,
}
}
+ accessibilityValue={{}}
accessible={true}
style={
[
{
- "alignItems": "center",
- "justifyContent": "center",
+ "height": 18,
+ "width": 18,
},
[
{
@@ -1599,181 +1600,54 @@ exports[`renders loading button 1`] = `
],
]
}
+ testID="circular-progress-indicator"
>
-
-
-
-
-
-
-
-
-
-
-
+ "rotate": "0deg",
+ },
+ ],
+ },
+ ]
+ }
+ >
-
-
-
-
-
-
-
-
+ cx={9}
+ cy={9}
+ fill="none"
+ r={8.1}
+ stroke="rgba(103, 80, 164, 1)"
+ strokeDasharray={
+ [
+ 50.893800988154645,
+ 50.893800988154645,
+ ]
+ }
+ strokeLinecap="round"
+ strokeWidth={1.8}
+ testID="circular-progress-indicator-active"
+ />
diff --git a/src/components/__tests__/__snapshots__/ProgressBar.test.tsx.snap b/src/components/__tests__/__snapshots__/ProgressBar.test.tsx.snap
deleted file mode 100644
index ae5c19ff75..0000000000
--- a/src/components/__tests__/__snapshots__/ProgressBar.test.tsx.snap
+++ /dev/null
@@ -1,211 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders colored progress bar 1`] = `
-
-
-
-
-
-`;
-
-exports[`renders hidden progress bar 1`] = `
-
-
-
-
-
-`;
-
-exports[`renders indeterminate progress bar 1`] = `
-
-
-
-
-
-`;
-
-exports[`renders progress bar with specific progress 1`] = `
-
-
-
-
-
-`;
diff --git a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap
index bbe1779a66..b65d99a9cf 100644
--- a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`activity indicator snapshot test 1`] = `
+exports[`loading indicator snapshot test 1`] = `
-
-
-
-
-
-
-
-
-
-
-
+ "rotate": "0deg",
+ },
+ ],
+ },
+ ]
+ }
+ >
-
-
-
-
-
-
-
-
+ cx={20}
+ cy={20}
+ fill="none"
+ r={18}
+ stroke="rgba(103, 80, 164, 1)"
+ strokeDasharray={
+ [
+ 113.09733552923255,
+ 113.09733552923255,
+ ]
+ }
+ strokeLinecap="round"
+ strokeWidth={4}
+ testID="loading-indicator-active"
+ />
diff --git a/src/index.tsx b/src/index.tsx
index 8863e2fa20..26e7617571 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -22,23 +22,25 @@ import * as List from './components/List/List';
export { Avatar, List, Drawer };
export { default as Badge } from './components/Badge';
-export { default as ActivityIndicator } from './components/ActivityIndicator';
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 Checkbox } from './components/Checkbox';
export { default as Chip } from './components/Chip/Chip';
+export { default as CircularProgressIndicator } from './components/ProgressIndicator/CircularProgressIndicator/CircularProgressIndicator';
+export { default as CircularWavyProgressIndicator } from './components/ProgressIndicator/CircularWavyProgressIndicator/CircularWavyProgressIndicator';
export { default as DataTable } from './components/DataTable/DataTable';
export { default as Dialog } from './components/Dialog/Dialog';
export { default as Divider } from './components/Divider';
export { default as FAB } from './components/FAB';
export { default as Icon } from './components/Icon';
export { default as IconButton } from './components/IconButton/IconButton';
+export { default as LinearProgressIndicator } from './components/ProgressIndicator/LinearProgressIndicator/LinearProgressIndicator';
+export { default as LinearWavyProgressIndicator } from './components/ProgressIndicator/LinearWavyProgressIndicator/LinearWavyProgressIndicator';
export { default as Menu } from './components/Menu/Menu';
export { default as Modal } from './components/Modal';
export { default as Portal } from './components/Portal/Portal';
-export { default as ProgressBar } from './components/ProgressBar';
export { default as RadioButton } from './components/RadioButton';
export { default as Searchbar } from './components/Searchbar';
export { default as Snackbar } from './components/Snackbar';
@@ -54,7 +56,6 @@ export { default as Tooltip } from './components/Tooltip/Tooltip';
export { default as Text, customText } from './components/Typography/Text';
// Types
-export type { Props as ActivityIndicatorProps } from './components/ActivityIndicator';
export type { Props as AppbarProps } from './components/Appbar/Appbar';
export type { Props as AppbarActionProps } from './components/Appbar/AppbarAction';
export type { Props as AppbarBackActionProps } from './components/Appbar/AppbarBackAction';
@@ -78,6 +79,8 @@ export type { Props as CardTitleProps } from './components/Card/CardTitle';
export type { Props as CheckboxProps } from './components/Checkbox/Checkbox';
export type { Props as CheckboxItemProps } from './components/Checkbox/CheckboxItem';
export type { Props as ChipProps } from './components/Chip/Chip';
+export type { Props as CircularProgressIndicatorProps } from './components/ProgressIndicator/CircularProgressIndicator/CircularProgressIndicator';
+export type { Props as CircularWavyProgressIndicatorProps } from './components/ProgressIndicator/CircularWavyProgressIndicator/CircularWavyProgressIndicator';
export type { Props as DataTableProps } from './components/DataTable/DataTable';
export type { Props as DataTableCellProps } from './components/DataTable/DataTableCell';
export type { Props as DataTableHeaderProps } from './components/DataTable/DataTableHeader';
@@ -106,6 +109,8 @@ export type {
Size as FABSize,
} from './components/FAB/tokens';
export type { Props as IconButtonProps } from './components/IconButton/IconButton';
+export type { Props as LinearProgressIndicatorProps } from './components/ProgressIndicator/LinearProgressIndicator/LinearProgressIndicator';
+export type { Props as LinearWavyProgressIndicatorProps } from './components/ProgressIndicator/LinearWavyProgressIndicator/LinearWavyProgressIndicator';
export type { Props as ListAccordionProps } from './components/List/ListAccordion';
export type { Props as ListAccordionGroupProps } from './components/List/ListAccordionGroup';
export type { Props as ListIconProps } from './components/List/ListIcon';
@@ -117,7 +122,6 @@ export type { Props as MenuItemProps } from './components/Menu/MenuItem';
export type { Props as ModalProps } from './components/Modal';
export type { Props as PortalProps } from './components/Portal/Portal';
export type { Props as PortalHostProps } from './components/Portal/PortalHost';
-export type { Props as ProgressBarProps } from './components/ProgressBar';
export type { Props as ProviderProps } from './core/PaperProvider';
export type { Props as RadioButtonProps } from './components/RadioButton/RadioButton';
export type { Props as RadioButtonAndroidProps } from './components/RadioButton/RadioButtonAndroid';
diff --git a/yarn.lock b/yarn.lock
index 07931713e5..b12667310b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -20587,6 +20587,7 @@ __metadata:
react-native-reanimated: "npm:4.3.1"
react-native-safe-area-context: "npm:~5.7.0"
react-native-screens: "npm:4.25.1"
+ react-native-svg: "npm:15.15.4"
react-native-web: "npm:^0.21.0"
react-native-worklets: "npm:0.8.3"
languageName: unknown
@@ -20641,6 +20642,7 @@ __metadata:
react-native-builder-bob: "npm:^0.41.0"
react-native-reanimated: "npm:4.3.1"
react-native-safe-area-context: "npm:5.7.0"
+ react-native-svg: "npm:15.15.4"
react-native-worklets: "npm:0.8.3"
react-test-renderer: "npm:19.2.3"
release-it: "npm:^13.4.0"
@@ -20652,6 +20654,7 @@ __metadata:
react-native: "*"
react-native-reanimated: ">=4.3.0"
react-native-safe-area-context: "*"
+ react-native-svg: ">=15.0.0"
react-native-worklets: ">=0.8.1"
languageName: unknown
linkType: soft
@@ -20703,6 +20706,20 @@ __metadata:
languageName: node
linkType: hard
+"react-native-svg@npm:15.15.4":
+ version: 15.15.4
+ resolution: "react-native-svg@npm:15.15.4"
+ dependencies:
+ css-select: "npm:^5.1.0"
+ css-tree: "npm:^1.1.3"
+ warn-once: "npm:0.1.1"
+ peerDependencies:
+ react: "*"
+ react-native: "*"
+ checksum: 10c0/1fb8e3ac9d45a4db74731a006cd32f883051844f361974dff49e1a4142aa7c1a0d87e0b04fff06a1932ca53940bcfb94e45e01a845eb451d4659fbf07092629e
+ languageName: node
+ linkType: hard
+
"react-native-web@npm:^0.18.12":
version: 0.18.12
resolution: "react-native-web@npm:0.18.12"
@@ -24749,7 +24766,7 @@ __metadata:
languageName: node
linkType: hard
-"warn-once@npm:^0.1.0, warn-once@npm:^0.1.1":
+"warn-once@npm:0.1.1, warn-once@npm:^0.1.0, warn-once@npm:^0.1.1":
version: 0.1.1
resolution: "warn-once@npm:0.1.1"
checksum: 10c0/f531e7b2382124f51e6d8f97b8c865246db8ab6ff4e53257a2d274e0f02b97d7201eb35db481843dc155815e154ad7afb53b01c4d4db15fb5aa073562496aff7