Skip to content

Commit

Permalink
feat: add pan and hover gesture handlers, with point resolution
Browse files Browse the repository at this point in the history
  • Loading branch information
e-younan committed Mar 4, 2024
1 parent be93f88 commit cdf73d7
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 30 deletions.
24 changes: 21 additions & 3 deletions example/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,28 @@ import { type AxisLabelComponentProps, LineChart } from "@codeherence/react-nati
import { useCallback, useState } from "react";
import { Button, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { LineChartProps } from "src/components/LineChart";

const generateRandomData = (): [number, number][] => {
return Array.from({ length: 30 }, (_, i) => [i, Math.random() * 2000]);
};

type FilterUndefined<T> = T extends undefined ? never : T;

const onGestureChangeWorklet: FilterUndefined<LineChartProps["onPanGestureChange"]> = ({
point,
}) => {
"worklet";
console.log(point);
};

const onHoverChangeWorklet: FilterUndefined<LineChartProps["onHoverGestureChange"]> = ({
point,
}) => {
"worklet";
console.log(point);
};

const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
Expand All @@ -20,9 +37,8 @@ export default () => {
const { top, bottom } = useSafeAreaInsets();
const [data, setData] = useState<[number, number][]>(generateRandomData());

const handlePress = useCallback(() => {
setData(generateRandomData());
}, []);
// Randomize the data
const handlePress = useCallback(() => setData(generateRandomData()), []);

return (
<View style={[styles.container, { paddingTop: top, paddingBottom: bottom }]}>
Expand All @@ -32,6 +48,8 @@ export default () => {
style={styles.chart}
TopAxisLabel={AxisLabel}
BottomAxisLabel={AxisLabel}
onPanGestureChange={onGestureChangeWorklet}
onHoverGestureChange={onHoverChangeWorklet}
/>
</View>
);
Expand Down
14 changes: 7 additions & 7 deletions example/components/Banner.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { BannerComponentProps } from "@codeherence/react-native-graph";
import { Text, useFont } from "@shopify/react-native-skia";
// import { BannerComponentProps } from "@codeherence/react-native-graph";
// import { Text, useFont } from "@shopify/react-native-skia";

const robotoMedium = require("../public/fonts/Roboto/Roboto-Medium.ttf");
// const robotoMedium = require("../public/fonts/Roboto/Roboto-Medium.ttf");

export const Banner: React.FC<BannerComponentProps> = ({ text }) => {
const font = useFont(robotoMedium, 24);
return <Text x={0} y={0} font={font} text={text} />;
};
// export const Banner: React.FC<BannerComponentProps> = ({ text }) => {
// const font = useFont(robotoMedium, 24);
// return <Text x={0} y={0} font={font} text={text} />;
// };
3 changes: 1 addition & 2 deletions src/components/LineChart/Math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,8 @@ export interface GetYForXProps {
precision?: number;
}

export const getYForX = ({ path, x, precision = 2 }: GetYForXProps): number | undefined => {
export const getYForX = ({ path, x, precision = 2 }: GetYForXProps): number => {
"worklet";

return linearYForX({ path, x, precision });
};

Expand Down
41 changes: 34 additions & 7 deletions src/components/LineChart/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@ import { Canvas, Path } from "@shopify/react-native-skia";
import { useCallback, useMemo, useState } from "react";
import { LayoutChangeEvent, StyleSheet, View, ViewProps } from "react-native";
import { GestureDetector } from "react-native-gesture-handler";
import { useDerivedValue, useSharedValue } from "react-native-reanimated";
import { useSharedValue } from "react-native-reanimated";

import { AxisLabelComponentProps, AxisLabelContainer } from "./AxisLabel";
import { Cursor } from "./Cursor";
import { computePath, getYForX, type ComputePathProps, computeGraphData } from "./Math";
import { computePath, type ComputePathProps, computeGraphData } from "./Math";
import {
DEFAULT_CURSOR_RADIUS,
DEFAULT_CURVE_TYPE,
DEFAULT_FORMATTER,
DEFAULT_STROKE_WIDTH,
} from "./constants";
import { useGestures } from "./useGestures";
import { useGestures, type UseGestureProps } from "./useGestures";

type FilterNull<T> = T extends null ? never : T;

export type LineChartProps = ViewProps & {
/** Array of [x, y] points for the chart */
Expand All @@ -25,6 +27,13 @@ export type LineChartProps = ViewProps & {
formatter?: (price: number) => string;
TopAxisLabel?: React.FC<AxisLabelComponentProps>;
BottomAxisLabel?: React.FC<AxisLabelComponentProps>;
/** Callback when the pan gesture begins. This function must be a worklet function. */
onPanGestureBegin?: FilterNull<UseGestureProps["onPanGestureBegin"]>;
onPanGestureChange?: FilterNull<UseGestureProps["onPanGestureChange"]>;
onPanGestureEnd?: FilterNull<UseGestureProps["onPanGestureEnd"]>;
onHoverGestureBegin?: FilterNull<UseGestureProps["onHoverGestureBegin"]>;
onHoverGestureChange?: FilterNull<UseGestureProps["onHoverGestureChange"]>;
onHoverGestureEnd?: FilterNull<UseGestureProps["onHoverGestureEnd"]>;
};

export const LineChart: React.FC<LineChartProps> = ({
Expand All @@ -35,14 +44,20 @@ export const LineChart: React.FC<LineChartProps> = ({
formatter = DEFAULT_FORMATTER,
TopAxisLabel = null,
BottomAxisLabel = null,
onPanGestureBegin = null,
onPanGestureChange = null,
onPanGestureEnd = null,
onHoverGestureBegin = null,
onHoverGestureChange = null,
onHoverGestureEnd = null,
...viewProps
}) => {
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);

// Initially -cursorRadius so that the cursor is offscreen
const x = useSharedValue(-cursorRadius);
const gestures = useGestures({ x, cursorRadius });
const y = useSharedValue(0);

// We separate the computation of the data from the rendering. This is so that these values are
// not recomputed when the width or height of the chart changes, but only when the points change.
Expand All @@ -52,9 +67,21 @@ export const LineChart: React.FC<LineChartProps> = ({
return computePath({ ...data, width, height, cursorRadius, curveType });
}, [data, width, height, cursorRadius, curveType]);

const y = useDerivedValue(() => {
return path ? getYForX({ path, x: x.value }) ?? 0 : 0;
}, [path]);
const gestures = useGestures({
x,
y,
path,
height,
minValue: data.minValue,
maxValue: data.maxValue,
cursorRadius,
onPanGestureBegin,
onPanGestureChange,
onPanGestureEnd,
onHoverGestureBegin,
onHoverGestureChange,
onHoverGestureEnd,
});

const onLayout = useCallback((e: LayoutChangeEvent) => {
setWidth(e.nativeEvent.layout.width);
Expand Down
132 changes: 122 additions & 10 deletions src/components/LineChart/useGestures.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,133 @@
import { Gesture } from "react-native-gesture-handler";
import { SharedValue } from "react-native-reanimated";
import { SkPath } from "@shopify/react-native-skia";
import {
Gesture,
type PanGestureHandlerEventPayload,
type PanGestureChangeEventPayload,
} from "react-native-gesture-handler";
import { SharedValue, interpolate } from "react-native-reanimated";

interface UseGestureProps {
import { getYForX } from "./Math";

type PanGestureHandlerOnBeginEventPayload = {
point: number;
event: PanGestureHandlerEventPayload;
};
type PanGestureHandlerOnChangeEventPayload = {
point: number;
event: PanGestureHandlerEventPayload & PanGestureChangeEventPayload;
};

// Extract Hover Gesture onBegin args since it isn't exported by rngh
type HoverGestureOnBegin = ReturnType<typeof Gesture.Hover>["onBegin"];
type HoverGestureOnBeginCallBack = Parameters<HoverGestureOnBegin>[0];
type HoverGestureHandlerOnBeginEventPayload = {
point: number;
event: Parameters<HoverGestureOnBeginCallBack>[0];
};

// Extract Hover Gesture onChange args since it isn't exported by rngh
type HoverGestureOnChange = ReturnType<typeof Gesture.Hover>["onChange"];
type HoverGestureOnChangeCallBack = Parameters<HoverGestureOnChange>[0];
type HoverGestureHandlerOnChangeEventPayload = {
point: number;
event: Parameters<HoverGestureOnChangeCallBack>[0];
};

// Extract Hover Gesture onEnd args since it isn't exported by rngh
type HoverGestureOnEnd = ReturnType<typeof Gesture.Hover>["onEnd"];
type HoverGestureOnEndCallBack = Parameters<HoverGestureOnEnd>[0];
type HoverGestureHandlerOnEndEventPayload = Parameters<HoverGestureOnEndCallBack>[0];

export interface UseGestureProps {
x: SharedValue<number>;
y: SharedValue<number>;
path: SkPath;
height: number;
minValue: number;
maxValue: number;
cursorRadius: number;
/** Callback when the pan gesture begins. This function must be a worklet function. */
onPanGestureBegin: ((payload: PanGestureHandlerOnBeginEventPayload) => void) | null;
onPanGestureChange: ((payload: PanGestureHandlerOnChangeEventPayload) => void) | null;
onPanGestureEnd: ((payload: PanGestureHandlerEventPayload) => void) | null;
onHoverGestureBegin: ((payload: HoverGestureHandlerOnBeginEventPayload) => void) | null;
onHoverGestureChange: ((payload: HoverGestureHandlerOnChangeEventPayload) => void) | null;
onHoverGestureEnd: ((payload: HoverGestureHandlerOnEndEventPayload) => void) | null;
}

export const useGestures = ({ x, cursorRadius }: UseGestureProps) => {
/**
* Returns the gesture handlers for the LineChart component.
* @param param0 - The props to allow the gesture handlers to interact with the LineChart component.
* @returns The gesture handlers for the LineChart component.
*/
export const useGestures = ({
x,
y,
path,
height,
minValue,
maxValue,
cursorRadius,
onPanGestureBegin,
onPanGestureChange,
onPanGestureEnd,
onHoverGestureBegin,
onHoverGestureChange,
onHoverGestureEnd,
}: UseGestureProps) => {
const panGesture = Gesture.Pan()
.onBegin((evt) => (x.value = evt.x))
.onChange((evt) => (x.value = evt.x))
.onEnd(() => (x.value = -cursorRadius));
.onBegin((event) => {
x.value = event.x;
y.value = getYForX({ path, x: event.x });
const point = interpolate(
y.value,
[cursorRadius, height - cursorRadius],
[maxValue, minValue]
);
if (onPanGestureBegin) onPanGestureBegin({ event, point });
})
.onChange((event) => {
x.value = event.x;
y.value = getYForX({ path, x: event.x });
const point = interpolate(
y.value,
[cursorRadius, height - cursorRadius],
[maxValue, minValue]
);
if (onPanGestureChange) onPanGestureChange({ event, point });
})
.onEnd((event) => {
x.value = -cursorRadius;
if (onPanGestureEnd) onPanGestureEnd(event);
});

const hoverGesture = Gesture.Hover()
.onBegin((evt) => (x.value = evt.x))
.onChange((evt) => (x.value = evt.x))
.onEnd(() => (x.value = -cursorRadius));
.onBegin((event) => {
x.value = event.x;
y.value = getYForX({ path, x: event.x });
const point = interpolate(
y.value,
[cursorRadius, height - cursorRadius],
[maxValue, minValue]
);
if (onHoverGestureBegin) onHoverGestureBegin({ event, point });
})
.onChange((event) => {
x.value = event.x;
y.value = getYForX({ path, x: event.x });
const point = interpolate(
y.value,
[cursorRadius, height - cursorRadius],
[maxValue, minValue]
);
if (onHoverGestureChange) onHoverGestureChange({ event, point });
})
.onEnd((event) => {
x.value = -cursorRadius;
if (onHoverGestureEnd) onHoverGestureEnd(event);
});

// We return a composed gesture that listens to both pan and hover gestures. This is to
// allow the chart component to work on both touch and mouse devices.
return Gesture.Race(hoverGesture, panGesture);
};
1 change: 0 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
export { LineChart } from "./components/SkiaComponents";
export { type BannerComponentProps } from "./components/LineChart/Banner";
export { type AxisLabelComponentProps } from "./components/LineChart/AxisLabel";

0 comments on commit cdf73d7

Please sign in to comment.