Jump to content
Search Community

Update React State via Timeline

velkas test
Moderator Tag

Go to solution Solved by Rodrigo,

Recommended Posts

My goal is update a Three JS scene (colors, camera position, material properties, etc.) based on state properties set by the timeline. My scene is rendered inside of the BlobScene component which accepts an intensity prop. In the sample below, I'd like to update the intensity as the timeline is scrubbed which then updates the light intensity of my scene.

 

In the pinned section, there are two columns: the left column contains text that will be translated into view and the right column contains the BlobScene. My ultimate goal is to change the color of the blob for each corresponding text section. So when the user scrolls from the red section to the green section, for example, the blob mesh would smoothly transition it's material color from red to green.

 

Am I going about this the wrong way? I can create a minimal demo if needed.

 

import React, { useState, useEffect } from "react"
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import BlobScene from '../components/blob-scene'

function Index() {

  let [intensity, setIntensity] = useState({value: 1.5})

  useEffect(() => {

    gsap.registerPlugin(ScrollTrigger)

    // Verticals Pinned Section Story
    const tl = gsap.timeline()

    tl.from(".red", {y: "100%"})
      .from(".green", {y: "100%"})
      .from(".blue", {y: "100%"});

    // PROBLEM LINE
    tl.to(intensity, { value: setIntensity(0.5) })
    
    ScrollTrigger.create({
        animation: tl,
        trigger: "#verticals",
        scroller: "#___gatsby",
        start: "top top",
        end: "+=4000", 
        scrub: true,
        pin: true,
        anticipatePin: 1
    });

    // each time the window updates, we should refresh ScrollTrigger and then update LocomotiveScroll. 
    ScrollTrigger.addEventListener('refresh', () => window.scroll.update())

    ScrollTrigger.refresh()

  }, [intensity]);

    return (
      <React.Fragment>
        <div className="page">

            <section id="verticals" className="h-screen flex items-center relative w-full overflow-hidden">
                <div className="grid grid-cols-2 gap-6 h-full w-full">
                    
                    {/* Grid Column 1 */}
                    <div className="flex items-center">
                    <div className="relative w-full overflow-hidden h-80">
                    <div className="panel absolute h-full w-full red bg-red-500 text-black">
                        <h2>Panel 1</h2>
                    </div>
                    <div className="panel absolute h-full w-full green bg-green-500">
                        <h2>Panel 2</h2>
                    </div>
                    <div className="panel absolute h-full w-full blue bg-blue-500">
                        <h2>Panel 3</h2>
                    </div>    
                    </div>
                    </div>

                    {/* Grid Column 2 */}
                    <div className="flex items-center">
                        <BlobScene intensity={intensity.value}></BlobScene>
                    </div>

                </div>
            </section>
        </div>
      </React.Fragment>
    )
}

export default Index

 

Link to comment
Share on other sites

  • Solution

Hi and welcome to the GreenSock forums.

 

There are a few issues with the code you posted.

 

First, you're wrapping all your relevant code inside a single useEffect hook, which depends on the value of the state property intensity. The problem here is that, if you want to update the value of intensity using a GSAP instance, you can't create and run that instance inside a hook that is called when the same value is updated. You can easily create a bug that will start an infinite loop or a potential huge memory leak. Use a useEffect hook with an empty dependencies array in order to run that only when the component is mounted. Also don't run the registerPlugin method on a hook that will be ran several times, there is no need for doing that.

 

Second, try to create and store your GSAP instances and ScrollTrigger instances using the useRef hook in order to create instances that will persist throughout re-renders. Right now every time the intensity value is changed you'll end up re-creating the GSAP instance, which might not be necessary, same thing with the ScrollTrigger instances. Also this allows you to easily kill your instances when the component is unmounted during the cleanup stage.

 

Third, state values are meant to be updated only by their corresponding callback, that's the whole reason why the useState hook creates two elements the variable holding the state value and a callback that updates such value. With this tl.to(intensity, { value: setIntensity(0.5) }) basically you're telling GSAP to update something that already has a method to be updated. You can update the value of a dummy object and using an onUpdate callback you can pass the setIntensity method and the value being tweened as the new state value:

gsap.to({value: 1.5}, {
  value: 0.5,
  onUpdate: setIntensity,
  onUpdateParams: [value]
});

Finally, for what I see in your code, you're not really using the intensity value anywhere else in your code. You're only passing it as a prop to the blob component, so that's another reason to remove it as a dependency in your useEffect hook.

 

As you mentioned a reduced live editable sample in codesandbox would prove very helpful to understand what you're trying to do. No need to set up the whole THREE code, since it seems to be irrelevant to the question at this point, just a simple one describing what you're trying to achieve.

 

Happy Tweening!!!

  • Like 3
Link to comment
Share on other sites

Thanks, that all makes sense.

 

The one part I'm having trouble understanding is the "dummy object". I understand the concept but am having trouble implementing the solution. I've created a minimal demo here: https://codesandbox.io/s/react-threejs-gsap-i0id1?file=/src/index.js

 

And to reiterate, as the sections are scrolled, I would like to update the intensity (and other props) simultaneously. Again, the ultimate goal is to smoothly transition the blob colors from red to green to blue as the sections are scrolled. Hope that makes sense and let me know if there's anything I can clarify. 

 

Thanks for your response!

 

EDIT:

Seems that I was able to get the intensity demo working using get syntax and some temp variables - is there a way to optimize the configuration in the demo?

Link to comment
Share on other sites

Yeah, you ran into some scope issues. This can be solve really simple like this:

useEffect(() => {
  gsap.registerPlugin(ScrollTrigger)

  const tl = gsap.timeline()

  // PROBLEM CODE
  var firstIntensity = { value: intensity },
      secondIntensity = { value: 2 }

  tl.from(redRef.current, { y: '100%' })
    .to(
      firstIntensity,
      {
        value: 2,
        ease: 'none',
        onUpdate() {
          setIntensity(parseFloat(firstIntensity.value.toFixed(2)));
        }
      },
      0
    )
    .from(greenRef.current, { y: '100%' })
    .to(
      secondIntensity,
      {
        value: 0,
        ease: 'none',
        onUpdate() {
          setIntensity(parseFloat(secondIntensity.value.toFixed(2)));
        }
      },
      '<'
    )
    .from(blueRef.current, { y: '100%' })
  // END PROBLEM CODE

  ScrollTrigger.create({
    animation: tl,
    trigger: pinnedSectionRef.current,
    start: 'top top',
    end: '+=4000',
    scrub: true,
    pin: true,
    anticipatePin: 1
  })
}, [])

The reason for using parseFloat there is because toFixed returns a string, so in some cases is better to set it back to a number just in case. Some JS libraries are quite picky about that type of things:

 

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toFixed#return_value

 

Happy Tweening!!!

  • Like 3
Link to comment
Share on other sites

  • 1 year later...
On 3/19/2021 at 8:02 PM, Rodrigo said:

The reason for using parseFloat there is because toFixed returns a string, so in some cases is better to set it back to a number just in case. Some JS libraries are quite picky about that type of things:

It seems like Math.round() is a better solution, but it is not! In some cases it will NOT round correctly. Also, toFixed() will NOT round correctly in some cases.

 

To correct the rounding problem with the previous Math.round() and toFixed(), you can define a custom JavaScript round function that performs a "nearly equal" test to determine whether a fractional value is sufficiently close to a midpoint value to be subject to midpoint rounding. The following function return the value of the given number rounded to the nearest integer accurately.

Number.prototype.roundTo = function(decimal) {
  return +(Math.round(this + "e+" + decimal)  + "e-" + decimal);
}

var num = 9.7654;
console.log( num.roundTo(2)); //output 9.77

 

Link to comment
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...