Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to calculate the speed of an animation in seconds based on current pageYOffset and previousPageYOffset?

I'm trying to rotate a wheel based on user interaction. If the user is accelerating his scroll, the wheel should spin faster. Likewise, if the user is decelerating his scroll, the wheel should stop. I'm applying these conditions with styled-components and React state, using the Skrollr library.

Here is what I have:

import React from "react";
import styled, { createGlobalStyle, css, keyframes } from "styled-components";

export default function App() {
  const [previousPosition, setPreviousPosition] = React.useState(0);
  const [previousDelta, setPreviousDelta] = React.useState(0);
  const [speed, setSpeed] = React.useState(1);
  const [isStopping, setIsStopping] = React.useState(false);

  React.useEffect(() => {
    skrollr.init();

    window.addEventListener("scroll", handleScroll);

    return () => window.removeEventListener("scroll", handleScroll);
  }, [previousPosition, previousDelta]);

  const handleScroll = React.useCallback(() => {
    // get delta
    const delta = window.pageYOffset - previousPosition;

    if (previousDelta > delta) {
      // is stopping
      setIsStopping(true);
      setSpeed(0);
    } else {
      // is accelerating

      // calculate delta as a percentage
      const deltaAsPercentage = (delta / previousPosition) * 100;
      console.log("deltaAsPercentage", deltaAsPercentage);

      setIsStopping(false);
      setSpeed(deltaAsPercentage);
    }

    setPreviousPosition(window.pageYOffset);
  }, [previousPosition, previousDelta]);

  return (
    <Container data-10000p="transform: translateX(0%)">
      <GlobalStyles />
      <WheelContainer speed={speed} isStopping={isStopping}>
        <Image src="wheel.png" alt="wheel" />
      </WheelContainer>
    </Container>
  );
}

const Container = styled.div`
  position: fixed;
  width: 100%;
  display: flex;
  top: 0;
  left: 0;
`;

const spinForward = keyframes`
    0% {
        transform: rotateZ(0deg);
    }
    50% {
        transform: rotateZ(180deg);
    }
    100% {
        transform: rotateZ(360deg);
    }
`;

const stopWheel = keyframes`
    0% {
        transform: rotateZ(0deg);
    }
    100% {
        transform: rotateZ(360deg);
    }
`;

const WheelContainer = styled.div`
  ${({ speed, isStopping }) =>
    isStopping
      ? css`
          animation: ${stopWheel} 1s ease-out;
        `
      : css`
          animation: ${speed
            ? css`
                ${spinForward} ${speed}s linear infinite
              `
            : ""};
        `}
`;

const Image = styled.img``;

const GlobalStyles = createGlobalStyle`
    * {
        &::-webkit-scrollbar {
            display: none;
        }
    }
`;

I'm not great at math, so I'm doing my best with what I know.

The first thing that I do on scroll is determine whether or not the wheel should be stopping or accelerating. If it's stopping, I alter the state of the component and let WheelContainer know that it should swap out the current animation. If the wheel shouldn't be stopping I keep the current animation and alter the speed of the rotation.

Anyway, I've got it kind of working. The issue that I'm running into is that it doesn't recognize a "slower scroll". For instance, the user could be scrolling quickly or slowly but scrolling nonetheless. A slow scroll shouldn't necessarily mean that it should come to a stop.

The other issue is that spinBack seems to never be invoked. And even if it was, I'm having trouble figuring out how I'd be able to differentiate between a "slower" scroll and a backward spin.

Finally, I should note that the accelerated scroll seems to be recognized only on a mac's trackpad. I just plugged in an external mouse to test it out, and it doesn't really quite rotate as expected.

In addition to this, I feel like there is a better approach to this. Perhaps something with Math.sin or Math.cos, or something similar that I should've been paying more attention to in high school math class. The component just feels too bulky, when it seems like there is a much more simple approach.

Here's a Sandbox,

priceless-snow-8wkjo

like image 866
Mike K Avatar asked Nov 07 '22 03:11

Mike K


1 Answers

Here's my attempt:

The first problem for me was that the code was executing as the input was coming in. Felt it needed some little time delay for the function to calculate. We'll do that with a setTimeout.

Second: yup, you're right. We need a math/trig-like function that will give a value close to zero for very small values, and a value close to 1 for increasing values.

Third is...well, this was more of a personal thing — wasn't sure if this was intentional, but I noticed that the spinBack function wouldn't work once you'd scrolled to the top (i.e. window.pageYOffset = 0). So, instead of scroll eventListener, I used wheel eventListener — this way, I could use the deltaY property to see by how much it changed.

Fourth, I set the speed to be a function of distance covered by time.

Finally: the CSS speed thing was counter-intuitive for me at first — for some reason, the higher the value the slower it rotated! I kept wondering what was wrong till I realised my silly error!😅

Wrapping it all up, I'll only paste the sections I changed (I've put comments where I made changes):

// == adding these
  var scrollTimeoutVar = null, scrollAmount = 0, totalScrollAmount = 0, scrollTimes = [];

  var theMathFunction = (x) => {
    return x/(2 + x);   // <- you can use a different value from '2' to determine how fast your want the curve to grow
  };

export default function App() {
  const [previousPosition, setPreviousPosition] = React.useState(0);
  const [previousDelta, setPreviousDelta] = React.useState(0);
  const [speed, setSpeed] = React.useState(1);
  const [isStopping, setIsStopping] = React.useState(false);
  const [isMovingForward, setIsMovingForward] = React.useState(true);

  React.useEffect(() => {
    skrollr.init();

    window.addEventListener("wheel", handleScroll); // <- changed from 'scroll'

    return () => window.removeEventListener("wheel", handleScroll); // <- changed from 'scroll'
  }, [previousPosition, previousDelta, isStopping]);

  const handleScroll = React.useCallback((wheelEvent) => {  // this time, we're passing in a wheel event
    // console.log("wheelEvent", wheelEvent);  // if you'd like to see it, uncomment

    // get deltaY, which will be our distance covered
    scrollAmount += wheelEvent.deltaY;  // this will be reset every time a new scroll is recorded
    totalScrollAmount += scrollAmount;  // this one will never be reset
    // add all the times that occured in the scroll
    scrollTimes.push((new Date()).getTime());
    setIsStopping(false);

    // adding this here, to cancel the setTimeout when the user is done
    if(scrollTimeoutVar !== null){
      clearTimeout(scrollTimeoutVar);
    };

    scrollTimeoutVar = setTimeout(() => {

      if(!isStopping){
        
        setIsStopping(true);
        // const delta = window.pageYOffset - previousPosition; <- no longer need this
        // get time difference
        const timeDiff = scrollTimes[scrollTimes.length - 1] - scrollTimes[0]; // when the scroll stopped - when it started


        // get direction
        // since our scrollAmount can either be positive or negative depending on the direction the user scrolled...
        // ...we can use the cumulative amount to determine direction:
        const isMovingForward =  totalScrollAmount > 0; // no longer window.pageYOffset > previousPosition;
        // console.log("isMovingForward", isMovingForward);  // if you'd like to see it, uncomment
        setIsMovingForward(isMovingForward);

        // calculate delta as a percentage
        // const deltaAsPercentage = (delta / previousPosition) * 100;  <- we no longer need this
        // setSpeed(deltaAsPercentage);  <- we no longer need this

        // our speed will be the simple physics equation of change in distance divided by change in time
        const current_speed = Math.abs(scrollAmount/timeDiff);   // we're using the 'absolute' function, so the speed is always a positive value regardless of the direction of scroll
        const trig_speed = theMathFunction(current_speed);       // give it a value between 0 and 1


        // here's the counter-intuitive part
        let speedToSet = 0;
        // if it's currently movingforward...
        if(isMovingForward){
          // should we go faster, or slower? in this case, subtract to go FASTER, add to go SLOWER
          speedToSet = scrollAmount > 0 ? (speed - trig_speed) : (speed + trig_speed);
        }
        // if not, it will be the reverse (because we're going in the OPPOSITE direction)
        else{
          // in this case, subtract to go SLOWER, add to go FASTER
          speedToSet = scrollAmount > 0 ? (speed + trig_speed) : (speed - trig_speed);
        }
        // console.log("speedToSet", speedToSet);  // if you'd like to see it, uncomment


        setSpeed(speedToSet);

        // set it back to zero, pending a new mouse scroll
        scrollAmount = 0;
        scrollTimes = [];

      }

    },100);
  }, [previousPosition, previousDelta, isStopping]);

With the above, the rotation will accelerate in whatever direction the user scrolls their mouse (and will decelerate should they change direction).

like image 70
Deolu A Avatar answered Nov 15 '22 06:11

Deolu A