Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
October 11, 2021 07:40 pm GMT

Going "Meta GSAP": The Quest for "Perfect" Infinite Scrolling

I'm not sure how this one came about. But, it's a story. This article is more about grokking a concept, one that's going to help you think about your animations in a different way. It so happens that this particular example features infinite scrollingspecifically the "perfect" infinite scroll for a deck of cards without duplicating any of them.

Why am I here? Well, this all started from a tweet. A tweet that got me thinking about layouts and side-scrolling content.

I took that concept and used it on my site. And it's still there in action at the time of writing.

Then I got to thinking more about gallery views and side-scrolling concepts. We hopped on a livestream and decided to try and make something like the old Apple "Cover Flow" pattern. Remember it?

My first thoughts for making this assumed I'd make this so it works without JavaScript, as it does in the demo above, in a way that uses "progressive enhancement." I grabbed Greensock and ScrollTrigger, and off we went. I came away from that work pretty disappointed. I had something but couldn't quite get infinite scrolling to work how the way I wanted. The "Next and Previous" buttons didn't want to play ball. You can see it here, and it requires horizontal scrolling.

So I opened up a new thread on the Greensock forum. Little did I know I was about to open myself up to some serious learning! We solved the issue with the buttons. But, being me, I had to ask whether something else was possible. Was there a "clean" way to do infinite scrolling? I'd tried something on stream but had no luck. I was curious. I'd tried a technique like that used in this pen which I created for the ScrollTrigger release.

The initial answer was that it is kinda tricky to do:

The hard part about infinite things on scroll is that the scroll bar is limited while the effect that you're wanting is not. So you have to either loop the scroll position like this demo (found in the ScrollTrigger demos section) or hook directly into the scroll-related navigation events (like the wheel event) instead of actually using the actual scroll position.

I figured that was the case and was happy to leave it "as-is." A couple of days passed and Jack dropped a reply that kinda blew my mind when I started digging into it. And now, after a bunch of going through it, I'm here to share the technique with you.

Animate anything

One thing that is often overlooked with GSAP, is that you can animate almost anything with it. This is often because visual things are what spring to mind when thinking about animationthe actual physical movement of something. Our first thought isn't about taking that process to a meta-level and animating from a step back.

But, think about animation work on a larger scale and then break it down into layers. For example, you play a cartoon. The cartoon is a collection of compositions. Each composition is a scene. And then you have the power to scrub through that collection of compositions with a remote, whether its on YouTube, using your TV remote, or whatever. There are almost three levels to what is happening.

And this is the trick we need for creating different types of infinite loops. This is the main concept right here. We animate the play head position of a timeline with a timeline. And then we can scrub that timeline with our scroll position.

Don't worry if that sounds confusing. We're going to break it down.

Going meta

Let's start with an example. We're going to create a tween that moves some boxes from left to right. Here it is.

Ten boxes that keep going left to right. That's quite straightforward with Greensock. Here, we use fromTo and repeat to keep the animation going. But, we have a gap at the start of each iteration. Were also using stagger to space out the movement and thats something that will play an important role as we continue.

gsap.fromTo('.box', {  xPercent: 100}, {  xPercent: -200,  stagger: 0.5,  duration: 1,  repeat: -1,  ease: 'none',})

Now comes the fun part. Let's pause the tween and assign it to a variable. Then lets create a tween that plays it. We can do this by tweening the totalTime of the tween, which allows us to get or set the tweens playhead tween, while considering repeats and repeat delays.

const SHIFT = gsap.fromTo('.box', {  xPercent: 100}, {  paused: true,  xPercent: -200,  stagger: 0.5,  duration: 1,  repeat: -1,  ease: 'none',})const DURATION = SHIFT.duration()gsap.to(SHIFT, {  totalTime: DURATION,  repeat: -1,  duration: DURATION,  ease: 'none',})

This is our first meta tween. It looks exactly the same but were adding another level of control. We can change things on this layer without affecting the original layer. For example, we could change the tween ease to power4.in. This completely changes the animation but without affecting the underlying animation. We're kinda safeguarding ourselves with a fallback.

Not only that, we might choose to repeat only a certain part of the timeline. We could do that with another fromTo, like this:

The code for that would be something like this.

gsap.fromTo(SHIFT, {  totalTime: 2,}, {  totalTime: DURATION - 1,  repeat: -1,  duration: DURATION,  ease: 'none'})

Do you see where this is going? Watch that tween. Although it keeps looping, the numbers flip on each repeat. But, the boxes are in the correct position.

Achieving the perfect loop

If we go back to our original example, theres a noticeable gap between each repetition.

Here comes the trick. The part that unlocks everything. We need to build a perfect loop.

Let's start by repeating the shift three times. It's equal to using repeat: 3. Notice how weve removed repeat: -1 from the tween.

const getShift = () => gsap.fromTo('.box', {  xPercent: 100}, {  xPercent: -200,  stagger: 0.5,  duration: 1,  ease: 'none',})const LOOP = gsap.timeline()  .add(getShift())  .add(getShift())  .add(getShift())

Weve turned the initial tween into a function that returns the tween and we add it to a new timeline three times. And this gives us the following.

OK. But, there's still a gap. Now we can bring in the position parameter for adding and positioning those tweens. We want it to be seamless. That means inserting each each set of tweens before the previous one ends. Thats a value based on the stagger and the amount of elements.

const stagger = 0.5 // Used in our shifting tweenconst BOXES = gsap.utils.toArray('.box')const LOOP = gsap.timeline({  repeat: -1})  .add(getShift(), 0)  .add(getShift(), BOXES.length * stagger)  .add(getShift(), BOXES.length * stagger * 2)

If we update our timeline to repeat and watch it (while adjusting the stagger to see how it affects things)

You'll notice that there's a window in the middle there that creates a "seamless" loop. Recall those skills from earlier where we manipulated time? That's what we need to do here: loop the window of time where the loop is "seamless."

We could try tweening the totalTime through that window of the loop.

const LOOP = gsap.timeline({  paused: true,  repeat: -1,}).add(getShift(), 0).add(getShift(), BOXES.length * stagger).add(getShift(), BOXES.length * stagger * 2)gsap.fromTo(LOOP, {  totalTime: 4.75,},{  totalTime: '+=5', // HIGHLIGHT  duration: 10,  ease: 'none',  repeat: -1,})

Here, were saying tween the totalTime from 4.75 and add the length of a cycle to that. The length of a cycle is 5. And thats the middle window of the timeline. We can use GSAPs nifty += to do that, which gives us this:

Take a moment to digest what's happening there. This could be the trickiest part to wrap your head around. We're calculating windows of time in our timeline. Its kinda hard to visualise but Ive had a go.

This is a demo of a watch that takes 12 seconds for the hands go round once. It's looped infinitely with repeat: -1 and then we're using fromTo to animate a specific time window with a given duration. If you, reduce the time window to say 2 and 6, then change the duration to 1, the hands will go from 2 o'clock to 6 o'clock on repeat. But, we never changed the underlying animation.

Try configuring the values to see how it affects things.

At this point, its a good idea to put together a formula for our window position. We could also use a variable for the duration it takes for each box to transition.

const DURATION = 1const CYCLE_DURATION = BOXES.length * STAGGERconst START_TIME = CYCLE_DURATION + (DURATION * 0.5)const END_TIME = START_TIME + CYCLE_DURATION

Instead of using three stacked timelines, we could loop over our elements three times where we get the benefit of not needing to calculate the positions. Visualizing this as three stacked timelines is a neat way to grok the concept, though, and a nice way to help understand the main idea.

Lets change our implementation to create one big timeline from the start.

const STAGGER = 0.5const BOXES = gsap.utils.toArray('.box')const LOOP = gsap.timeline({  paused: true,  repeat: -1,})const SHIFTS = [...BOXES, ...BOXES, ...BOXES]SHIFTS.forEach((BOX, index) => {  LOOP.fromTo(BOX, {    xPercent: 100  }, {    xPercent: -200,    duration: 1,    ease: 'none',  }, index * STAGGER)})

This is easier to put together and gives us the same window. But, we don't need to think about math. Now we loop through three sets of the boxes and position each animation according to the stagger.

How might that look if we adjust the stagger? It will squish the boxes closer together.

But, its broken the window because now the totalTime is out. We need to recalculate the window. Nows a good time to plug in the formula we calculated earlier.

const DURATION = 1const CYCLE_DURATION = STAGGER * BOXES.lengthconst START_TIME = CYCLE_DURATION + (DURATION * 0.5)const END_TIME = START_TIME + CYCLE_DURATIONgsap.fromTo(LOOP, {  totalTime: START_TIME,},{  totalTime: END_TIME,  duration: 10,  ease: 'none',  repeat: -1,})

Fixed!

We could even introduce an offset if we wanted to change the starting position.

const STAGGER = 0.5const OFFSET = 5 * STAGGERconst START_TIME = (CYCLE_DURATION + (STAGGER * 0.5)) + OFFSET

Now our window starts from a different position.

But still, this isnt great as it gives us these awkward stacks at each end. To get rid of that effect, we need to think about a "physical" window for our boxes. Or think about how they enter and exit the scene.

Were going to use document.body as the window for our example. Let's update the box tweens to be individual timelines where the boxes scale up on enter and down on exit. We can use yoyo and repeat: 1 to achieve entering and exiting.

SHIFTS.forEach((BOX, index) => {  const BOX_TL = gsap    .timeline()    .fromTo(      BOX,      {        xPercent: 100,      },      {        xPercent: -200,        duration: 1,        ease: 'none',      }, 0    )    .fromTo(      BOX,      {        scale: 0,      },      {        scale: 1,        repeat: 1,        yoyo: true,        ease: 'none',        duration: 0.5,      },      0    )  LOOP.add(BOX_TL, index * STAGGER)})

Why are we using a timeline duration of 1? It makes things easier to follow. We know the time is 0.5 when the box is at the midpoint. It's worth noting that easing won't have the effect we usually think of here. In fact, easing will actually play a part in how the boxes position themselves. For example, an ease-in would bunch the boxes up on the right before they move across.

The code above gives us this.

Almost. But, our boxes disappear for a time in the middle. To fix this, lets introduce the immediateRender property. It acts like animation-fill-mode: none in CSS. Were telling GSAP that we dont want to retain or pre-record any styles that are being set on a box.

SHIFTS.forEach((BOX, index) => {  const BOX_TL = gsap    .timeline()    .fromTo(      BOX,      {        xPercent: 100,      },      {        xPercent: -200,        duration: 1,        ease: 'none',        immediateRender: false, // HIGHLIGHT      }, 0    )    .fromTo(      BOX,      {        scale: 0,      },      {        scale: 1,        repeat: 1,        zIndex: BOXES.length + 1,        yoyo: true,        ease: 'none',        duration: 0.5,        immediateRender: false, // HIGHLIGHT      },      0    )  LOOP.add(BOX_TL, index * STAGGER)})

That small change fixes things for us! Note how weve also included z-index: BOXES.length. That should safeguard us against any z-index issues.

There we have it! Our first infinite seamless loop. No duplicate elements and perfect continuation. Were bending time! Pat yourself on the back if youve gotten this far!

If we want to see more boxes at once, we can tinker with the timing, stagger, and ease. Here, we have a STAGGER of 0.2 and weve also introduced opacity into the mix.

The key part here is that we can make use of repeatDelay so that the opacity transition is quicker than the scale. Fade in over 0.25 seconds. Wait 0.5 seconds. Fade back out over 0.25 seconds.

.fromTo(  BOX, {    opacity: 0, // HIGHLIGHT  }, {    opacity: 1, // HIGHLIGHT    duration: 0.25,    repeat: 1,    repeatDelay: 0.5, // HIGHLIGHT    immediateRender: false,    ease: 'none',    yoyo: true,  }, 0)

Cool! We could do whatever we want with those in and out transitions. The main thing here is that we have our window of time that gives us the infinite loop.

Hooking this up to scroll

Now that we have a seamless loop, lets attach it to scroll. For this we can use GSAPs ScrollTrigger. This requires an extra tween to scrub our looping window. Note how weve set the loop to be paused now, too.

const LOOP_HEAD = gsap.fromTo(LOOP, {  totalTime: START_TIME,},{  totalTime: END_TIME,  duration: 10,  ease: 'none',  repeat: -1,  paused: true,})const SCRUB = gsap.to(LOOP_HEAD, {  totalTime: 0,  paused: true,  duration: 1,  ease: 'none',})

The trick here is to use ScrollTrigger to scrub the play head of the loop by updating the totalTime of SCRUB. There are various ways we could set up this scroll. We could have it horizontal or bound to a container. But, what we're going to do is wrap our boxes in a .boxes element and pin that to the viewport. (This fixes its position in the viewport.) We'll also stick with vertical scrolling. Check the demo to see the styling for .boxes which sets things to the size of the viewport.

import ScrollTrigger from 'https://cdn.skypack.dev/gsap/ScrollTrigger'gsap.registerPlugin(ScrollTrigger)ScrollTrigger.create({  start: 0,  end: '+=2000',  horizontal: false,  pin: '.boxes',  onUpdate: self => {    SCRUB.vars.totalTime = LOOP_HEAD.duration() * self.progress    SCRUB.invalidate().restart()  }})

The important part is inside onUpdate. Thats where we set the totalTime of the tween based on the scroll progress. The invalidate call flushes any internally recorded positions for the scrub. The restart then sets the position to the new totalTime we set.

Try it out! We can go back and forth in the timeline and update the position.

How cool is that? We can scroll to scrub a timeline that scrubs a timeline that is a window of a timeline. Digest that for a second because that's what's happening here.

Time travel for infinite scrolling

Up to now, we've been manipulating time. Now we're going to time travel!

To do this, we're going to use some other GSAP utilities and we're no longer going to scrub the totalTime of LOOP_HEAD. Instead, we're going to update it via proxy. This is another great example of going meta GSAP.

Lets start with a proxy object that marks the playhead position.

const PLAYHEAD = { position: 0 }

Now we can update our SCRUB to update the position. At the same time, we can use GSAPs wrap utility, which wraps the position value around the LOOP_HEAD duration. For example, if the duration is 10 and we provide the value 11, we will get back 1.

const POSITION_WRAP = gsap.utils.wrap(0, LOOP_HEAD.duration())const SCRUB = gsap.to(PLAYHEAD, {  position: 0,  onUpdate: () => {    LOOP_HEAD.totalTime(POSITION_WRAP(PLAYHEAD.position))  },  paused: true,  duration: 1,  ease: 'none',})

Last, but not least, we need to revise ScrollTrigger so it updates the correct variable on the SCRUB. Thats position, instead of totalTime.

ScrollTrigger.create({  start: 0,  end: '+=2000',  horizontal: false,  pin: '.boxes',  onUpdate: self => {    SCRUB.vars.position = LOOP_HEAD.duration() * self.progress    SCRUB.invalidate().restart()  }})

At this point weve switched to a proxy and we wont see any changes.

We want an infinite loop when we scroll. Our first thought might be to scroll to the start when we complete scroll progress. And it would do exactly that, scroll back. Although that's what we want to do, we don't want the playhead to scrub backwards. This is where totalTime comes in. Remember? It gets or sets the position of the playhead according to the totalDuration which includes any repeats and repeat delays.

For example, say the duration of the loop head was 5 and we got there, we won't scrub back to 0. Instead, we will keep scrubbing the loop head to 10. If we keep going, it'll go to 15, and so on. Meanwhile, we'll keep track of an iteration variable because that tells us where we are in the scrub. We'll also make sure that we only update iteration when we hit the progress thresholds.

Lets start with an iteration variable:

let iteration = 0

Now lets update our ScrollTrigger implementation:

const TRIGGER = ScrollTrigger.create({  start: 0,  end: '+=2000',  horizontal: false,  pin: '.boxes',  onUpdate: self => {    const SCROLL = self.scroll()    if (SCROLL > self.end - 1) {      // Go forwards in time      WRAP(1, 1)    } else if (SCROLL < 1 && self.direction < 0) {      // Go backwards in time      WRAP(-1, self.end - 1)    } else {      SCRUB.vars.position = (iteration + self.progress) * LOOP_HEAD.duration() // HIGIHLIGHT      SCRUB.invalidate().restart()    }  }})

Notice how we're now factoring iteration into the position calculation. Remember that this gets wrapped with the scrubber. We're also detecting when we hit the limits of our scroll, and that's the point where we WRAP. This function sets the appropriate iteration value and sets the new scroll position.

const WRAP = (iterationDelta, scrollTo) => {  iteration += iterationDelta  TRIGGER.scroll(scrollTo)  TRIGGER.update()}

We have infinite scrolling! If you have one of those fancy mice with the scroll wheel that you can let loose on, give it a go! Its fun!

Heres a demo that displays the current iteration and progress:

Scroll snapping

We're there. But, there are always nice to haves" when working on a feature like this. Let's start with scroll snapping. GSAP makes this easy, as we can use gsap.utils.snap without any other dependencies. That handles snapping to a time when we provide the points. We declare the step between 0 and 1 and we have 10 boxes in our example. That means a snap of 0.1 would work for us.

const SNAP = gsap.utils.snap(1 / BOXES.length)

And that returns a function we can use to snap our position value.

We only want to snap once the scroll has ended. For that, we can use an event listener on ScrollTrigger. When the scroll ends, we are going to scroll to a certain position.

ScrollTrigger.addEventListener('scrollEnd', () => {  scrollToPosition(SCRUB.vars.position)})

And heres scrollToPosition:

const scrollToPosition = position => {  const SNAP_POS = SNAP(position)  const PROGRESS =    (SNAP_POS - LOOP_HEAD.duration() * iteration) / LOOP_HEAD.duration()  const SCROLL = progressToScroll(PROGRESS)  TRIGGER.scroll(SCROLL)}

What are we doing here?

  1. Calculating the point in time to snap to
  2. Calculating the current progress. Lets say the LOOP_HEAD.duration() is 1 and weve snapped to 2.5. That gives us a progress of 0.5 resulting in an iteration of 2, where 2.5 - 1 * 2 / 1 === 0.5 . We calculate the progress so that its always between 1 and 0.
  3. Calculating the scroll destination. This is a fraction of the distance our ScrollTrigger can cover. In our example, weve set a distance of 2000 and we want a fraction of that. We create a new function progressToScroll to calculate it.
const progressToScroll = progress =>  gsap.utils.clamp(1, TRIGGER.end - 1, gsap.utils.wrap(0, 1, progress) * TRIGGER.end)

This function takes the progress value and maps it to the largest scroll distance. But we use a clamp to make sure the value can never be 0 or 2000. This is important. We are safeguarding against snapping to these values as it would put us in an infinite loop.

There is a bit to take in there. Check out this demo that shows the updated values on each snap.

Why are things a lot snappier? The scrubbing duration and ease have been altered. A smaller duration and punchier ease give us the snap.

const SCRUB = gsap.to(PLAYHEAD, {  position: 0,  onUpdate: () => {    LOOP_HEAD.totalTime(POSITION_WRAP(PLAYHEAD.position))  },  paused: true,  duration: 0.25,  ease: 'power3',})

But, if you played with that demo, you'll notice there's an issue. Sometimes when we wrap around inside the snap, the playhead jumps about. We need to account for that by making sure we wrap when we snapbut, only when it's necessary.

const scrollToPosition = position => {  const SNAP_POS = SNAP(position)  const PROGRESS =    (SNAP_POS - LOOP_HEAD.duration() * iteration) / LOOP_HEAD.duration()  const SCROLL = progressToScroll(PROGRESS)  if (PROGRESS >= 1 || PROGRESS < 0) return WRAP(Math.floor(PROGRESS), SCROLL)  TRIGGER.scroll(SCROLL)}

And now we have infinite scrolling with snapping!

What next?

We've completed the groundwork for a solid infinite scroller. We can leverage that to add things, like controls or keyboard functionality. For example, this could be a way to hook up "Next" and "Previous" buttons and keyboard controls. All we have to do is manipulate time, right?

const NEXT = () => scrollToPosition(SCRUB.vars.position - (1 / BOXES.length))const PREV = () => scrollToPosition(SCRUB.vars.position + (1 / BOXES.length))// Left and Right arrow plus A and Ddocument.addEventListener('keydown', event => {  if (event.keyCode === 37 || event.keyCode === 65) NEXT()  if (event.keyCode === 39 || event.keyCode === 68) PREV()})document.querySelector('.next').addEventListener('click', NEXT)document.querySelector('.prev').addEventListener('click', PREV)

That could give us something like this.

We can leverage our scrollToPosition function and bump the value as we need.

Thats it!

See that? GSAP can animate more than elements! Here, we bent and manipulated time to create an almost perfect infinite slider. No duplicate elements, no mess, and good flexibility.

Lets recap what we covered:

  • We can animate an animation.
  • We can think about timing as a positioning tools when we manipulate time.
  • How to use ScrollTrigger to scrub an animation via proxy.
  • How to use some of GSAPs awesome utilities to handle logic for us.

You can now manipulate time!

That concept of going "meta GSAP opens up a variety of possibilities. What else could you animate? Audio? Video? As for the Cover Flow demo, heres where that went!

Stay Awesome!


Original Link: https://dev.to/jh3y/going-meta-gsap-the-quest-for-perfect-infinite-scrolling-572i

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To