React Native Skia で Circular Progressを作る

React Native Skia で Circular Progress のコンポーネントを作成するサンプルです。

React Native Skia をインストール

shell
npm i @shopify/react-native-skia

CircularProgress.tsx

components/CircularProgress.tsx
import {
  Canvas,
  Path,
  type SkFont,
  Skia,
  type SkiaMutableValue,
  Text,
} from '@shopify/react-native-skia';
import { StyleSheet, View } from 'react-native';

interface CircularProgressProps {
  strokeWidth: number;
  radius: number;
  percentageCompleted: SkiaMutableValue<number>;
  font: SkFont;
  smallerFont: SkFont;
  targetPercentage: number;
}

export default function CircularProgress({
  strokeWidth,
  radius,
  targetPercentage,
  font,
  smallerFont,
  percentageCompleted,
}: CircularProgressProps) {
  const innerRadius = radius - strokeWidth / 2;
  const targetText = `${targetPercentage * 100}`;

  const path = Skia.Path.Make();
  path.addCircle(radius, radius, innerRadius);

  const width = font.getTextWidth(targetText);

  const getPathColor = (targetPercentage: number) => {
    if (targetPercentage < 0.5) return 'green';
    if (targetPercentage < 0.8) return 'orange';
    return 'red';
  };

  return (
    <View style={styles.container}>
      <Canvas style={styles.container}>
        <Path
          path={path}
          color={getPathColor(targetPercentage)}
          style={'stroke'}
          strokeWidth={strokeWidth}
          strokeCap={'round'}
          start={0}
          end={percentageCompleted}
        />
        <Text
          x={innerRadius - width / 2}
          y={radius + strokeWidth}
          text={targetText}
          font={font}
          opacity={percentageCompleted}
        />
        <Text
          x={innerRadius - width / 2}
          y={radius + 45}
          text={'Power'}
          font={smallerFont}
          opacity={percentageCompleted}
        />
      </Canvas>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
});

App.tsx から呼び出す

App.tsx
import { PixelRatio, Pressable, StyleSheet, Text, View } from 'react-native';

import { runTiming, useFont, useValue, Easing } from '@shopify/react-native-skia';
import CircularProgress from './components/CircularProgress';

const RADIUS = PixelRatio.roundToNearestPixel(130);
const STROKE_WIDTH = 12;
export default function App() {
  const percentageComplete = 0.85;
  const animationState = useValue(0);

  // eslint-disable-next-line @typescript-eslint/no-var-requires
  const font = useFont(require('./Roboto-Light.ttf'), 60);
  // eslint-disable-next-line @typescript-eslint/no-var-requires
  const smallerFont = useFont(require('./Roboto-Light.ttf'), 25);

  const animateChart = () => {
    animationState.current = 0;

    runTiming(animationState, percentageComplete, {
      duration: 1250,
      easing: Easing.inOut(Easing.cubic),
    });
  };

  if (font == null || smallerFont == null) return <View></View>;

  return (
    <View style={styles.container}>
      <View style={styles.donutChartContainer}>
        <CircularProgress
          strokeWidth={STROKE_WIDTH}
          radius={RADIUS}
          percentageCompleted={animationState}
          font={font}
          smallerFont={smallerFont}
          targetPercentage={percentageComplete}
        />
      </View>
      <Pressable onPress={animateChart} style={styles.button}>
        <Text style={styles.buttonText}>Animate !</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  button: {
    marginTop: 40,
    backgroundColor: 'blue',
    paddingHorizontal: 60,
    paddingVertical: 15,
    borderRadius: 10,
  },
  donutChartContainer: {
    height: RADIUS * 2,
    width: RADIUS * 2,
  },
  buttonText: {
    color: 'white',
    fontSize: 20,
  },
});