Skip to content

Commit dab1d08

Browse files
nXhermanezibs
andauthored
Add ref Prop to CartesianChart to Expose Skia Canvas and Chart Actions (#599)
Co-authored-by: Eli Zibin <1131641+zibs@users.noreply.github.com>
1 parent 46db881 commit dab1d08

6 files changed

Lines changed: 341 additions & 7 deletions

File tree

.changeset/curvy-bugs-thank.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"victory-native": patch
3+
---
4+
5+
Add ref Prop to CartesianChart to Expose Skia Canvas and Chart Actions

example/app/chart-refs.tsx

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import React, { useState } from "react";
2+
import {
3+
StyleSheet,
4+
View,
5+
Image,
6+
SafeAreaView,
7+
ScrollView,
8+
} from "react-native";
9+
import {
10+
type CartesianActionsHandle,
11+
CartesianChart,
12+
Line,
13+
useChartPressState,
14+
type CartesianChartRef,
15+
} from "victory-native";
16+
import { Circle, useFont, ImageFormat } from "@shopify/react-native-skia";
17+
import { type SharedValue } from "react-native-reanimated";
18+
import { useDarkMode } from "react-native-dark";
19+
import { Button } from "../components/Button";
20+
import inter from "../assets/inter-medium.ttf";
21+
import { appColors } from "../consts/colors";
22+
23+
const randomNumber = () => Math.floor(Math.random() * (50 - 25 + 1)) + 25;
24+
25+
const DATA = (numberPoints = 13) =>
26+
Array.from({ length: numberPoints }, (_, index) => ({
27+
day: index + 1,
28+
sales: randomNumber(),
29+
}));
30+
function ToolTip({
31+
x,
32+
y,
33+
color = "red",
34+
}: {
35+
x: SharedValue<number>;
36+
y: SharedValue<number>;
37+
color?: string;
38+
}) {
39+
return <Circle cx={x} cy={y} r={6} color={color} />;
40+
}
41+
42+
export default function ChartRefsExample() {
43+
const isDark = useDarkMode();
44+
const [data, setData] = useState(DATA());
45+
46+
const colors = {
47+
stroke: isDark ? "#fafafa" : "#71717a",
48+
xLine: isDark ? "#71717a" : "#ffffff",
49+
yLine: isDark ? "#aabbcc" : "#ddfa55",
50+
frameLine: isDark ? "#444" : "#aaa",
51+
xLabel: isDark ? appColors.text.dark : appColors.text.light,
52+
yLabel: isDark ? appColors.text.dark : appColors.text.light,
53+
scatter: "#a78bfa",
54+
};
55+
const font = useFont(inter, 12);
56+
// Create chart press state for interactivity
57+
const { state } = useChartPressState<{
58+
x: number;
59+
y: Record<"sales", number>;
60+
}>({
61+
x: 0,
62+
y: { sales: 0 },
63+
});
64+
const chartRef =
65+
React.useRef<CartesianChartRef<typeof state | undefined>>(null);
66+
const actionRef = React.useRef<CartesianActionsHandle>(null);
67+
const [snapshotUri, setSnapshotUri] = React.useState<string | null>(null);
68+
69+
const handleProgrammaticTouch = () => {
70+
if (chartRef.current) {
71+
const x = Math.floor(Math.random() * data.length);
72+
const y = randomNumber();
73+
chartRef.current.actions.handleTouch(state, x, y);
74+
}
75+
};
76+
77+
const handleRedraw = () => {
78+
if (chartRef.current?.canvas) {
79+
chartRef.current.canvas.redraw();
80+
}
81+
};
82+
83+
const handleSnapshot = async () => {
84+
if (chartRef.current?.canvas) {
85+
try {
86+
setSnapshotUri(null);
87+
const sKImage = await chartRef.current.canvas.makeImageSnapshot();
88+
const skData = sKImage.encodeToBase64(ImageFormat.PNG, 100);
89+
setSnapshotUri(`data:image/png;base64,${skData}`);
90+
} catch (err) {
91+
console.error("Failed to take snapshot:", err);
92+
}
93+
}
94+
};
95+
96+
const handleAsyncSnapshot = () => {
97+
if (chartRef.current?.canvas) {
98+
setSnapshotUri(null);
99+
chartRef.current.canvas
100+
.makeImageSnapshotAsync()
101+
.then((sKImage) => {
102+
const skData = sKImage.encodeToBase64(ImageFormat.PNG, 100);
103+
setSnapshotUri(`data:image/png;base64,${skData}`);
104+
})
105+
.catch((err) => {
106+
console.error("Failed to take async snapshot:", err);
107+
});
108+
}
109+
};
110+
111+
return (
112+
<SafeAreaView style={styles.safeView}>
113+
<View style={{ flex: 1, maxHeight: 400, padding: 0 }}>
114+
<CartesianChart
115+
actionsRef={actionRef}
116+
ref={chartRef}
117+
data={data}
118+
xKey="day"
119+
yKeys={["sales"]}
120+
axisOptions={{
121+
font,
122+
lineWidth: { grid: { x: 0, y: 2 }, frame: 0 },
123+
lineColor: {
124+
grid: {
125+
x: colors.xLine!,
126+
y: colors.yLine!,
127+
},
128+
frame: colors.frameLine!,
129+
},
130+
}}
131+
chartPressState={state}
132+
>
133+
{({ points }) => (
134+
<>
135+
<Line
136+
points={points.sales}
137+
color={colors.stroke}
138+
strokeWidth={2}
139+
/>
140+
<ToolTip
141+
x={state.x.position}
142+
y={state.y.sales.position}
143+
color={colors.scatter}
144+
/>
145+
</>
146+
)}
147+
</CartesianChart>
148+
</View>
149+
<ScrollView
150+
style={styles.optionsScrollView}
151+
contentContainerStyle={styles.options}
152+
>
153+
<View
154+
style={{
155+
flexDirection: "row",
156+
gap: 12,
157+
marginVertical: 16,
158+
}}
159+
>
160+
<Button
161+
style={{ flex: 1 }}
162+
onPress={() => setData((data) => DATA(data.length))}
163+
title="Shuffle Data"
164+
/>
165+
<Button
166+
style={{ flex: 1 }}
167+
onPress={() =>
168+
setData((data) => [
169+
...data,
170+
{
171+
day: data.length + 1,
172+
sales: randomNumber(),
173+
},
174+
])
175+
}
176+
title="Add Point"
177+
/>
178+
</View>
179+
<View style={styles.buttonContainer}>
180+
<Button onPress={handleProgrammaticTouch} title="Trigger Touch" />
181+
<Button onPress={handleRedraw} title="Redraw" />
182+
<Button onPress={handleSnapshot} title="Take Snapshot" />
183+
<Button onPress={handleAsyncSnapshot} title="Async Snapshot" />
184+
</View>
185+
186+
{snapshotUri && (
187+
<View style={styles.snapshotContainer}>
188+
<Image
189+
source={{ uri: snapshotUri }}
190+
style={styles.snapshot}
191+
resizeMode="contain"
192+
/>
193+
</View>
194+
)}
195+
</ScrollView>
196+
</SafeAreaView>
197+
);
198+
}
199+
200+
const styles = StyleSheet.create({
201+
safeView: {
202+
flex: 1,
203+
backgroundColor: appColors.viewBackground.light,
204+
$dark: {
205+
backgroundColor: appColors.viewBackground.dark,
206+
},
207+
},
208+
container: {
209+
flex: 1,
210+
padding: 16,
211+
},
212+
buttonContainer: {
213+
flexDirection: "row",
214+
flexWrap: "wrap",
215+
justifyContent: "space-around",
216+
marginTop: 16,
217+
gap: 8,
218+
},
219+
snapshotContainer: {
220+
marginTop: 20,
221+
height: 300,
222+
borderRadius: 8,
223+
overflow: "hidden",
224+
width: "100%",
225+
},
226+
snapshot: {
227+
width: "100%",
228+
height: "100%",
229+
},
230+
optionsScrollView: {
231+
flex: 0.5,
232+
backgroundColor: appColors.cardBackground.light,
233+
$dark: {
234+
backgroundColor: appColors.cardBackground.dark,
235+
},
236+
},
237+
options: {
238+
paddingHorizontal: 20,
239+
paddingVertical: 15,
240+
alignItems: "flex-start",
241+
justifyContent: "flex-start",
242+
},
243+
});

example/consts/routes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@ export const ChartRoutes: {
153153
description: "Show example of scrolling chart data.",
154154
path: "/scroll",
155155
},
156+
{
157+
title: "Chart Refs",
158+
description: "This example demonstrates chart interactions using refs.",
159+
path: "/chart-refs",
160+
},
156161
];
157162

158163
if (__DEV__) {

lib/src/cartesian/CartesianChart.tsx

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from "react";
22
import { type LayoutChangeEvent } from "react-native";
3-
import { Canvas, Group } from "@shopify/react-native-skia";
3+
import { Canvas, Group, type CanvasRef } from "@shopify/react-native-skia";
44
import { useSharedValue } from "react-native-reanimated";
55
import {
66
type ComposedGesture,
@@ -67,6 +67,11 @@ export type CartesianActionsHandle<T = undefined> =
6767
: never
6868
: never;
6969

70+
export type CartesianChartRef<T = undefined> = {
71+
canvas: CanvasRef | null;
72+
actions: CartesianActionsHandle<T>;
73+
};
74+
7075
type CartesianChartProps<
7176
RawData extends Record<string, unknown>,
7277
XK extends keyof InputFields<RawData>,
@@ -116,16 +121,30 @@ type CartesianChartProps<
116121
}>
117122
| undefined
118123
> | null>;
124+
ref?: React.Ref<
125+
CartesianChartRef<
126+
| ChartPressState<{
127+
x: InputFields<RawData>[XK];
128+
y: Record<YK, number>;
129+
}>
130+
| undefined
131+
>
132+
>;
119133
};
120134

121135
export function CartesianChart<
122136
RawData extends Record<string, unknown>,
123137
XK extends keyof InputFields<RawData>,
124138
YK extends keyof NumericalFields<RawData>,
125-
>({ transformState, children, ...rest }: CartesianChartProps<RawData, XK, YK>) {
139+
>({
140+
transformState,
141+
children,
142+
ref,
143+
...rest
144+
}: CartesianChartProps<RawData, XK, YK>) {
126145
return (
127146
<CartesianTransformProvider transformState={transformState}>
128-
<CartesianChartContent {...{ ...rest, transformState }}>
147+
<CartesianChartContent {...{ ...rest, transformState }} ref={ref}>
129148
{children}
130149
</CartesianChartContent>
131150
</CartesianTransformProvider>
@@ -160,6 +179,7 @@ function CartesianChartContent<
160179
customGestures,
161180
actionsRef,
162181
viewport,
182+
ref,
163183
}: CartesianChartProps<RawData, XK, YK>) {
164184
const [size, setSize] = React.useState({ width: 0, height: 0 });
165185
const chartBoundsRef = React.useRef<ChartBounds | undefined>(undefined);
@@ -170,6 +190,7 @@ function CartesianChartContent<
170190
const yScaleRef = React.useRef<ScaleLinear<number, number> | undefined>(
171191
undefined,
172192
);
193+
const canvasRef = React.useRef<CanvasRef | null>(null);
173194
const [hasMeasuredLayoutSize, setHasMeasuredLayoutSize] =
174195
React.useState(false);
175196
const onLayout = React.useCallback(
@@ -297,7 +318,10 @@ function CartesianChartContent<
297318
* Take a "press value" and an x-value and update the shared values accordingly.
298319
*/
299320
const handleTouch = (
300-
v: ChartPressState<{ x: InputFields<RawData>[XK]; y: Record<YK, number> }>,
321+
v: ChartPressState<{
322+
x: InputFields<RawData>[XK];
323+
y: Record<YK, number>;
324+
}>,
301325
x: number,
302326
y: number,
303327
) => {
@@ -307,7 +331,6 @@ function CartesianChartContent<
307331
if (typeof idx !== "number") return;
308332

309333
const isInYs = (yk: string): yk is YK & string => yKeys.includes(yk as YK);
310-
311334
// begin stacked bar handling:
312335
// store the heights of each bar segment
313336
const barHeights: number[] = [];
@@ -362,6 +385,18 @@ function CartesianChartContent<
362385
lastIdx.value = idx;
363386
};
364387

388+
React.useImperativeHandle(
389+
ref,
390+
() => ({
391+
canvas: canvasRef.current,
392+
actions: {
393+
handleTouch,
394+
},
395+
}),
396+
// eslint-disable-next-line react-hooks/exhaustive-deps
397+
[canvasRef],
398+
);
399+
365400
if (actionsRef) {
366401
actionsRef.current = {
367402
handleTouch,
@@ -649,7 +684,7 @@ function CartesianChartContent<
649684

650685
// Body of the chart.
651686
const body = (
652-
<Canvas style={{ flex: 1 }}>
687+
<Canvas ref={canvasRef} style={{ flex: 1 }}>
653688
{YAxisComponents}
654689
{XAxisComponents}
655690
{FrameComponent}

lib/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
export {
55
CartesianChart,
66
type CartesianActionsHandle,
7+
type CartesianChartRef,
78
} from "./cartesian/CartesianChart";
89

910
export {

0 commit comments

Comments
 (0)