Skip to content

Commit

Permalink
feat: pie animations (#451)
Browse files Browse the repository at this point in the history
  • Loading branch information
zibs authored Dec 13, 2024
1 parent bed3412 commit dc085bc
Show file tree
Hide file tree
Showing 10 changed files with 328 additions and 18 deletions.
5 changes: 5 additions & 0 deletions .changeset/plenty-olives-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"victory-native": minor
---

Add animations for pie chart
3 changes: 2 additions & 1 deletion example/app/donut-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export default function DonutChart(props: { segment: string }) {

return (
<>
<Pie.Slice>
<Pie.Slice animate={{ type: "spring" }}>
<LinearGradient
start={vec(startX, startY)}
end={vec(endX, endY)}
Expand All @@ -82,6 +82,7 @@ export default function DonutChart(props: { segment: string }) {
/>
</Pie.Slice>
<Pie.SliceAngularInset
animate={{ type: "spring" }}
angularInset={{
angularStrokeWidth: 5,
angularStrokeColor: "white",
Expand Down
24 changes: 21 additions & 3 deletions lib/src/hooks/useAnimatedPath.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SkPath } from "@shopify/react-native-skia";
import { Skia, type SkPath } from "@shopify/react-native-skia";
import * as React from "react";
import {
useDerivedValue,
Expand Down Expand Up @@ -54,9 +54,27 @@ export const useAnimatedPath = (
}, [path, t]);

const currentPath = useDerivedValue<SkPath>(() => {
if (t.value !== 1 && path.isInterpolatable(prevPath)) {
return path.interpolate(prevPath, t.value) || path;
if (t.value !== 1) {
// Match floating-point numbers in a string and normalize their precision as this is essential for Skia to interpolate paths
// Without normalization, Skia won't be able to interpolate paths in Pie slice shapes
// This normalization is really only needed for pie charts at the moment
const normalizePrecision = (path: string): string =>
path.replace(/(\d+\.\d+)/g, (match) => parseFloat(match).toFixed(3));
const pathNormalized = Skia.Path.MakeFromSVGString(
normalizePrecision(path.toSVGString()),
);
const prevPathNormalized = Skia.Path.MakeFromSVGString(
normalizePrecision(prevPath.toSVGString()),
);
if (
pathNormalized &&
prevPathNormalized &&
pathNormalized.isInterpolatable(prevPathNormalized)
) {
return pathNormalized.interpolate(prevPathNormalized, t.value) || path;
}
}

return path;
});

Expand Down
21 changes: 17 additions & 4 deletions lib/src/pie/PieSlice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { useSlicePath } from "./hooks/useSlicePath";
import { usePieSliceContext } from "./contexts/PieSliceContext";
import type { PieLabelProps } from "./PieLabel";
import PieLabel from "./PieLabel";
import { AnimatedPath } from "../cartesian/components/AnimatedPath";
import type { PathAnimationConfig } from "../hooks/useAnimatedPath";

export type PieSliceData = {
center: SkPoint;
Expand All @@ -24,9 +26,12 @@ export type PieSliceData = {
};

type AdditionalPathProps = Partial<Omit<PathProps, "color" | "path">>;
type PieSliceProps = AdditionalPathProps & { label?: PieLabelProps };
type PieSliceProps = AdditionalPathProps & {
label?: PieLabelProps;
animate?: PathAnimationConfig;
};

export const PieSlice = ({ children, ...rest }: PieSliceProps) => {
export const PieSlice = ({ children, animate, ...rest }: PieSliceProps) => {
const { slice } = usePieSliceContext();
const path = useSlicePath({ slice });

Expand All @@ -41,11 +46,19 @@ export const PieSlice = ({ children, ...rest }: PieSliceProps) => {
label = childrenArray.splice(labelIndex, 1);
}

const Component = animate ? AnimatedPath : Path;

return (
<>
<Path path={path} style="fill" color={slice.color} {...rest}>
<Component
path={path}
style="fill"
color={slice.color}
animate={animate}
{...rest}
>
{childrenArray}
</Path>
</Component>
{label}
</>
);
Expand Down
18 changes: 14 additions & 4 deletions lib/src/pie/PieSliceAngularInset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,40 @@ import React from "react";
import { type Color, Path, type PathProps } from "@shopify/react-native-skia";
import { useSliceAngularInsetPath } from "./hooks/useSliceAngularInsetPath";
import { usePieSliceContext } from "./contexts/PieSliceContext";
import type { PathAnimationConfig } from "../hooks/useAnimatedPath";
import { AnimatedPath } from "../cartesian/components/AnimatedPath";

export type PieSliceAngularInsetData = {
angularStrokeWidth: number;
angularStrokeColor: Color;
};

type AdditionalPathProps = Partial<Omit<PathProps, "color" | "path">>;
type AdditionalPathProps = Partial<Omit<PathProps, "color" | "path">> & {
animate?: PathAnimationConfig;
};

type PieSliceAngularInsetProps = {
angularInset: PieSliceAngularInsetData;
} & AdditionalPathProps;

export const PieSliceAngularInset = (props: PieSliceAngularInsetProps) => {
const { angularInset, children, ...rest } = props;
const { angularInset, children, animate, ...rest } = props;
const { slice } = usePieSliceContext();
const [path, insetPaint] = useSliceAngularInsetPath({ slice, angularInset });

// If the path is empty, don't render anything
if (path.toSVGString() === "M0 0L0 0M0 0L0 0") {
return null;
}

if (angularInset.angularStrokeWidth === 0) {
return null;
}

const Component = animate ? AnimatedPath : Path;
return (
<Path path={path} paint={insetPaint} {...rest}>
<Component path={path} paint={insetPaint} animate={animate} {...rest}>
{children}
</Path>
</Component>
);
};
4 changes: 1 addition & 3 deletions website/docs/polar/pie/pie-charts.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@

The `Pie.Chart` component is a child component of the `PolarChart` component and is responsible for rendering the `Pie` or `Donut` chart.

:::info
This chart does not yet support labels. We are working on adding support for labels in the future. In the meantime, you can easily add your own legend next to the chart. See the [example app](https://github.com/FormidableLabs/victory-native-xl/tree/main/example) for more details.
:::

:::tip

Expand Down Expand Up @@ -208,6 +205,7 @@ type PieSliceData = {

:::info
Generally, you would not need to use the `slice` object directly, but it is available to you if you need to do something custom with each slice. Please refer to the example app repo for more information on how to use the `slice` object e.g the `LinearGradient` examples.
:::

### Pie Slice Labels

Expand Down
82 changes: 82 additions & 0 deletions website/docs/polar/pie/pie-slice-angular-inset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Pie.SliceAngularInset (Component)

The `Pie.SliceAngularInset` component is a child component of the `Pie.Chart` component and is responsible for rendering the individual slice of a `Pie` or `Donut` chart. By default the `Pie.SliceAngularInset` component will render a simple slice of the pie, but you can customize the rendering of each slice by providing children to the `Pie.Chart` component.

:::tip

The [example app](https://github.com/FormidableLabs/victory-native-xl/tree/main/example) inside this repo has a lot of examples of how to use the `Pie.Chart` and its associated components!

:::

## Example

The example below shows how to use `Pie.SliceAngularInset` to render `LinearGradient` slices.

```tsx
import { View } from "react-native";
import { Pie, PolarChart } from "victory-native";

function MyChart() {
return (
<View style={{ height: 300 }}>
<PolarChart
data={DATA} // 👈 specify your data
labelKey={"label"} // 👈 specify data key for labels
valueKey={"value"} // 👈 specify data key for values
colorKey={"color"} // 👈 specify data key for color
>
<Pie.Chart>
{({ slice }) => {
// ☝️ render function of each slice object for each pie slice
return (
<>
<Pie.Slice />
<Pie.SliceAngularInset
angularInset={{
angularStrokeWidth: insetWidth,
angularStrokeColor: insetColor,
}}
/>
</>
);
}}
</Pie.Chart>
</PolarChart>
</View>
);
}

function randomNumber() {
return Math.floor(Math.random() * 26) + 125;
}
function generateRandomColor(): string {
// Generating a random number between 0 and 0xFFFFFF
const randomColor = Math.floor(Math.random() * 0xffffff);
// Converting the number to a hexadecimal string and padding with zeros
return `#${randomColor.toString(16).padStart(6, "0")}`;
}
const DATA = (numberPoints = 5) =>
Array.from({ length: numberPoints }, (_, index) => ({
value: randomNumber(),
color: generateRandomColor(),
label: `Label ${index + 1}`,
}));
```

## Props

### angularStrokeWidth

The `angularStrokeWidth` prop is used to set the width of the angular inset stroke.

### angularStrokeColor

The `angularStrokeColor` prop is used to set the color of the angular inset stroke.

### `animate`

The `animate` prop takes [a `PathAnimationConfig` object](../../animated-paths.md#animconfig) and will animate the path when the points changes.

### `children`

This component is just a `Path` under the hood, so accepts most props that a `Path` would accept.
Loading

0 comments on commit dc085bc

Please sign in to comment.