Home > Software design >  Matching setState interval with css animation
Matching setState interval with css animation

Time:06-24

I am rotating the items in an array of images inside an interval of 5 seconds. Then I have a css animation with styled components that eases in the gallery images with a fade that occurs at the same time interval.

const fadeIn = keyframes`
  5%, 95% { opacity: 1 }
  100% { opacity: 0 }
`
export const Gallery = styled.div<{ lapse: number }>`
  position: relative;
  margin: 0 auto;
  max-width: 1064px;
  opacity: 0;
  animation: ${fadeIn} ease-in-out ${({ lapse }) => `${lapse}s`} infinite;
`

The problem is that when I change the state even thou at first it seems in sync, eventually the setState takes a bit longer

import React, { useState, useEffect } from 'react'
const [images, setImages] = useState<string[]>([])

// Time in seconds for each image swap, fading in between
const lapse = 5
...

useEffect(() => {
  // Clone the images array
  const imgs = [...images]
        
  // Time interval same as css animation to fade in
  const interval = setInterval(() => {
    // Take the first element and put it at the end
    imgs.push(...imgs.splice(0, 1))
    // Update the state, this seems to desync as time passes
    setImages(imgs)
  }, lapse * 1000)

  return () => clearInterval(interval)
}, [images])
        

return (
  <Gallery lapse={lapse}>
    <Portal>
      <img src={imgs/${images[0]}`}
    </Portal>
    <Thumbnails>
      <Thumbwrapper> 
        <img src={imgs/${images[1]}`}
      </Thumbwrapper>
      <Thumbwrapper> 
        <img src={imgs/${images[2]}`}
      </Thumbwrapper>
     </Thumbnails>
  </Gallery>
)

Is there a way I can make sure the swapping happends smoothly?

enter image description here

CodePudding user response:

Make the component class based,

constructor(props) {
    super(props);
    this.state = {
        interval: null,
        images: ["1.jpg", "2.jpg", "3.jpg"],
        timeChanged: null
    };
}

// setInterval when component is mounted
componentDidMount() {
    var interval = setInterval(()=> {
        // use callback argument for setState 
        // when new value (images) depends on old value
        this.setState((state)=>({
            timeChanged: (new Date()).toString(),
            images: state.images.slice(1).concat(state.images[0]) 
            // add 1st img at the end
        });
    }, 3000);
    this.setState({interval: interval});
}

// clearInterval before component is unmounted
componentWillUnmount() {
    clearInterval(this.state.interval);
}

When the state is updated using its old value the callback argument should be used.
(See https://reactjs.org/docs/state-and-lifecycle.html#state-updates-may-be-asynchronous)

slice returns a portion of an array.

Also see how to update state arrays in React.

If you still wish to use hooks. Just call setImages with (images)=>images.slice(1).concat(images[0]).
I'm not sure where the clearTimeout should be, when using hooks.

CodePudding user response:

It would be great if we can have example in codesandbox. Also I would like to ask you, maybe I am missing something, but how do you determine when to stop using effect, since it depends on image, which is, I assume, part of the useSate hook, and in that same effect you are updating that same image, which will lead to another effect usage(leading to infinity loop)?

UPDATED ANSWER

I made similar example on codesandbox to look it on my own. It looks to me that main problem here is sync between applied CSS and actual update in your react component. When using setInterval with 5000ms it does not mean that it will be triggered right after 5000ms, but CSS animation on the other side is always right on time, so it stuck when two of those become unsynced. It looks like the only way to solve this is to recreate img elements on each reorder, by introducing timestamp state and use it as part of the each img key in order to force img recreation on each update. Also you would need to change animation in simpler way.

CSS would besomething like this:

  @keyframes fadeIn {
    from {
      opacity: 0;
    }
    to {
      opacity: 1;
    }
  }

.image {
  position: relative;
  margin: 0 auto;
  max-width: 1064px;
  animation: fadeIn 3s;
  opacity: 1;
}

And functional component would be something like this(hook part only):

      const [images, setImages] = useState(["1.jpg", "2.jpg", "3.jpg"]);
      const [dateTimeWhenChanged, setDatetimeWhenChanged] = useState("");
    
      useEffect(() => {
        const imgs = [...images];
    
        // Time interval same as css animation to fade in
        const interval = setInterval(() => {
          // Take the first element and but it to the end
          imgs.push(...imgs.splice(0, 1));
          // Update the state, this seems to desync as time passes
          setImages(imgs);
          setDatetimeWhenChanged(new Date().toString());
        }, 5 * 1000);
        
         return (
    <div>
      {images.map((img, ind) => (
        <img
          key={`${ind}-${dateTimeWhenChanged}`}
          alt={img}
          src={`images/${img}`}
          className="imgg"
        />
      ))}
    </div>
  );

CodePudding user response:

Given the lack of a better proposition, I came to the conclusion that having two 'clocks' to keep the time, one being react setState and the other css animation, was at the core of the problem.

So I am now keeping a single interval with a setTimeout to make sure it goes in order then substract the time taken on the timeouts from the interval and toggle the class for css

export const Gallery = styled.div<{ fadeIn: boolean }>`
  position: relative;
  margin: 0 auto;
  max-width: 1064px;
  transition: opacity 0.3s;
  opacity: ${({ fadeIn }) => (fadeIn ? 1 : 0)};
`
import React, { useState, useEffect } from 'react'
const [images, setImages] = useState<string[]>([])
const [fadeIn, setFadeIn] = useState<boolean>(true)

useEffect(() => {
  let interval: ReturnType<typeof setInterval>
  if (images.length) {

    interval = setInterval(() => {
      setFadeIn(false)

      setTimeout(() => {
        setImages(images => images.slice(1).concat(images[0]) )
        setFadeIn(true)
      }, 400)
    }, (lapse - 0.4) * 1000)
  }
  return () => clearInterval(interval)
}, [images])

const getImage = (i: number) =>  <img src={imgs/${images[i]}`} />

<Gallery fadeIn={fadeIn}>
  <Portal>{getImage(0)}</Portal>
  <Thumbnails>
    <Thumbwrapper>{getImage(1)}</Thumbwrapper>
    <Thumbwrapper>{getImage(2)}</Thumbwrapper>
  </Thumbnails>
</Gallery>

I did however used @Nice Books better setState aproach

  • Related