w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.44k stars 656 forks source link

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

Closed CyberAP closed 1 month ago

CyberAP commented 5 years ago

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 commented 5 years ago

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

Dan503 commented 4 years ago

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 commented 4 years ago

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 commented 3 years ago

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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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

Dan503 commented 2 years ago

@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 commented 2 years ago

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: https://github.com/w3c/csswg-drafts/issues/3744#issuecomment-975309289

ivanduka commented 2 years ago

@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 commented 2 years ago

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 commented 2 years ago

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 commented 1 year ago

Opened an issue years ago about all scrolling methods being promises, btw: https://github.com/w3c/csswg-drafts/issues/1562. I'm guessing that whenever they implement that, it could resolve this issue as well.

guswelter commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 11 months ago

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 commented 10 months ago

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 commented 10 months ago

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 commented 9 months ago

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 commented 7 months ago

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

webdevelopers-eu commented 6 months ago

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.

argyleink commented 1 month ago

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

happy to see the event is solving the problem so elegantly!

I do want to share 1 potential exception, is that if scrollIntoView() is called and doesn't result in any change to the scroll position, the event won't fire. This is likely what folks want, but in the case it's not, it's good to know.

webdevelopers-eu commented 1 month ago

happy to see the event is solving the problem so elegantly!

Considering that the aim of scrollIntoView() is to bring something into view, triggering events may not be the most elegant solution. It might be the easiest to implement in browsers, but it will require significantly more JavaScript code as each use must handle edge cases in JS...

There are at least two edge cases that make this solution less practical:

Returning a promise that either fulfills or rejects would eliminate the need for additional JavaScript code to handle these cases, making it a more elegant solution overall.

bramus commented 1 month ago

In https://github.com/w3c/csswg-drafts/issues/1562#issuecomment-389586317 we resolved on having all scroll methods return a promise (still needs edits). I like to assume this would also apply to scrollIntoView().

Pentadome commented 1 month ago

I had issues with the workarounds other commenter provided so I made this function that basically returns a cancellable promise that smoothly scrolls if needed. It isn't perfect, but i hope it helps someone.

Note, the container should have scroll-snap-type: none and scroll-behavior: auto while the promise is being resolved.

type SmoothScrollIntoViewArgs = {
  scrollee: HTMLElement;
  durationMs?: number;
  direction?: 'horizontal' | 'vertical';
  abortSignal?: AbortSignal;
};

export const smoothScrollIntoView = ({
  scrollee,
  abortSignal,
  direction = 'horizontal',
  durationMs = 250,
}: SmoothScrollIntoViewArgs) => {
  abortSignal?.throwIfAborted();
  const container = scrollee.parentElement!;

  const [moveProp, compareProp] =
    direction === 'horizontal'
      ? (['scrollLeft', 'offsetLeft'] as const)
      : (['scrollTop', 'offsetTop'] as const);

  if (durationMs === 0) durationMs = 0.1;

  const startPos = container[moveProp];
  const targetPos = scrollee[compareProp] - container[compareProp];

  const startingDifference = targetPos - startPos;

  if (startingDifference > -1 && startingDifference < 1) {
    return Promise.resolve();
  }

  const constrainer = startPos > targetPos ? Math.max : Math.min;
  const speed = startingDifference / durationMs;
  return new Promise<void>((resolve, reject) => {
    let startTime = undefined as DOMHighResTimeStamp | undefined;
    const move = (timeFrame: DOMHighResTimeStamp) => {
      try {
        abortSignal?.throwIfAborted();
      } catch(error) {
        reject(error);
        return
      }
      if (startTime === undefined) {
        startTime = timeFrame;
        requestAnimationFrame(move);
        return;
      }

      const elapsed = timeFrame - startTime;

      const toScroll = speed * elapsed;

      const toScrollTo = constrainer(targetPos, startPos + toScroll);

      container[moveProp] = toScrollTo;

      const currentPos = container[moveProp];
      const currentTargetPos = scrollee[compareProp] - container[compareProp];
      const diff = currentPos - currentTargetPos;

      if (diff > 1 || diff < -1) {
        requestAnimationFrame(move);
      } else {
        resolve();
      }
    };
    requestAnimationFrame(move);
  });
};