21

I have a situation where, for example, if a user's scroll will result in a 1000 px change in scrollTop I'd like to know ahead of time.

The perfect example is iCalendar's control over a user's scroll. No matter how hard you scroll in the iCalendar application, the farthest you can scroll is to the next or previous month.

I currently have a very hackish solution to limit scroll behavior, which only takes into account where the user's scroll currently is.

MyConstructor.prototype._stopScroll = function(){

    //Cache the previous scroll position and set a flag that will control
    //whether or not we stop the scroll
    var previous = this._container.scrollTop;
    var flag     = true;

    //Add an event listener that stops the scroll if the flag is set to true
    this._container.addEventListener('scroll', function stop(){
        if(flag) {
            this._container.scrollTop = previous;
        }
    }.bind(this), false);

    //Return a function that has access to the stop function and can remove it
    //as an event listener
    return function(){
        setTimeout(function(){
            flag = false;
            this._container.removeEventListener('scroll', stop, false);
        }.bind(this), 0);
    }.bind(this);
};

This approach works, and will stop a scroll in progress, but it is not smooth and I'd love to know if there's a better way to accomplish this.

The key to this question is can I know ahead of time where a scroll will end up. Thanks!!!

Robert
  • 981
  • 1
  • 15
  • 24
  • How would you know when the user will stop ahead of time? – epascarello Aug 14 '16 at 01:51
  • @epascarello I'm referring essentially to 1 gesture. Even though our scrolls appear continuous they're made up of a sequence of discrete gestures with specific scroll values. Different hardware treats those gestures differently, but somewhere the computer knows how far the scroll will animate. – Robert Aug 14 '16 at 01:59
  • 1
    You may want to mention that you're referring to inertia/momentum scrolling, not normal mouse scrolling. I don't know of any way to do this. – David Gilbertson Aug 14 '16 at 02:12
  • the `wheel` event has `deltaX` and `deltaY` values that you will want to check. – Kaiido Aug 14 '16 at 02:18
  • 1
    What about using a plugin like [Scrollify](http://projects.lukehaas.me/scrollify/#home)? – Anthony Aug 17 '16 at 15:39
  • 1
    @Anthony That may actually work really well. I haven't worked with scrollify, but I will definitely explore it further. Thanks for the input! – Robert Aug 17 '16 at 15:57
  • I added my own implementations as answer, currently working on implementation C :P – seahorsepip Aug 18 '16 at 01:03
  • @seahorsepip I saw that and I've given you an upvote. I think it's a great answer and attempts to make the best of the tools available to us on the web. I will strongly consider you as a candidate for the bounty. Right now you're the only answer so your chances are looking pretty good :). – Robert Aug 18 '16 at 01:29
  • 1
    Added implementation C, keep in mind the animation code is not part of the answer, you have to write the animation yourself that includes the correct use of the array of registered speeds in implementation C – seahorsepip Aug 18 '16 at 01:55
  • 1
    @seahorsepip I'll look things over in more detail tomorrow after work and get back with you. Thanks for the hard work, your upvotes are well deserved. – Robert Aug 18 '16 at 02:00
  • I supoust the scroll ends at the highest element + its possible distance to the top... but then again if it's empty there may not need to scroll all the way. – DIEGO CARRASCAL Aug 18 '16 at 21:53
  • @DIEGOCARRASCAL I'm not sure I follow, can you elaborate? – Robert Aug 18 '16 at 21:54
  • @Robert I normally use a div to contain all the page so its height + the distance to the top is normally my max scroll... You could try to find the distance to the top of all elements and their height and then calculate their position in the page to know wich one is the one that is the lowest... – DIEGO CARRASCAL Aug 18 '16 at 22:04
  • Before I read the answers, I thought for a moment that the implementation C mentioned by @seahorsepip was a variant of the C programming language, like Objective-C :P – Bernard Aug 21 '16 at 02:52

4 Answers4

6

Edit: Just found the following project on github:

https://github.com/jquery/jquery-mousewheel

I tried the demo and it's able to report my touchpad and mouse scroll speed. Also it able to stop scrolling without any position fixed hacks :D

I'll have a look in the next few days and see if I can write anything that reports scroll speed, direction, velocity, device etc. Hopefully I'm able to make some jquery plugin that can override all scrolling interaction.

I'll update this post when I've got more info on this subject.


It's impossible to predict where a mouse scroll will end up.

A touchscreen/touchpad swipe on the other hand has a certain speed that will slow down after the user stopped swiping, like a car that got a push and starts slowing down afterwards.

Sadly every browser/os/driver/touchscreen/touchpad/etc has it's own implementation for that slowing down part so we can't predict that.


But we can of course write our own implementation.

We got 3 implementations that could be made:

A. Direction

B. Direction and speed

C. Direction, speed and velocity


iCalender probably uses implementation A.


Implementation A:

Outputs scroll direction to console, user is able to scroll +/- 1px before the direction is detected.

Demo on JSFiddle

Demo with animation on JSFiddle

(function iDirection() {
    var preventLoop = true;
    var currentScroll = scrollTop();
    function scroll() {
        if(preventLoop) {
            //Get new scroll position
            var newScroll = scrollTop();

            //Stop scrolling
            preventLoop = false;
            freeze(newScroll);

            //Check direction
            if(newScroll > currentScroll) {
                console.log("scrolling down");
                //scroll down animation  here
            } else {
               console.log("scrolling up");
                //scroll up animation here
            }
            /*
            Time in milliseconds the scrolling is disabled,
            in most cases this is equal to the time the animation takes
            */
            setTimeout(function() {
                //Update scroll position
                currentScroll = newScroll;

                //Enable scrolling
                unfreeze();

                /*
                Wait 100ms before enabling the direction function again
                (to prevent a loop from occuring).
                */
                setTimeout(function() {
                    preventLoop = true;
                }, 100);
            }, 1000);
        }
    }
    $(window).on("scroll", scroll);
})();


Implementation B:

Outputs scroll direction, distance and average speed to console, user is able to scroll the amount of pixels set in the distance variable.

If the user scrolls fast they might scroll a few more pixels though.

Demo on JSFiddle

(function iDirectionSpeed() {
    var distance = 50; //pixels to scroll to determine speed
    var preventLoop = true;
    var currentScroll = scrollTop();
    var currentDate = false;
    function scroll() {
        if(preventLoop) {
            //Set date on scroll
            if(!currentDate) {
                currentDate = new Date();
            }

            //Get new scroll position
            var newScroll = scrollTop();

            var scrolledDistance = Math.abs(currentScroll - newScroll);

            //User scrolled `distance` px or scrolled to the top/bottom
            if(scrolledDistance >= distance || !newScroll || newScroll == scrollHeight()) {
                //Stop scrolling
                preventLoop = false;
                freeze(newScroll);

                //Get new date
                var newDate = new Date();

                //Calculate time
                var time = newDate.getTime() - currentDate.getTime();

                //Output speed
                console.log("average speed: "+scrolledDistance+"px in "+time+"ms");

                /*
                To calculate the animation duration in ms:
                x: time
                y: scrolledDistance
                z: distance you're going to animate

                animation duration = z / y * x
                */

                //Check direction
                if(newScroll > currentScroll) {
                    console.log("scrolling down");
                    //scroll down animation  here
                } else {
                   console.log("scrolling up");
                    //scroll up animation here
                }

                /*
                Time in milliseconds the scrolling is disabled,
                in most cases this is equal to the time the animation takes
                */

                setTimeout(function() {
                    //Update scroll position
                    currentScroll = newScroll;

                    //Unset date
                    currentDate = false;

                    //Enable scrolling
                    unfreeze();

                    /*
                    Wait 100ms before enabling the direction function again
                    (to prevent a loop from occuring).
                    */
                    setTimeout(function() {
                        preventLoop = true;
                    }, 100);
                }, 1000);
            }
        }
    }
    $(window).on("scroll", scroll);
})();


Implementation C:

Outputs scroll direction, distance and speeds to console, user is able to scroll the amount of pixels set in the distance variable.

If the user scrolls fast they might scroll a few more pixels though.

Demo on JSFiddle

(function iDirectionSpeedVelocity() {
    var distance = 100; //pixels to scroll to determine speed
    var preventLoop = true;
    var currentScroll = [];
    var currentDate = [];
    function scroll() {
        if(preventLoop) {
            //Set date on scroll
            currentDate.push(new Date());

            //Set scrollTop on scroll
            currentScroll.push(scrollTop());

            var lastDate = currentDate[currentDate.length - 1];
            var lastScroll = currentScroll[currentScroll.length - 1];

            //User scrolled `distance` px or scrolled to the top/bottom
            if(Math.abs(currentScroll[0] - lastScroll) >= distance || !lastScroll || lastScroll == scrollHeight()) {
                //Stop scrolling
                preventLoop = false;
                freeze(currentScroll[currentScroll.length - 1]);

                //Total time
                console.log("Time: "+(lastDate.getTime() - currentDate[0].getTime())+"ms");

                //Total distance
                console.log("Distance: "+Math.abs(lastScroll - currentScroll[0])+"px");

                /*
                Calculate speeds between every registered scroll
                (speed is described in milliseconds per pixel)
                */
                var speeds = [];
                for(var x = 0; x < currentScroll.length - 1; x++) {
                    var time = currentDate[x + 1].getTime() - currentDate[x].getTime();
                    var offset = Math.abs(currentScroll[x - 1] - currentScroll[x]);
                    if(offset) {
                        var speed = time / offset;
                        speeds.push(speed);
                    }
                }

                //Output array of registered speeds (milliseconds per pixel)
                console.log("speeds (milliseconds per pixel):");
                console.log(speeds);

                /*
                We can use the array of speeds to check if the speed is increasing
                or decreasing between the first and last half as example
                */ 
                var half = Math.round(speeds.length / 2);
                var equal = half == speeds.length ? 0 : 1;
                var firstHalfSpeed = 0;
                for(var x = 0; x < half; x++ ) {
                    firstHalfSpeed += speeds[x];
                }
                firstHalfSpeed /= half;
                var secondHalfSpeed = 0;
                for(var x = half - equal; x < speeds.length; x++ ) {
                    secondHalfSpeed += speeds[x];
                }
                secondHalfSpeed /= half;
                console.log("average first half speed: "+firstHalfSpeed+"ms per px");
                console.log("average second half speed: "+secondHalfSpeed+"ms per px");
                if(firstHalfSpeed < secondHalfSpeed) {
                    console.log("conclusion: speed is decreasing");
                } else {
                    console.log("conclusion: speed is increasing");
                }

                //Check direction
                if(lastScroll > currentScroll[0]) {
                    console.log("scrolling down");
                    //scroll down animation  here
                } else {
                   console.log("scrolling up");
                    //scroll up animation here
                }

                /*
                Time in milliseconds the scrolling is disabled,
                in most cases this is equal to the time the animation takes
                */
                setTimeout(function() {
                    //Unset scroll positions
                    currentScroll = [];

                    //Unset dates
                    currentDate = [];

                    //Enable scrolling
                    unfreeze();

                    /*
                    Wait 100ms before enabling the direction function again
                    (to prevent a loop from occuring).
                    */
                    setTimeout(function() {
                        preventLoop = true;
                    }, 100);
                }, 2000);
            }
        }
    }
    $(window).on("scroll", scroll);
})();


Helper functions used in above implementations:

//Source: https://github.com/seahorsepip/jPopup
function freeze(top) {
    if(window.innerWidth > document.documentElement.clientWidth) {
        $("html").css("overflow-y", "scroll");
    }
    $("html").css({"width": "100%", "height": "100%", "position": "fixed", "top": -top});
}
function unfreeze() {
    $("html").css("position", "static");
    $("html, body").scrollTop(-parseInt($("html").css("top")));
    $("html").css({"position": "", "width": "", "height": "", "top": "", "overflow-y": ""});
}
function scrollTop() {
    return $("html").scrollTop() ? $("html").scrollTop() : $("body").scrollTop();
}
function scrollHeight() {
    return $("html")[0].scrollHeight ? $("html")[0].scrollHeight : $("body")[0].scrollHeight;
}

Just had a look at scrollify mentioned in the comments, it's 10kb and needs to hook at every simple event: touch, mouse scroll, keyboard buttons etc.

That doesn't seem very future proof, who know what possible user interaction can cause a scroll in the future?

The onscroll event on the other hand will always be triggered when the page scrolls, so let's just hook the animation code on that without worrying about any input device interaction.

seahorsepip
  • 4,519
  • 1
  • 19
  • 30
  • @Kaiido That's almost the same as I wrote (except the fancy canvas drawing) but sadly that doesn't work on touchscreens, keyboard navigation etc. So I decided to make it use the scroll event only since that's triggered by everything :P – seahorsepip Aug 18 '16 at 02:16
  • 1
    Sadly it does only show 2 speeds (fast or slow) on windows :/ I've written quite a few thing that were triggered on scroll in the past and I've learned that writing code for every single event(touch, scroll etc) gets annoying and becomes a lot of work :P Writing above implementations also took some fiddling around to get working since there is no real method to stop scrolling properly, I had to use a positioning trick I used before. – seahorsepip Aug 18 '16 at 02:35
  • the mousewheel plugin looked promising, but a webkit bug prevents it from being able to work in Safari, which is problematic. – Robert Aug 21 '16 at 15:10
  • I just wrote a hook for touchscreen scrolling for the plugin so that can be handled with onmousewheel too :D Sadly there's still the issue of touchpads adding queued wheel/scroll events :/ But maybe I've got an idea with onmousedown/up since the touchpad is no longer touched when a user fast scrolls. – seahorsepip Aug 21 '16 at 15:40
  • I just awarded you the bounty. I held out on you until the last possible day, but I appreciate that your answer covered several different approaches. It looks like there is no real way to determine scroll ahead of time, which is unfortunate, but I'll have to make due with the user's current scroll value. Thank you for your time and effort! – Robert Aug 23 '16 at 13:24
  • 1
    @Robert Thanks, I'll update this answer when I've got time to finish the new examples with onmousewheel and got my queued scroll detection method working properly. The touchscreen swipe to onmousewheel code is already finished but has a small bug that I'm trying to fix. – seahorsepip Aug 23 '16 at 14:48
0

As @seahorsepip states, it is not generally possible to know where a scroll will end up without adding custom behavior with JavaScript. The MDN docs do not list any way to access queued scroll events: https://developer.mozilla.org/en-US/docs/Web/Events/scroll

I found this information helpful: Normalizing mousewheel speed across browsers

It highlights the difficulty of knowing where the page will go based on user input. My suggestion is to trigger a scroll to Y event when the code predicts the threshold is reached. In your example, if the scroll has moved the page 800 of 1000 pixels in a time window of 250ms, then set the scroll to that 1000 pixel mark and cut off the scroll for 500ms.

https://developer.mozilla.org/en-US/docs/Web/API/window/scrollTo

Community
  • 1
  • 1
  • Touchscreen and mouse scroll isn't that huge of a problem as I've shown in my answer but touchpad scroll is EVIL. A fast touchpad scroll will trigger the scroll event for almost 3 seconds D: Which means if you stop the scrolling like in my example then animate for 500ms and then enable scrolling, it will scroll for 2.5 seconds after the animation :/ – seahorsepip Aug 20 '16 at 18:02
0

i'm not pretty sure if i've got what you're looking for. I've had project once, where i had to control the scrolling. Back then i've overwritten the default scroll event, after that you can set a custom distance for "one" scroll. Additionally added jQuery animations to scroll to a specific position. Here you can take a look: http://c-k.co/zw1/ If that's what you're looking for you can contact me, and i'll see how much i still understand of my own thingy there

Chris
  • 36
  • 4
0

is easy to use event listener to do it. Here is a React example:

/**
 * scroll promise
 */
const scrollPromiseCallback = useCallback((func:Function) => {
  return new Promise((resolve, reject) => {
    func(resolve, reject)
  })
}, [])

/**
 * scroll callback
 */
const scrollCallback = useCallback((scrollContainer, onScrollEnd, resolve) => {
  /** 防抖时间 */
  const debounceTime = 200
  /** 防抖计时器 */
  let timer = null
  const listener = () => {
    if (timer) {
      clearTimeout(timer)
    }
    timer = setTimeout(() => {
      scrollContainer.removeEventListener('scroll', listener)
      resolve(true)
      onScrollEnd?.()
    }, debounceTime)
  }
  scrollContainer.addEventListener('scroll', listener)
}, [])

const scrollTo = useCallback((props:IUseScrollToProps) => {

  return scrollPromiseCallback((resolve, reject) => {
    const {
      scrollContainer = window, top = 0, left = 0, behavior = 'auto',
    } = props

    scrollCallback(scrollContainer, props?.onScrollEnd, resolve)

    scrollContainer.scrollTo({
      top,
      left,
      behavior,
    })
  })
}, [scrollCallback, scrollPromiseCallback])
Aaron Meese
  • 1,670
  • 3
  • 22
  • 32