flackr / scroll-timeline

A polyfill of ScrollTimeline.
Apache License 2.0
887 stars 82 forks source link

How to `cancel` ScrollTimeline using only JS API #258

Closed ivodolenc closed 2 months ago

ivodolenc commented 3 months ago

Hi, awesome work on polyfill! Hope this will land in all major browsers soon.

I was wondering how we can cancel/disconnect the scroll timeline after we don't need it anymore, for example when changing route etc.

I want to avoid creating all timelines without properly disconnecting the previous ones. Also, I'm not using it with the animation API.

Here is example for built-in ScrollTimeline API in Chrome:

// starting the timeline
const timeline = new ScrollTimeline()

// works fine
console.log(timeline) // -> ScrollTimeline {source: html, axis: 'block', currentTime: CSSUnitValue, duration: CSSUnitValue}

// ... some code, a small observer to get `timeline.currentTime`

// later, when the route changes, I need to cancel this `timeline` before the route change to avoid running endlessly in bg

// is this possible without `el.animate().cancel()`?
timeline.cancel()
ivodolenc commented 2 months ago

Can someone please advise how to stop the timeline (Scroll or View Timeline) after we start it?

const  timeline = new ScrollTimeline() // or new ViewTimeline()

// how to stop it manually?
timeline.stop()

I can't find it anywhere in the docs or in the css draft documentation.

Any suggestion is welcome, maybe I'm missed something.

bramus commented 2 months ago

IIUC by “stop the the timeline” you mean freeze the animation at its current point.

You can take inspiration from https://www.bram.us/2023/10/05/run-a-scroll-driven-animation-only-once/ here. At its core, these two lines matter:

    animation.commitStyles();
    animation.cancel();

If you want to remove the timeline without freezing the frames, you can simply set the animation’s timeline to null.

bramus commented 2 months ago

I’m going to close this issue as your problem is more of a general question unrelated to the polyfill. The aim of this polyfill is to support what the native implementation supports. Your issue is related to the Web Animations API in general, not the polyfill.

ivodolenc commented 2 months ago

@bramus Thanks for the reply and suggestion, but I mean to use ScrollTimeline without Web Animation API, i.e. without using element.animate(), so I can't use animation API:

animation.commitStyles(); 
animation.cancel();

Maybe I asked the wrong question, so I'll rephrase it.

Is it necessary to stop the timeline after it is started, with this polyfill or native implementation in Chrome?

My use case is, I want to start a timeline and then observe the progress of the timeline as it scrolls.

I don't need to use any extra animation API, all I need is timeline.currentTime to convert it into progress and then use that progress for other stuff. Also, this all works great, but I'm worried that if I create a timeline multiple times, without manually stopping it, it will run infinite in the background and maybe negatively impacts on site performance.

I hope this makes more sense now.

bramus commented 2 months ago

Is it necessary to stop the timeline after it is started, with this polyfill or native implementation in Chrome?

Animations automatically get cleaned up when the nodes get trashed. If you have an instance of ScrollTimeline lingering around after you have trashed all the nodes, you can set it null to actually destroy it.

let st = new ScrollTimeline(…);

// … use it as the `timeline` for some animations

// … remove all animations

st= null;

My use case is, I want to start a timeline and then observe the progress of the timeline as it scrolls. I don't need to use any extra animation API, all I need is timeline.currentTime to convert it into progress and then use that progress for other stuff.

Aha, got it what you want to do now.

To do this today, you need to attach your timeline to an animation and then read the progress from that animation. There is active discussion about progress events on animations, but that’s still in the works. For now, you’ll have to read the effect’s progress in requestAnimationFrame and use that. I have done this in https://www.bram.us/2023/06/21/synchronize-videos-3d-models-to-scroll-driven-animations/ and have extracted away the functionality into a package named @bramus/sda-utilities

import { trackProgress } from '@bramus/sda-utilities';

// Update text of the `.animation-subject` element with the effect progress
trackProgress(document.querySelector('.animation-subject').getAnimations()[0], (progress) => {
  document.querySelector('.animation-subject').innerText = `${(progress * 100).toFixed(5)}%`;
});

I’ll make note of you requesting a way to get a timeline’s progress without needing to attach it to an animation first.

ivodolenc commented 2 months ago

... you can set it null to actually destroy it.

I think this information 👆, if I understood correctly, is all I need. So after all, it's super simple to do (stop the timeline), if this 👇 is true?

// start the timeline normally via JS API
let st = new ScrollTimeline(…);

// ...
// observing the `st.currentTime` in `requestAnimationFrame` on each tick, and converting it to 0-1 progress (custom function)
// ...

// later when I don't need it anymore, or change route in SPAs etc...,
// I simply stop the `requestAnimationFrame` and set this `st` to null
// so that the timeline also finishes its processes
st = null;

Also, thanks again for the help and links, all this information is very useful and hard to find without tips. I will definitely be checking out this sda-utils package and using it as inspiration, it's something similar to what I had in mind.

Overall, I think all developers can be very happy when this polyfill and API lands and becomes a standard because it's definitely a game-changer and makes a lot of work easier.

flackr commented 2 months ago

Yes, that's correct. If there are no animations running on the timeline then removing the last reference will clean up all memory associated with it. Running animations implicitly keep the timeline alive until they are finished.

ivodolenc commented 2 months ago

... then removing the last reference will clean up all memory associated with it ...

Ok, that's what I was wondering about, how to manually clear all memory associated with the timeline when needed.

Thanks for confirmation. 👍

ivodolenc commented 2 months ago

Hi, just wanted to note that reassigning the timeline to null doesn't work for the polyfill as expected, the scroll and observe events still fire after that.

Also, for native Chrome timelines I can't test properly so please give me some feedback.


Polyfill Tests

Here's a simple example of how I tested this in polyfill:

  1. Add console.log to scrollListener function inside of ScrollTimeline class in polyfill:

This will tell us when the scroll event is triggered:

// `scrollListener` function is inside `ScrollTimeline` class

const scrollListener = () => {
  // Sample and store scroll pos
  details.sourceMeasurements.scrollLeft = source.scrollLeft
  details.sourceMeasurements.scrollTop = source.scrollTop

  console.log('scroll test') // 👈 add this line here

  for (const ref of details.timelineRefs) {
    const timeline = ref.deref()
    if (timeline) {
      updateInternal(timeline)
    }
  }
}
  1. Add simple code to test polyfill:
import { ScrollTimeline } from './polyfill'

let scrollTimeline = new ScrollTimeline()

// check the browser console, it works as expected as it outputs 'scroll test'...

setTimeout(() => {
  scrollTimeline = null
}, 500)

// after 500 ms, I expect that reassigning the `timeline` to `null` would disconnect all scroll and observe events

// check browser console, still outputs 'scroll test'... infinite time

Polyfill solution

I have a local polyfill solution to solve this:

// add simple `cancel()` method to ST class:

class ScrollTimeline {
  // ...
  cancel() {
    const details = sourceDetails.get(this.source)
    details.disconnect()
  }
  // ...
}

// starts timeline
const st = new ScrollTimeline()

setTimeout(() => {
  st.cancel() // 👈 disconnects all timeline events as expected
}, 500)

But now I'm still worried if this will really stop all events for Chrome's built-in timelines, since I can't test it like the examples above?