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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/demo/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ const App = () => {
name="components/Progress"
options={{ title: "Progress" }}
/>
<Stack.Screen
name="components/CircleProgress"
options={{ title: "Circle Progress" }}
/>
<Stack.Screen
name="components/Skeleton"
options={{ title: "Skeleton" }}
Expand Down
48 changes: 48 additions & 0 deletions apps/demo/app/components/CircleProgress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { ScrollView, View } from 'react-native';
import {
CircleProgress,
AnimatedCircleProgress,
Text,
Button,
useTheme,
} from '@leshi/ui-rn';
import { useState } from 'react';

export default function CircleProgressScreen() {
const [value, setValue] = useState(30);
const { colors } = useTheme();
return (
<ScrollView contentContainerStyle={{ padding: 16 }}>
<Text weight="bold" size="xl" style={{ marginBottom: 4 }}>
Circle Progress
</Text>
<Text style={{ marginBottom: 12 }}>
Circular indicator built with react-native-svg.
</Text>
<View style={{ alignItems: 'center', marginBottom: 16 }}>
<AnimatedCircleProgress
size={120}
width={12}
fill={value}
tintColor={colors.primary}
backgroundColor={colors.muted}
>{(fill) => <Text>{Math.round(fill)}</Text>}</AnimatedCircleProgress>
<View style={{ flexDirection: 'row', marginTop: 12 }}>
<Button text="-" size="icon" onPress={() => setValue((v) => Math.max(0, v - 10))} style={{ marginRight: 8 }} />
<Button text="+" size="icon" onPress={() => setValue((v) => Math.min(100, v + 10))} />
</View>
</View>
<Text weight="bold" size="lg" style={{ marginBottom: 8 }}>
Static
</Text>
<CircleProgress
size={100}
width={10}
fill={75}
tintColor={colors.primary}
backgroundColor={colors.muted}
style={{ marginBottom: 12 }}
/>
</ScrollView>
);
}
1 change: 1 addition & 0 deletions apps/demo/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const componentScreens = [
{ name: "Dialog", href: "/components/Dialog" },
{ name: "Modal", href: "/components/Modal" },
{ name: "Progress", href: "/components/Progress" },
{ name: "CircleProgress", href: "/components/CircleProgress" },
{ name: "Skeleton", href: "/components/Skeleton" },
{ name: "Surface", href: "/components/Surface" },
{ name: "Switch", href: "/components/Switch" },
Expand Down
8 changes: 8 additions & 0 deletions component-notes.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@
],
"example": "import { Progress } from './components/ui/progress';\n\n<Progress value={60} max={100} />\n<Progress value={progress} variant=\"success\" />"
},
"circle-progress": {
"dependencies": [],
"externalDeps": ["react-native-svg", "react-native-reanimated"],
"setup": [
"Install external dependency: bun add react-native-svg react-native-reanimated"
],
"example": "import { CircleProgress } from './components/ui/circle-progress';\n\n<CircleProgress size={120} width={12} fill={75} />"
},
"switch": {
"dependencies": [],
"externalDeps": ["react-native-reanimated"],
Expand Down
14 changes: 14 additions & 0 deletions packages/rn/@types/react-native-svg/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
declare module 'react-native-svg' {
import * as React from 'react';
import { ViewProps } from 'react-native';

export interface SvgProps extends ViewProps {
width?: number | string;
height?: number | string;
}

export const Svg: React.FC<SvgProps>;
export const Path: React.FC<any>;
export const G: React.FC<any>;
export default Svg;
}
220 changes: 220 additions & 0 deletions packages/rn/components/ui/circle-progress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import React, { useEffect, useState } from 'react';
import { View, StyleSheet } from 'react-native';
import type { ViewProps, StyleProp, ViewStyle } from 'react-native';
import Svg, { G, Path } from 'react-native-svg';
import Animated, {
useSharedValue,
withTiming,
withDelay,
useAnimatedProps,
useAnimatedReaction,
runOnJS,
interpolateColor,
Easing,
} from 'react-native-reanimated';
import { useTheme } from '../../styles/theme';

interface DashConfig { width: number; gap: number; }

export interface CircleProgressProps extends Omit<ViewProps, 'children'> {
size: number;
width: number;
backgroundWidth?: number;
fill?: number;
tintColor?: string;
tintColorSecondary?: string;
backgroundColor?: string;
rotation?: number;
lineCap?: 'butt' | 'round' | 'square';
arcSweepAngle?: number;
children?: (fill: number) => React.ReactNode;
childrenContainerStyle?: StyleProp<ViewStyle>;
padding?: number;
dashedBackground?: DashConfig;
dashedTint?: DashConfig;
renderCap?: (payload: { center: { x: number; y: number } }) => React.ReactNode;
}

export interface AnimatedCircleProgressProps extends CircleProgressProps {
prefill?: number;
duration?: number;
delay?: number;
easing?: (value: number) => number;
onAnimationComplete?: (finished: boolean) => void;
onFillChange?: (value: number) => void;
}

const polarToCartesian = (
centerX: number,
centerY: number,
radius: number,
angleInDegrees: number,
) => {
const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0;
return {
x: centerX + radius * Math.cos(angleInRadians),
y: centerY + radius * Math.sin(angleInRadians),
};
};

const circlePath = (
x: number,
y: number,
radius: number,
startAngle: number,
endAngle: number,
) => {
const start = polarToCartesian(x, y, radius, endAngle * 0.9999999);
const end = polarToCartesian(x, y, radius, startAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1';
return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} 0 ${end.x} ${end.y}`;
};

const AnimatedPath = Animated.createAnimatedComponent(Path);

export const CircleProgress = ({
size,
width,
backgroundWidth,
fill = 0,
tintColor,
tintColorSecondary,
backgroundColor,
rotation = 90,
lineCap = 'butt',
arcSweepAngle = 360,
style,
children,
childrenContainerStyle,
padding = 0,
dashedBackground = { width: 0, gap: 0 },
dashedTint = { width: 0, gap: 0 },
renderCap,
...rest
}: CircleProgressProps) => {
const theme = useTheme();
const clamped = Math.max(0, Math.min(fill, 100));
const fillValue = useSharedValue(clamped);
const [jsFill, setJsFill] = useState(clamped);
const maxWidthCircle = backgroundWidth ? Math.max(width, backgroundWidth) : width;
const radius = size / 2 - maxWidthCircle / 2 - padding / 2;
const center = size / 2 + padding / 2;

useEffect(() => {
fillValue.value = clamped;
}, [clamped, fillValue]);

useAnimatedReaction(
() => fillValue.value,
(v) => runOnJS(setJsFill)(v),
);

const animatedProps = useAnimatedProps(() => {
const current = (arcSweepAngle * fillValue.value) / 100;
const stroke = tintColorSecondary
? (interpolateColor(
fillValue.value,
[0, 100],
[tintColor ?? theme.colors.primary, tintColorSecondary],
) as string)
: tintColor ?? theme.colors.primary;
return {
d: circlePath(center, center, radius, 0, current),
stroke,
};
});

const backgroundPath = circlePath(center, center, radius, 0, arcSweepAngle);

const strokeDashTint =
dashedTint.gap > 0 ? [dashedTint.width, dashedTint.gap] : undefined;
const strokeDashBg =
dashedBackground.gap > 0 ? [dashedBackground.width, dashedBackground.gap] : undefined;

const childOffset = size - maxWidthCircle * 2;

const childStyle = StyleSheet.compose(
{
position: 'absolute',
left: maxWidthCircle + padding / 2,
top: maxWidthCircle + padding / 2,
width: childOffset,
height: childOffset,
borderRadius: childOffset / 2,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
childrenContainerStyle,
);

const capPosition = polarToCartesian(
center,
center,
radius,
(arcSweepAngle * jsFill) / 100,
);

return (
<View style={style} {...rest}>
<Svg width={size + padding} height={size + padding}>
<G rotation={rotation} originX={(size + padding) / 2} originY={(size + padding) / 2}>
{backgroundColor && (
<Path
d={backgroundPath}
stroke={backgroundColor}
strokeWidth={backgroundWidth ?? width}
strokeLinecap={lineCap}
strokeDasharray={strokeDashBg}
fill="transparent"
/>
)}
<AnimatedPath
animatedProps={animatedProps}
strokeWidth={width}
strokeLinecap={lineCap}
strokeDasharray={strokeDashTint}
fill="transparent"
/>
{renderCap && renderCap({ center: capPosition })}
</G>
</Svg>
{children && <View style={childStyle}>{children(jsFill)}</View>}
</View>
);
};

export const AnimatedCircleProgress = ({
prefill = 0,
duration = 500,
delay = 0,
easing = Easing.out(Easing.ease),
onAnimationComplete,
onFillChange,
fill = 0,
...rest
}: AnimatedCircleProgressProps) => {
const progress = useSharedValue(prefill);
const [display, setDisplay] = useState(prefill);

useEffect(() => {
progress.value = withDelay(
delay,
withTiming(fill, { duration, easing }, (finished?: boolean) => {
if (onAnimationComplete) {
runOnJS(onAnimationComplete)(!!finished);
}
}),
);
}, [fill, duration, easing, delay, onAnimationComplete, progress]);

useAnimatedReaction(
() => progress.value,
(v) => {
runOnJS(setDisplay)(v);
if (onFillChange) runOnJS(onFillChange)(v);
},
);

return <CircleProgress {...rest} fill={display} />;
};
1 change: 1 addition & 0 deletions packages/rn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export { Checkbox } from './components/ui/checkbox';
export { Dialog } from './components/ui/dialog';
export { Modal, ModalProvider } from './components/ui/modal';
export { Progress } from './components/ui/progress';
export { CircleProgress, AnimatedCircleProgress } from './components/ui/circle-progress';
export { Skeleton } from './components/ui/skeleton';
export { Spinner } from './components/ui/spinner';
export { Divider } from './components/ui/divider';
Expand Down
2 changes: 1 addition & 1 deletion packages/rn/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"types": [],
"typeRoots": ["./@types"],
"baseUrl": "./",
"paths": {
"@/*": ["./*"]
Expand Down
12 changes: 12 additions & 0 deletions packages/unistyles/@types/react-native-svg/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
declare module 'react-native-svg' {
import * as React from 'react';
import { ViewProps } from 'react-native';
export interface SvgProps extends ViewProps {
width?: number | string;
height?: number | string;
}
export const Svg: React.FC<SvgProps>;
export const Path: React.FC<any>;
export const G: React.FC<any>;
export default Svg;
}
Loading
Loading