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

Convert victory-animation to function component #2788

Merged
merged 4 commits into from
Feb 8, 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eleven-geckos-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"victory-core": patch
---

Convert victory-animation to function component
251 changes: 110 additions & 141 deletions packages/victory-core/src/victory-animation/victory-animation.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
/* global setTimeout:false */
import React from "react";
import * as d3Ease from "victory-vendor/d3-ease";
import { victoryInterpolator } from "./util";
import TimerContext from "../victory-util/timer-context";
import isEqual from "react-fast-compare";
import type Timer from "../victory-util/timer";

/**
* Single animation object to interpolate
Expand All @@ -15,6 +12,7 @@ export type AnimationStyle = { [key: string]: string | number };
*/

export type AnimationData = AnimationStyle | AnimationStyle[];

export type AnimationEasing =
| "back"
| "backIn"
Expand Down Expand Up @@ -58,17 +56,19 @@ export type AnimationEasing =
| "sinInOut";

export interface VictoryAnimationProps {
children: (style: AnimationStyle, info: AnimationInfo) => React.ReactNode;
children: (style: AnimationStyle, info: AnimationInfo) => React.ReactElement;
duration?: number;
easing?: AnimationEasing;
delay?: number;
onEnd?: () => void;
data: AnimationData;
}

export interface VictoryAnimationState {
data: AnimationStyle;
animationInfo: AnimationInfo;
}

export interface AnimationInfo {
progress: number;
animating: boolean;
Expand All @@ -79,169 +79,138 @@ export interface VictoryAnimation {
context: React.ContextType<typeof TimerContext>;
}

export class VictoryAnimation extends React.Component<
VictoryAnimationProps,
VictoryAnimationState
> {
static displayName = "VictoryAnimation";

static defaultProps = {
data: {},
delay: 0,
duration: 1000,
easing: "quadInOut",
};
/** d3-ease changed the naming scheme for ease from "linear" -> "easeLinear" etc. */
const formatAnimationName = (name: AnimationEasing) => {
const capitalizedName = name.charAt(0).toUpperCase() + name.slice(1);
return `ease${capitalizedName}`;
};

static contextType = TimerContext;
private interpolator: null | ((value: number) => AnimationStyle);
private queue: AnimationStyle[];
private ease: any;
private timer: Timer;
private loopID?: number;

constructor(props, context) {
super(props, context);
/* defaults */
this.state = {
data: Array.isArray(this.props.data)
? this.props.data[0]
: this.props.data,
animationInfo: {
progress: 0,
animating: false,
},
};
this.interpolator = null;
this.queue = Array.isArray(this.props.data) ? this.props.data.slice(1) : [];
/* build easing function */
this.ease = d3Ease[this.toNewName(this.props.easing)];
this.timer = this.context.animationTimer;
}

componentDidMount() {
const DEFAULT_DURATION = 1000;

export const VictoryAnimation = ({
duration = DEFAULT_DURATION,
easing = "quadInOut",
delay = 0,
data,
children,
onEnd,
}: VictoryAnimationProps) => {
const [state, setState] = React.useState<VictoryAnimationState>({
data: Array.isArray(data) ? data[0] : data,
animationInfo: {
progress: 0,
animating: false,
},
});

const timer = React.useContext(TimerContext).animationTimer;
const queue = React.useRef<AnimationStyle[]>(
Array.isArray(data) ? data.slice(1) : [],
);
const interpolator = React.useRef<null | ((value: number) => AnimationStyle)>(
null,
);
const loopID = React.useRef<number | undefined>(undefined);
const ease = d3Ease[formatAnimationName(easing)];

React.useEffect(() => {
// Length check prevents us from triggering `onEnd` in `traverseQueue`.
if (this.queue.length) {
this.traverseQueue();
if (queue.current.length) {
traverseQueue();
}
}

componentDidUpdate(prevProps) {
const equalProps = isEqual(this.props, prevProps);
if (!equalProps) {
/* If the previous animation didn't finish, force it to complete before starting a new one */
if (
this.interpolator &&
this.state.animationInfo &&
this.state.animationInfo.progress < 1
) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({
data: this.interpolator(1),
animationInfo: {
progress: 1,
animating: false,
terminating: true,
},
});

// Clean up the animation loop
return () => {
if (loopID.current) {
timer.unsubscribe(loopID.current);
} else {
/* cancel existing loop if it exists */
this.timer.unsubscribe(this.loopID);
/* If an object was supplied */
if (!Array.isArray(this.props.data)) {
// Replace the tween queue. Could set `this.queue = [nextProps.data]`,
// but let's reuse the same array.
this.queue.length = 0;
this.queue.push(this.props.data);
/* If an array was supplied */
} else {
/* Extend the tween queue */
this.queue.push(...this.props.data);
}
/* Start traversing the tween queue */
this.traverseQueue();
timer.stop();
}
}
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

componentWillUnmount() {
if (this.loopID) {
this.timer.unsubscribe(this.loopID);
React.useEffect(() => {
// If the previous animation didn't finish, force it to complete before starting a new one
if (
interpolator.current &&
state.animationInfo &&
state.animationInfo.progress < 1
) {
setState({
data: interpolator.current(1),
animationInfo: {
progress: 1,
animating: false,
terminating: true,
},
});
} else {
this.timer.stop();
// Cancel existing loop if it exists
timer.unsubscribe(loopID.current);
// Set the tween queue to the new data
queue.current = Array.isArray(data) ? data : [data];
// Start traversing the tween queue
traverseQueue();
}
}

toNewName(ease) {
// d3-ease changed the naming scheme for ease from "linear" -> "easeLinear" etc.
const capitalize = (s) => s && s[0].toUpperCase() + s.slice(1);
return `ease${capitalize(ease)}`;
}

/* Traverse the tween queue */
traverseQueue() {
if (this.queue.length) {
/* Get the next index */
const data = this.queue[0];
/* compare cached version to next props */
this.interpolator = victoryInterpolator(this.state.data, data);
/* reset step to zero */
if (this.props.delay) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);

const traverseQueue = () => {
if (queue.current.length) {
const nextData = queue.current[0];

// Compare cached version to next props
interpolator.current = victoryInterpolator(state.data, nextData);

// Reset step to zero
if (delay) {
setTimeout(() => {
this.loopID = this.timer.subscribe(
this.functionToBeRunEachFrame,
this.props.duration!,
);
}, this.props.delay);
loopID.current = timer.subscribe(functionToBeRunEachFrame, duration);
}, delay);
} else {
this.loopID = this.timer.subscribe(
this.functionToBeRunEachFrame,
this.props.duration!,
);
loopID.current = timer.subscribe(functionToBeRunEachFrame, duration);
}
} else if (this.props.onEnd) {
this.props.onEnd();
} else if (onEnd) {
onEnd();
}
}
/* every frame we... */
functionToBeRunEachFrame = (elapsed, duration) => {
/*
step can generate imprecise values, sometimes greater than 1
if this happens set the state to 1 and return, cancelling the timer
*/
const animationDuration =
duration !== undefined ? duration : this.props.duration;
const step = animationDuration ? elapsed / animationDuration : 1;
};

const functionToBeRunEachFrame = (elapsed: number) => {
if (!interpolator.current) return;

// Step can generate imprecise values, sometimes greater than 1
// if this happens set the state to 1 and return, cancelling the timer
const step = duration ? elapsed / duration : 1;

if (step >= 1) {
this.setState({
data: this.interpolator!(1),
setState({
data: interpolator.current(1),
animationInfo: {
progress: 1,
animating: false,
terminating: true,
},
});
if (this.loopID) {
this.timer.unsubscribe(this.loopID);
if (loopID.current) {
timer.unsubscribe(loopID.current);
}
this.queue.shift();
this.traverseQueue();
queue.current.shift();
traverseQueue();
return;
}
/*
if we're not at the end of the timer, set the state by passing
current step value that's transformed by the ease function to the
interpolator, which is cached for performance whenever props are received
*/
this.setState({
data: this.interpolator!(this.ease(step)),

// If we're not at the end of the timer, set the state by passing
// current step value that's transformed by the ease function to the
// interpolator, which is cached for performance whenever props are received
setState({
data: interpolator.current(ease(step)),
animationInfo: {
progress: step,
animating: step < 1,
},
});
};

render() {
return this.props.children(this.state.data, this.state.animationInfo);
}
}
return children(state.data, state.animationInfo);
};