Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[cssom-view-1] Provide onAnimationEnd callback in scrollIntoView options #3744

Open
CyberAP opened this issue Mar 19, 2019 · 26 comments
Open

Comments

@CyberAP
Copy link

CyberAP commented Mar 19, 2019

Spec

Right now you can't really tell if scrollIntoView scroll animation has finished nor you can't control the speed of the scroll and it might differ with each browser. So if you want to perform some action right after the animation has finished there's no determined way to do that. Having an onAnimationEnd callback as an initializer option would solve this problem:

Element.scrollIntoView({
  onAnimationEnd: () => Element.focus()
})

Why we would ever need that?

Imagine that you have some input you would like to focus that's out of the viewport boundaries right now, but you would also like to have a smooth scrolling animation to that input. If you execute focus before scrollIntoView then you'll get no scroll animation, because browser will
already scroll to that input without animation.

@jonjohnjohnson
Copy link

Methinks this should have the same solution as #156 - CSS Snap Points: Event Model Missing

@Dan503
Copy link

Dan503 commented Dec 12, 2019

Is there any chance of element.scrollIntoView and window.scroll returning a promise that resolves when the scroll animation has ended? Or is that going to break backwards compatibility?

Applied to element.scrollIntoView

element.scrollIntoView({ behavior: 'smooth', block: 'start' }).then( scrollEvent => {
  console.log('The browser has finished scrolling')
})

Applied to window.scroll

window.scroll({ top: 0, left: 0, behavior: 'smooth' }).then( scrollEvent => {
  console.log('The browser has finished scrolling')
})

@fchristant
Copy link

The particular scenario mentioned by @CyberAP can be solved as follow:

CSS:
html { scroll-behavior: smooth; }

JS:

element.scrollIntoView(..params);
element.focus({preventScroll:true});

Without the preventScroll parameter, you are subject to browser quirks as below:

Firefox
Scrolls to the correct position (meaning, element.scrollIntoView) yet abandons smooth scrolling, even though the browser supports it. Not great, but at least the scroll position is correct.

Safari/webkit
Same as Firefox yet would not scroll smoothly in any case, as it's not supported. Not great, but at least the scroll position is correct.

Chrome
Chrome is the big offender here. It seems to cancel the scrollIntoView action altogether, instead does the smooth scroll from element.focus(), CSS based, and scroll position logic is based on that. In practice, this often means a wrong scroll position.

Other scenarios
Whilst preventScroll solves this particular scenario, I still very much agree we need a callback. If you need to do anything else after scrolling completes (say, run an animation), there's no robust way to do that right now.

@webdevelopers-eu
Copy link

webdevelopers-eu commented Dec 18, 2020

There is another use case: when scrolling to particular element it is common that one must take in an account fixed-position headers. Unfortunately the very widespread solution is to have resizable fixed headers. E.g. tall header in the initial position and minimal header after a page was scrolled a bit.

That poses the problem with adjusting the target scroll position as the header changes size.

While one can easily use ResizeObserver to watch for header changes and adjust the target scroll offset accordingly there is no way to know when to stop and disconnect ResizeObserver after scrolling finished.

Ideally I would love to see

  1. scrollIntoView() method return Promise
  2. scrollIntoView() method honoring real-time changes to CSS scroll-padding property while scrolling is in progress

That way one can use ResizeObserver to update CSS properties and after scrolling finishes disconnect it.

@dnistreanuu
Copy link

dnistreanuu commented Nov 22, 2021

Can we have it added to the language? The only solution to really detecting the scroll end is by preventing default and running event handlers synchronously. For example, if you have 2 Web Components controlling the same scrollbar and running scroll animation, they will both run without forcing it to run synchronously.

scroll, scrollBy, scrollTo, scrollIntoView - none of these has a callback, frankly, it's embarrassing that javascript doesn't provide callback for that.

I'd suggest adding 3 callbacks, isScrollStarted isScrolling isScrollEnded

@pft
Copy link

pft commented Dec 17, 2021

I second something like this, either via a callback or a promise. Currently we are left in the dark as regards whether a scroll event has ended.

Imagine the use case to want show an absolutely positioned element (e.g. with completion candidates) after focus. The final position of the element is not known beforehand, so the completion candidates are off. We by the way also had the scroll event hiding completion candidates. I circumvented that issue by substituting the wheel event for it, but have to see whether that really pans out quite as well. If we could focus after scrolling into view, there would be no scroll event at that point, so it would also work with the original scroll event.

@Kymy
Copy link

Kymy commented Aug 23, 2022

hey @Dan503 were you able to fix your scenario? I'm looking for a solution like the one you suggested

@Dan503
Copy link

Dan503 commented Aug 24, 2022

@Kymy no, I think I ended up just using setTimeout which is far from perfect but it is close enough to do the job for now.

element.scrollIntoView({ behavior: 'smooth' });
setTimeout(() => {
  console.log('The browser has (theoretically) finished scrolling');
}, 500)

@Dan503
Copy link

Dan503 commented Aug 24, 2022

I get the feeling the solution I mention above (using a promise) is going to break backwards compatibility since element.scrollIntoView will go from returning a falsy value (undefined) to a truthy value (a promise).

In that case, it should be safe to just add a new callback option to the options object.

element.scrollIntoView({
    behavior: 'smooth',
    onScrollEnd: (scrollEvent) => {
        console.log('The browser has finished scrolling');
    } 
});

Then if developers want it in promise format we can make a simple utility for it:

function scrollElemIntoView(elem, options) {
    return new Promise((resolve, reject) => {
        if (!elem) {
            reject("Cannot scroll as the target element does not exist");
            return;
        }
        elem.scrollIntoView({
            behavior: 'smooth',
            onScrollEnd: resolve,
            ...options
        });
    })
}

scrollElemIntoView(element, { block: 'start' }).then( scrollEvent => {
  console.log('The browser has finished scrolling')
})

Actually the options object is probably also better because you can have onScrollStart, onScroll, and onScrollEnd callbacks to have finer control over when the callback is triggered as mentioned in this comment:
#3744 (comment)

@ivanduka
Copy link

ivanduka commented Sep 10, 2022

@Kymy no, I think I ended up just using setTimeout which is far from perfect but it is close enough to do the job for now.

element.scrollIntoView({ behavior: 'smooth' });
setTimeout(() => {
  console.log('The browser has (theoretically) finished scrolling');
}, 500)

Unfortunately, this is highly unreliable. The scrolling easily can take more than 500ms even for a very short content because the browser can be resource-constrained by other heavy pages or even general OS load, the chaces of this happening are even higher on mobile devices.
The only reliable way to do it as of now is not to use behavior: 'smooth' until we have a callback-based solution.

@Dan503
Copy link

Dan503 commented Sep 10, 2022

Unfortunately, this is highly unreliable. The scrolling easily can take more than 500ms even for a very short content because the browser can be resource-constrained by other heavy pages or even general OS load, the chaces of this happening are even higher on mobile devices.

That's why I said far from perfect.

@csicky
Copy link

csicky commented Sep 13, 2022

It is not always about adding focus after scroll into view. I arrived here because I have two lists and when I click on item in one, I scroll to the corresponding item in the other list. When the scroll to element completes, I add an animation class to make the element highlighted. Problem is I don't know how much timeout to use as the list is dynamic and scroll is smooth. I can't know how much it will take. If the list is long a short timeout will make the animation class be done by the time the scroll completes. If the timeout is too big, the animation will happen later than expected.

@markcellus
Copy link

markcellus commented Oct 2, 2022

Opened an issue years ago about all scrolling methods being promises, btw: #1562. I'm guessing that whenever they implement that, it could resolve this issue as well.

@guswelter
Copy link

guswelter commented Feb 10, 2023

EDIT: Please disregard this solution. It only worked for me in a specific scenario where my target element was an image, and Quasar was applying a fade-in transition on the image when it came into view.

You can add a transitionend event listener. This worked for me, though sometimes I get multiple callbacks for a single scroll (workaround could be for the event listener to detach itself).

const element = document.getElementById("element");
element.scrollIntoView({ behavior: "smooth" });
element.addEventListener("transitionend", function() {
  console.log("Scrolling finished");
});

@webdevelopers-eu
Copy link

element.addEventListener("transitionend", function() {
console.log("Scrolling finished");
});

Hi @guswelter ,
I'm not able to confirm if it works in Chromium 110. Have you checked if there's any other transition happening on that element? If you could create a simple example on codepen.io, that would be great! Let's see if it works.
Thanks!

@guswelter
Copy link

For someone reason, it works inside of my Vue/Quasar app but not in vanilla. I don't have time to dig deeper but will post back if I do.

@guswelter
Copy link

What I shared above was working for me because Quasar was applying a fade-in transition when my target image came into view, which explains why it seemed to be firing a bit early.

So I looked at how to watch for viewport changes, and here is a more generalized potential workaround for people. This solution uses IntersectionObserver to watch for changes to the intersection (amount of overlap) between the target element (being scrolled into view) and the viewport:

const myElement = document.getElementById("x");

const doSomething = (entries, observer) => {
  entries.forEach(entry => {
    if (entry.target === myElement && entry.intersectionRatio >= 0.90) {

      // The element is now fully visible
      console.log("Element is visible.")

      // Stop listening for intersection changes
      observer.disconnect();

    }
  });
}

let observer = new IntersectionObserver(doSomething, {
  root: null,
  rootMargin: '0px',
  threshold: 0.90,
});

observer.observe(myElement)

myElement.scrollIntoView({ behavior: "smooth", block: "end" });

Here is a jsfiddle: https://jsfiddle.net/04wo8cnr/

You need to account for what portion ("threshold" in the code above) of your element is going to come into view by the time the scrollIntoView finishes. This may depend on the block option you set on scrollIntoView. Or an alternative would be to set it to a low threshold such as 0.01 and take action as soon as the element starts to come into view.

@MireilleMedhat
Copy link

Any updates regarding this issue or ETA on when it will be picked up? I am amazed that this one is still open, should be a critical bug...

@mario-aleo
Copy link

If you are looking for a promise based workaround, we can use the @guswelter solution inside a promise.

   element.scrollIntoView({ behavior: 'smooth' });

   await new Promise(resolve => {
     new IntersectionObserver(
       (entries, observer) => {
         for (const entry of entries) {

           if (entry.target === element && entry.intersectionRatio >= 0.90) {
             observer.disconnect();
             resolve();
           }
         }
       }
     ).observe(element)
   });

I have a scroll based tab-content system and was looking for a way to set and attribute hidden to apply visibility: hidden; for the content that were not visible so the TAB focus behaviour could ignore the other content and thanks to @guswelter solution applied inside Promise I could reach a satisfactory solution.

  async focusContent(index) {
    const focusContentElement = this.shadowRoot!.querySelector(
      `#content > :nth-child(${index + 1})`
    );

    focusContentElement.removeAttribute('hidden');

    focusContentElement.scrollIntoView({ behavior: 'smooth' });

    await new Promise(resolve => {
      new IntersectionObserver(
        (entries, observer) => {
          for (const entry of entries) {

            if (entry.target === focusContentElement && entry.intersectionRatio >= 0.90) {
              observer.disconnect();
              resolve();
            }
          }
        }
      ).observe(focusContentElement)
    });

    const otherContentElementList = this.shadowRoot!.querySelectorAll(
      `#content > :not(:nth-child(${index + 1}))`
    );

    for (const contentElement of otherContentElementList) {
      contentElement.setAttribute('hidden', '');
    }

    this.dispatchEvent(new CustomEvent('tab-changed'));
    this.dispatchEvent(new CustomEvent('content-changed'));
  }

@superwesker1988
Copy link

Is there a chance this is being added to the scrollIntoView? I think while the suggested workaround is good for many cases, it would be much nicer to have standardized support for it.

@PieterjanDeClippel
Copy link

What I shared above was working for me because Quasar was applying a fade-in transition when my target image came into view, which explains why it seemed to be firing a bit early.

So I looked at how to watch for viewport changes, and here is a more generalized potential workaround for people. This solution uses IntersectionObserver to watch for changes to the intersection (amount of overlap) between the target element (being scrolled into view) and the viewport:

const myElement = document.getElementById("x");

const doSomething = (entries, observer) => {
  entries.forEach(entry => {
    if (entry.target === myElement && entry.intersectionRatio >= 0.90) {

      // The element is now fully visible
      console.log("Element is visible.")

      // Stop listening for intersection changes
      observer.disconnect();

    }
  });
}

let observer = new IntersectionObserver(doSomething, {
  root: null,
  rootMargin: '0px',
  threshold: 0.90,
});

observer.observe(myElement)

myElement.scrollIntoView({ behavior: "smooth", block: "end" });

Here is a jsfiddle: https://jsfiddle.net/04wo8cnr/

You need to account for what portion ("threshold" in the code above) of your element is going to come into view by the time the scrollIntoView finishes. This may depend on the block option you set on scrollIntoView. Or an alternative would be to set it to a low threshold such as 0.01 and take action as soon as the element starts to come into view.

@guswelter Okay, nice try, but sadly this won't work when the element window.scrollTo or element.scrollIntoView calls on is already visible in the viewport...

https://stackblitz.com/edit/web-platform-mfafhv?file=index.html

@alex-bacart
Copy link

alex-bacart commented Nov 11, 2023

In January 2023, an article was published on Google Chrome blog https://developer.chrome.com/blog/scrollend-a-new-javascript-event/

And now we have new onscrollend event. Here is the function to scroll into view and wait for scroll end:

function scrollIntoViewAndWait(element) {
    return new Promise(resolve => {
        document.addEventListener('scrollend', resolve, {once: true});

        element.scrollIntoView({behavior: 'smooth', block: 'center', inline: 'center'});
    });
}

Browser compatibility

@Dan503
Copy link

Dan503 commented Nov 11, 2023

I am so glad with the solution that the working group came up with. It is so much more robust than I was expecting and it is much better than all the example solutions posted in this issue.

This ticket can be closed now. The core of the issue has been resolved with the new scrollend event.


@alex-bacart thankyou for your code example. I would also include a check to see if the event exists.

This example will only use smooth animated scroll if the browser supports scrollend. Otherwise it will use a non-animated scroll to reach the element.

function scrollIntoViewAndWait(element) {
    return new Promise(resolve => {
        if ('onscrollend' in window) {
            document.addEventListener('scrollend', resolve, { once: true });
            element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
        } else {
            element.scrollIntoView({ block: 'center', inline: 'center' });
            resolve()
        }
    });
}

@tomasdev
Copy link

tomasdev commented Dec 5, 2023

For future reference, Safari (both desktop and mobile) implements smoothness but not the scrollend event.
https://caniuse.com/css-scroll-behavior
https://caniuse.com/mdn-api_element_scrollend_event

The solution suggested above would be clunky then there depending what you need to wait for.

@bansavage
Copy link

Just ran into an instance where this would be very helpful....maybe one day

@webdevelopers-eu
Copy link

Whatever "scroll into view" solution is adopted, it must support some kind of "scroll-interrupted" event. Since this is an animation-like action that can be interrupted by the user simply by scrolling, the solution must relay that information back to the programmer. This allows the programmer to take action if the intended behavior is not going to happen because the user is not scrolling to the target.

Of course, one can create a workaround in the form of a timeout(). I am dropping this note here for the record in case somebody undertakes the task to create a truly universal shim of some sort.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests