Long html page scroll snapping - javascript

I'm developing an html page that is quite long and the design has blocks on content vertically like pages. What I want to do is when the user scrolls down the page to "snap" to a point that each page is placed correctly in centre of screen if the position of the user is within a small distance of the correct position.
We're using jquery for other things on the page so happy to use that if it has something relevant.
Any advise is of course much appreciated.

Not tested this but it should work. Refer to the comments for an explanation of how it works.
I disable the point after it has been snapped so that it isn't repeatedly snapped when attempting to scroll away from it. The leniancy variable is both the distance from the point the user must scroll before it becomes active again and the distance before a point is snapped.
var points = [250, 675, 1225, $("#someDiv")]; // y coordinates or jQuery objects you want to centre against.
var disabledPoints = []; // Points that you won't snap to (leave empty) this allows user to scroll from a point
var wMid = ($(window).height() / 2); // Pixels to centre of window
var leniancy = 100; // Number of pixels ether side of a point before it is snapped
$.each(points, function(key, value) { // Loop through points
if (value instanceof jQuery) { // If value is a jQuery object
function setObjectPos(){
var offset = value.offset();
var centerPoint = offset.top + (value.height() /2); // Calculate object centre relative to document
value.data('centerPoint', centerPoint); // Store it for later
});
value.bind('DOMSubtreeModified', setObjectPos); // If div changes update center position.
}
}
$(window).resize(function () { // If user resizes window update window mid-point variable
wMid = ($(window).height() / 2);
});
$(window).scroll(function () {
var centerPos = $(window).scrollTop() + wMid; // Position of screen center relative to document
$.each(points, function(key, value) { // Loop through points
if (value instanceof jQuery) {
value = value.data('centerPoint');
}
var offset = value - centerPos;
if(checkDistance(offset, leniancy)) {
snapToPoint(key); // Success! snap that point
return false; // Kill the loop
}
});
$.each(disabledPoints, function(key, value) { // Loop through disabled points
if (value instanceof jQuery) {
value = value.data('centerPoint');
}
var offset = value - centerPos;
if(!checkDistance(offset, leniancy)) {
enablePoint(key); // Success! enable that point
}
});
});
function checkDistance(offset, margin) {
if (offset > 0) {
if (offset <= margin) {
return true;
}
} else {
if (offset >= (margin * -1)) {
return true;
}
}
return false;
}
function snapToPoint(index) {
var offset = (points[index] instanceof jQuery) ? points[index].data('centerPoint') - wMid : points[index] - wMid;
$(window).scrollTop(offset);
var point = points.splice(index, 1);
disabledPoints.push(point[0]);
}
function enablePoint(index) {
var point = disabledPoints.splice(index, 1);
points.push(point[0]);
}​

Related

Sidescrolling jittery when changing directions

I made my own sidescrolling parallax using jQuery and the mousewheel plugin. It's working great so far except for the fact that when I change directions of the scroll, it jitters first before actually scrolling (as though it's scrolling one unit to the previous side before actually moving to the correct one). I've tried adding a handler for this which supposedly stops the scroll completely.
Here is my script so far:
var scroll = 0; // Where the page is supposed to be
var curr = scroll; // The current location while scrolling
var isScrolling = false; // Tracker to check if scrolling
var previous = 0; // Tracks which direction it was previously
var loop // setInterval variable
function parallax() {
isScrolling = true;
// Loops until it's where it's supposed to be
loop = setInterval(() => {
if ( curr - scroll == 0 ) {
clearInterval(loop);
isScrolling = false;
return;
}
// Move the individual layers
$('.layer').each(function() {
$(this).css('left', -curr * $(this).data("speed"));
});
// Add/subtract to current to get closer to where it's supposed to be
curr += (scroll < curr) ? -0.5 : 0.5;
}, 25);
}
$(document).ready(() => {
$('.scrolling-container').mousewheel((e, dir) => {
e.preventDefault();
// If the speed's magnitude is greater than 1, revert it back to 1
if ( Math.abs(dir) > 1) {
dir = Math.sign(dir);
}
// If the direction changes, stop the current animation then set scroll to where it currently is
if ( previous !== dir ) {
previous = dir;
isScrolling = false;
scroll = curr;
clearInterval(loop);
}
// If not at the left most scrolling to the left, add to scroll
if ( scroll - dir >= 0 ) scroll -= dir;
// Call parallax function if it's not yet running
if ( !isScrolling ) {
parallax();
}
})
})
I think it's easier to show so here's a codepen of the functional parts: https://codepen.io/ulyzses/pen/yLyoYZm
Try scrolling for a while then change direction, the jittery behaviour should be noticeable.

Scrolling upwards

I want to make a function that will automatically display items as the user scrolls upwards. Think messages, where older ones are up top, but we start at the bottom with the newest ones. I have another function that loads items at the bottom of a container, and as it does so, the current items remain in position, and scrollbar is smaller. We are not scrolled to the bottom. However, with the other direction, when I prepend items to an array, it scrolls all the way to the top, displaying the top of the loaded items, instead of remaining in the same place and letting the user scroll up as needed.
My code for the bottom scroll is this:
attachScrollWatcher: function (element, offset, callback) {
console.log('scrolling');
var contentHeight = element.scrollHeight;
var yOffset = element.scrollTop;
var y = yOffset + element.offsetHeight;
if (y >= ( contentHeight - offset ))
{
callback();
}
}
This function is attached to an object's onscroll event. However, now I need to make a function that does the opposite, going upwards. Any ideas how this can be implemented?
Basically, when scrollTop === 0 then you're at the top and you need to load a new item..
attachScrollWatcher: function (element, offset, callback) {
if(!element.scrollHeight) callback();
}
The problem is, loading a new item will keep the scrollTop at zero, so the user would have to scroll down and then scroll back up in order for the callback to be triggered again. So, what you wanna do is calculate the scrollHeight before the new item is added and then again after the item is added and then manually set the scrollTop to the difference between the original and the new scrollHeight.
Check out my example attachScrollListener method below...
class upScroller{
constructor(ele = document.body){
this.renderedItems = 0;
this.ele = ele; var i=0;
this.initBoxContents();
}
initBoxContents(){
if(this.ele.scrollHeight == this.ele.clientHeight)
this.populateNextItem().then(()=>this.initBoxContents());
else{
this.ele.scrollTop = this.ele.clientHeight;
this.attachScrollListener();
}
}
getNextItem(){
// Do something here to get the next item to render
// preferably using ajax but I'm using setTimeout
// to emulate the ajax call.
return new Promise(done=>setTimeout(()=>{
this.renderedItems++;
done(`<p>This is paragraph #${this.renderedItems}</p>`);
},50));
}
populateNextItem(){
return new Promise(done=>{
this.getNextItem().then(item=>{
this.ele.insertAdjacentHTML('afterbegin', item);
done();
});
});
}
attachScrollListener(){
this.ele.addEventListener('scroll', ()=>{
if(this.ele.scrollTop) return;
var sh = this.ele.scrollHeight;
this.populateNextItem().then(()=>{
this.ele.scrollTop = this.ele.scrollHeight - sh;
});
});
}
}
var poop = document.getElementById('poop');
new upScroller(poop);
#poop{ height: 300px; overflow: auto; border:1px solid black;}
<div id=poop></div>
I've posted this here as well....
Something like this may work.
attachScrollWatcher: function (element, offset, callback) {
console.log('scrolling');
var contentHeight = element.scrollHeight;
var yOffset = element.scrollTop;
var y = yOffset + element.offsetHeight;
if (y >= ( contentHeight - offset ))
{
callback();
} else {
callbackGoingUp();
}
}

Is it possible to determine where a scroll will end up using javascript? If so, how?

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!!!
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.
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
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
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])

How to check bottom of child div touching parent bottom

i'm trying to change position of a child element(with varying height based on text) using range input,i would like to stop applying top position once the child div touches the bottom of the parent having fixed height.
$('#change_position').on('input', function () {
var val = +$(this).val(),
ScrSize = parseInt($('.screen').css('height')),
Stop = $('.screentip').offset().top,
h = parseInt($('.screentip').css('height')),
tofsset = Stop - h;
if (tofsset < ScrSize) {
$('.screentip').css('top', val + "%");
}
});
The height you are expecting jQuery to return is actually a string with the ending "px". You can use the function .height(). This will return you the height of the element as integer. I have tried a slightly modiefied version of your code:
$('#input').on('input', function () {
var val = parseInt($(this).val()),
ScrSize = parseInt($('#container').height()),
TxtSize = parseInt($("#text").height()),
Stop = ScrSize - TxtSize,
valInPixel = ScrSize * val / 100;
if (valInPixel < Stop) {
$('#text').css('top', val + "%");
}
else
{
$("#text").css("top", Stop + "px");
}
});
The else part will position your element to the bottom, if the number exceeds the frame. You may also have to be aware of padding and margin. This could also lead to mispositioning.
Hope this helps.

removeChild sometimes removes entire span and sometimes doesn't

I'm working on a rich text editor for iOS and have most of it working but running into endless problems ensuring that the cursor is visible in the viewport when the user starts typing.
I came up with a novel approach: insert a span at the cursor position, scroll to the span, and then remove it. (I haven't gotten to only scrolling if the span is on-screen.) Here's what I wrote:
document.addEventListener('keypress', function(e) {
jumpToID();
}, false);
function jumpToID() {
var id = "jumphere2374657";
var text = "<span id='" + id + "'> </span>"
document.execCommand('insertHTML', false, text);
var element = document.getElementById(id);
element.scrollIntoView();
element.parentNode.removeChild(element);
}
In some cases this works just fine and in some cases it leaves a non-break space between every key press, removing the <span></span> tags only. Any ideas? I'm open to better ways of doing this if someone has suggestions. I'm a little shocked at how hard it is to make the cursor appear but then JS is new to me.
EDIT
This is the code that works:
var viewportHeight = 0;
function setViewportHeight(vph) {
viewportHeight = vph;
if(viewportHeight == 0 && vph != 0)
viewportHeight = window.innerHeight;
}
function getViewportHeight() {
if(viewportHeight == 0)
return window.innerHeight;
return viewportHeight;
}
function makeCursorVisible() {
var sel = document.getSelection(); // change the selection
var ran = sel.getRangeAt(0); // into a range
var rec = ran.getClientRects()[0]; // that we can get coordinates from
if (rec == null) {
// Can't get coords at start of blank line, so we
// insert a char at the cursor, get the coords of that,
// then delete it again. Happens too fast to see.
ran.insertNode( document.createTextNode(".") );
rec = ran.getClientRects()[0]; // try again now that there's text
ran.deleteContents();
}
var top = rec.top; // Y coord of selection top edge
var bottom = rec.bottom; // Y coord of selection bottom edge
var vph = getViewportHeight();
if (top < 0) // if selection top edge is above viewport top,
window.scrollBy(0, top); // scroll up by enough to make the selection top visible
if (bottom >= vph) // if selection bottom edge is below viewport bottom,
window.scrollBy(0, bottom-vph + 1); // scroll down by enough to make the selection bottom visible
}
The viewportHeight is more complicated than need be for a web app. For a mobile app we need to account for the keyboard so offer a method for setting the viewportHeight manually as well as the automatic setting from the window.innerHeight.
I don't know if this will work on iOS, but if the position of the cursor means that there is a Selection at that point..
function moveToSelection(){
var sel = document.getSelection(), // change the selection
ran = sel.getRangeAt(0), // into a range
rec = ran.getClientRects()[0], // that we can get co-ordinates from
dy = rec.top; // distance to move down/up
window.scrollBy( 0, dy ); // actual move
// console.log( sel, ran, rec, y ); // help debug
}
moveToSelection();
Relevant links
getSelection
getRangeAt
getClientRects
scrollBy

Categories

Resources