-
Notifications
You must be signed in to change notification settings - Fork 693
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
Comments
Methinks this should have the same solution as #156 - CSS Snap Points: Event Model Missing |
Is there any chance of Applied to element.scrollIntoView({ behavior: 'smooth', block: 'start' }).then( scrollEvent => {
console.log('The browser has finished scrolling')
}) Applied to window.scroll({ top: 0, left: 0, behavior: 'smooth' }).then( scrollEvent => {
console.log('The browser has finished scrolling')
}) |
The particular scenario mentioned by @CyberAP can be solved as follow: CSS: JS:
Without the preventScroll parameter, you are subject to browser quirks as below: Firefox Safari/webkit Chrome Other scenarios |
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
That way one can use ResizeObserver to update CSS properties and after scrolling finishes disconnect it. |
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, |
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. |
hey @Dan503 were you able to fix your scenario? I'm looking for a solution like the one you suggested |
@Kymy no, I think I ended up just using element.scrollIntoView({ behavior: 'smooth' });
setTimeout(() => {
console.log('The browser has (theoretically) finished scrolling');
}, 500) |
I get the feeling the solution I mention above (using a promise) is going to break backwards compatibility since 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 |
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. |
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. |
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. |
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).
|
Hi @guswelter , |
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. |
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 |
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... |
If you are looking for a promise based workaround, we can use the @guswelter solution inside a promise.
I have a scroll based tab-content system and was looking for a way to set and attribute hidden to apply
|
Is there a chance this is being added to the |
@guswelter Okay, nice try, but sadly this won't work when the element https://stackblitz.com/edit/web-platform-mfafhv?file=index.html |
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 function scrollIntoViewAndWait(element) {
return new Promise(resolve => {
document.addEventListener('scrollend', resolve, {once: true});
element.scrollIntoView({behavior: 'smooth', block: 'center', inline: 'center'});
});
} |
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 @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 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()
}
});
} |
For future reference, Safari (both desktop and mobile) implements smoothness but not the The solution suggested above would be clunky then there depending what you need to wait for. |
Just ran into an instance where this would be very helpful....maybe one day |
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. |
happy to see the event is solving the problem so elegantly! I do want to share 1 potential exception, is that if |
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. |
In #1562 (comment) we resolved on having all scroll methods return a promise (still needs edits). I like to assume this would also apply to |
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 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);
});
};
|
Reopening per #10737. |
@fantasai Did you see #3744 (comment)? Do we need an extra resolution to state that the resolution in #1562 (comment) also includes |
For anyone still stuck on this, this is the implementation I was able to come up with: export const scrollToElementWithCallback = (
element: HTMLElement,
callback: () => void,
arg?: boolean | ScrollIntoViewOptions,
): void => {
const eventListenerCB = () => {
console.debug("scrollToElementWithCallback: Still scrolling");
clearTimeout(timer);
timer = setTimeout(timerCB, 50);
};
const timerCB = () => {
console.debug(
"scrollToElementWithCallback: Scrolling done, calling callback",
);
callback();
document.removeEventListener("scroll", eventListenerCB);
};
let timer = setTimeout(timerCB, 50);
document.addEventListener("scroll", eventListenerCB);
element.scrollIntoView(arg);
}; Rationale behind this and implementation details
Final Note
|
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 anonAnimationEnd
callback as an initializer option would solve this problem: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
beforescrollIntoView
then you'll get no scroll animation, because browser willalready scroll to that input without animation.
The text was updated successfully, but these errors were encountered: