reactjsrecursionreact-hooksreact-fullstackreact-animations

How to reinitiate a frame by frame animation built on the React Hooks useRef, useState, and UseEffect stopped by a conditional state update?


This is a beginner project I'm working on which utilizes react to animate pixel art. It functions by using a brute force method of iterating through a array to set the images of the cat. A timer is passed as a parameter of the arrow function animate. I didn't understand myself how timer iterates by itself at first, but I've learned that as each time the requestAnimationFrame calls animate, a timestamp is received as an argument. requestAnimationFrame is called twice, once by the useEffect arrow function and the other through animate. temp.current is then set to the current time stamp before calling animate to receive new timestamp through requestAnimationFrame. useEffect calls animate through requestAnimationFrame recursively. Now to explain the setSource, it utilizes a arrow function which uses a iteration initialized by the difference of slightly staggered timestamps of timer (current timestamp) and temp.current (previous timestamp). By iterating and multiplying by 0.002, the speed of the animation can be controlled to an extent. The modulus caps the values from only [0,1] which are set in the variable source. That means the frames that are animated are in the indexes from 0 to 1 of the images array. This is then passed into result which is used to return the image's source. Now, it stops all as soon as the timer reaches 5000. The issue is that I need to reinitiate the whole recursive loop again after a condition is met (in this context, when a button is pressed).

import {useRef, useState, useEffect } from "react";
import React from "react"

default function placeHolder() {

  const [source, setSource] = useState(0);
  const temp = React.useRef();
  const images = ['gray-pixil-frame-1.png', 'gray-pixil-frame-0.png', 'gray-pixil-frame-2.png']
  var result = images[Math.round(source)]

  const animate = timer=> {
      console.log(timer)
      if (timer <= 5000) {
        if (temp.current != undefined) {
          const iteration = timer - temp.current;
          setSource(
            (prevCount =>
              (prevCount + iteration * 0.002) % 1)
          )
        }
        temp.current = timer;
        requestAnimationFrame(animate);
      }
      else if (timer > 5000) {
        setSource(2) //sets the frame to images[2] after timer reaches 5000
      }
  }
  React.useEffect(() => {
    const id = requestAnimationFrame(animate)    
    return () => cancelAnimationFrame(id)
  },[])



  return (
      <img key={result} src={`image/${result}`} className="img" />
  );
}

disclaimer: I cannot show/implement all of the below attempts because of how messy it actually was and how long it would've taken to clean. I apologize deeply for this. It was a rough week What I've tried so far: Booleans && useEffect Booleans did not work as I expected. I used React's useState hooks to initialize a boolean and updated its value with setState. I wrapped if statements with this boolean and changed its state when said button was pressed. Well, one, the button may have not updated its value properly, but it was hard to confirm even with all my debugging. But, in general, I think it was definitely updating most times. After attempting to wrap it within the animate function, I gave up attempting it there and made a new useEffect function utilizing the booleans to control the animation. Here is an example:

 useEffect(() => {
    const handleButtonClick = () => {
      if (isAnimationActive) {
        setSource(0); // Reset animation source
        temp.current = undefined; // Reset temp
      }
      setIsAnimationActive(!isAnimationActive); // Toggle animation state
    };
    const questionsButton = document.getElementById("questions");
    questionsButton.addEventListener('click', handleButtonClick);
    return () => {
      questionsButton.removeEventListener('click', handleButtonClick);
    };
  }, []);

This failed too. Note: I'm utilizing the document.getElementbyId() and .addEventListener() to get by. The return (<button onClick{function()}>) creates far more complications in my scenario. Some issues may also lie within the way the button updates booleans through useState and useEffect. Anyhow, advice would be greatly appreciated. I understand if this was complicated and messy, feel free to give me constructive criticism on how to better present these issues for the future. Thank you. **


Solution

  • React has an onClick handler that you can use on a button or the image itself (like in the example).

    Create a method that resets all animation parameters (startAnimation in the example), and calls the animation.

    In addition, you need to have 3 refs:

    1. frameId - holds the current id returned from requestAnimationFrame, and it's used for cancelling the animation on click, or when the component unmounts.
    2. prevTimer - the previous value of the timer, or null on start.
    3. startTimer - the first value of the timer on each cycle, so we can measure the 5000ms have passed.

    Click the image to see the animation restarts:

    const {
      useRef,
      useState,
      useEffect,
      useCallback
    } = React;
    
    function PlaceHolder({ images }) {
      const [source, setSource] = useState(0);
      const frameId = useRef(null);
      const prevTimer = useRef(null);
      const startTimer = useRef(null);
    
      const imageUrl = images[Math.round(source)]
    
      const animate = useCallback(timer => {
         if(startTimer.current === null) startTimer.current = timer;
      
        const t = timer - startTimer.current;
        
        if (t <= 5000) {
          if (prevTimer.current !== null) {
            const iteration = t - prevTimer.current;
            setSource((prevCount => (prevCount + iteration * 0.002) % 1))
          }
          prevTimer.current = t;
          frameId.current = requestAnimationFrame(animate);
        } else if (t > 5000) {
          setSource(2); //sets the frame to images[2] after timer reaches 5000
        }
      }, []);
      
      const startAnimation = useCallback(() => {
        setSource(0);
        cancelAnimationFrame(frameId.current);
        prevTimer.current = null;
        startTimer.current = null;
        frameId.current = requestAnimationFrame(animate);
      }, [animate]);
      
      React.useEffect(() => {
        startAnimation();
        return () => cancelAnimationFrame(frameId.current);
      }, [startAnimation]);
    
      return (
        <img 
          onClick={startAnimation}
          src={imageUrl} 
          className="img" />
      );
    }
    
    const images = ['https://picsum.photos/id/237/200/300', 'https://picsum.photos/id/238/200/300', 'https://picsum.photos/id/239/200/300'];
    
    ReactDOM
      .createRoot(root)
      .render(<PlaceHolder images={images} />);
    <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
    
    <div id="root"></div>