Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: pie animations #451

Merged
merged 6 commits into from
Dec 13, 2024
Merged
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
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") {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this a default value of a path?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the value of an empty path of the angular inset, yes. I think there's an initial render that happens when all the values haven't been set/received yet higher up so it renders just a point. It's not the best solution, but it fixes the issue.

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}>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thinking out loud. Would be nice in future cleanup if we could just always use AnimatedPath so we don't have to always check which component to render.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. There's potentially a perf hit of some sort, but it would definitely keep things a bit simpler.

{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
Loading