My problem
I am making a vertical website for a client who wishes to have the window "snap" to the nearest page when most of the element is visible in the viewport. So, if the page is 85% visible, it should scroll to be 100% visible.
My problem is that occasionally when scrolling all the way to the top or bottom of the viewport, the viewport will "stick" to the first or last element, preventing a few scroll events and causing a highly noticeable flicker.
A working fiddle is here: http://jsfiddle.net/RTzu8/1/
To reproduce the error, use the scrollbar to scroll to the bottom of the page. Then, scroll up with your mousewheel. You should see the flicker. Sometimes it takes a few refreshes or attempts, but the issue is highly reproducible.
I'm at a loss as to what could be causing this issue. See below for a run-down of my code and what I have tried to prevent it so far.
My code
To accomplish my snapping, I needed to detect whether an element was a certain percentage visible. So, I added a jQuery function, isNearScreen, below. I have thoroughly tested this function, and as far as I can tell it returns accurate results.
//Modification of http://upshots.org/javascript/jquery-test-if-element-is-in-viewport-visible-on-screen
//Returns "true" if element is percent visible within the viewport
$.fn.isNearScreen = function(percent){
var offset = 1 - percent;
var win = $(window);
var viewport = {
top : win.scrollTop()
};
viewport.bottom = viewport.top + win.height();
var bounds = this.offset();
bounds.bottom = bounds.top + this.outerHeight();
bounds.top = bounds.top;
//If the element is visible
if(!(viewport.bottom < bounds.top || viewport.top > bounds.bottom)){
//Get the percentage of the element that's visible
var percentage = (viewport.bottom - bounds.top) / this.height();
//If it's greater than percent, but less than 1 + (1 - percent), return true;
return (percentage > (1 - offset) && percentage < (1 + offset));
}
return false;
};
I then created a snap function, which makes use of Underscore.js's _.debounce function, to only fire on the trailing end of continuous scroll events. It fires after a 500ms timeout, and I am fairly (though not 100%) convinced that it is firing correctly. I have not been able to reproduce console logs that would indicate multiple concurrent firings.
//Snaps the screen to a page after scroll input has stopped arriving.
var snap = _.debounce(function(event){
//Check each page view
$.each($('.page-contents'), function(index, element){
//If the page view is 70% of the screen and we are allowed to snap, snap into view
if($(element).isNearScreen(0.7)){
$('html,body').animate({
scrollTop: $(element).offset().top
}, 300);
}
});
}, 500);
Finally, I bind to the window's scroll event
$(window).on('scroll', snap});
The (extremely simplified) HTML:
<div class="page">
<div class="page-contents"></div>
</div>
<div class="page">
<div class="page-contents"></div>
</div>
<div class="page">
<div class="page-contents"></div>
</div>
<div class="page">
<div class="page-contents"></div>
</div>
and CSS:
.page{
height: 750px;
width: 100%;
margin: 10px 0;
background: gray;
}
.page-contents{
height: 100%;
width: 100%;
}
What I've tried
I have tried the following, with no success:
Setting a boolean, 'preventSnap', on the window, checking its state, and only firing the animate portion of snap if it is set to false. After animation, set it to true, then set it to false after 500ms (which should in theory prevent double firings).
Calling .stop() on the element before running the snap animation.
Calling event.preventDefault() on the scroll event before running the animation.
Reducing and increasing my _.debounce delay. Interestingly, a lower _.debounce delay (200-300ms) seems to aggravate the problem and a higher _.debounce delay (1000ms) seems to fix it. This is not an acceptable solution, however, as it feels "long" waiting 1sec for the page to "snap".
Changing the heights of the elements
If there is any other information I can provide, please let me know. I'm at a loss!
I think this is a combination of events and how _.debounce works. I noticed in the fiddle (in Chrome) that the elements were 'jitterring' long after the snap finished. If you put a console log in the snap event handler you can see it's constantly being called after a snap even with no scroll inputs.
This must be the scroll animation itself setting off the snap, I tried to set a flag to prevent dual snapping and clearing the flag after the animation was finished -- however that didn't work I think because _.debounce is queuing the event to happen later (after the animation finishes and clears the flag).
So what does work is to add this as the start of the snap handler:
var nosnap = false;
var snap = _.debounce(function(event){
// Don't snap if already animating ...
if (nosnap) { nosnap = false; return; }
nosnap = true;
Fiddle
That prevents the animation directly firing the next snap event -- however that's going to cause issues if you scroll again during the animation.
So, that's a bit of a hack. Ideally you want to be able to tell what's causing the scroll event and react accordingly but there's no easy way to do that.
I absolutely think you need to stop the animation when handling a second scroll event as well.
Related
I have a div containing a long, multi-screen blog post. The length varies depending on the content. I'd like to trigger a waypoint when a third of the way through the div. I understand the offset function, but that seems to apply to how far down the screen the div appears. I don't have the ability to modify the HTML to include any identifier; I would need to do it through the Javascript entirely.
$('.article-body').waypoint({
handler: function() {
alert('Hit midpoint of my context');
},
context: ".article-body",
offset: $(".article-body").height * 0.33
});
Sample HTML:
<body>
<div class="article-body">
CONTENT CONTENT CONTENT
</div>
<body>
I see Daniel's answer has been accepted, but here's how to do it with Waypoints: An offset function that returns a negative number.
Offsets are always in terms of distance from the top of the element to the top of the window. Let's say we have a 300px tall element. If we set the offset to -100 that would have the effect of triggering when the top third of the element is scrolled past. Now let's make that a dynamic function:
offset: function() {
return this.element.offsetHeight / -3;
}
This is sloppy with global variables, but you'll get the idea; the gist of it is to determine ahead of time where you want your "waypoint" triggered, then watch window scrolling until it reaches that point.
(Note that if your content changes after page load you'll need to recalculate waypointPos. You could calculate it on the fly every time, but the scroll event fires frequently enough that that might cause lagginess; I'd poll the window scroll position on a slower interval rather than do DOM calculations constantly during window scroll.)
// determine the scroll position where we want to do something, which is the element's top offset plus half of its height:
var waypointPos = ($('.hasWaypoint').height() / 2) + $('.hasWaypoint').offset().top;
// watch window scroll until we search that point:
var waypointTriggered = false;
$(window).scroll(function() {
if (!waypointTriggered && $(window).scrollTop() >= waypointPos) {
alert("Halfway there!");
waypointTriggered = true; // don't keep triggering endlessly
}
});
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<p style="height:200px">This is extra stuff whose height we want to ignore</p>
<div class="hasWaypoint" style="height: 3000px;border:1px solid">This is the big div</div>
I'm currently building a somewhat basic video timeline. On this timeline are various media assets of variable duration and the width of the DIVs representing the assets reflect the duration, with 1 second being measured as 35px. So, for example, a 5 second asset would take up 175px in width on the timeline.
Because the timeline needs to be longer than the width of my usable space on the page, it needs to scroll horizontally. Instead of using an ugly standard scrollbar, I'm using a jQuery plugin scrollbar, which requires that the full-width DIV of the timeline sits inside another DIV that is the width of the usable area of the page and acts as a frame, with the inner DIV being absolutely positioned. When you move the scrollbar left or right it changes the "left" value of the inner DIV.
Having given that context, I now come to my problem. The assets on the timeline need be horizontally resizable to adjust their duration. I have that working using jQuery UI, but I need to make it so that when I drag the right edge of an asset near to the right edge of the outer DIV framing the timeline, the inner DIV of the timeline moves (basically scrolls) left and the width of the asset increases by 1 second (35px).
Even this last bit I have working to a certain degree, but not well enough. What I need is that when I drag far enough to the right, so that I'm within 35-70 pixels of the right edge of the framing DIV, the inner DIV timeline will move to the left, the width of the asset will increase, and this will keep happening until I move my mouse back towards the left.
The best example I can think of is like when you're selecting text in your browser and you drag past the bottom of the screen, the screen starts scrolling down and it keeps doing that till you move your mouse up.
Currently I'm trying to to this by drawing on the "resize" event of the jQuery UI resizable element, but the problem is that I can't get that continuous effect I was just talking about, I have to keep dragging my mouse further to the right rather than just keeping it still. And when I reach the right edge of the window I have to release the mouse button, move back over to the resizing handle and start dragging again.
Here's the function I was trying to write (FYI, a .mediaInstance is an asset on the timeline):
//Scroll Timeline when resized handle comes close to right edge
function timelineScroll() {
//console.log('running');
var mediaElement = $('#mediaTrack .mediaInstance.resizing');
var track = $('#horiz_container_inner');
//Determine location of right edge of the timeline viewport
var timeline = $('#horiz_container_outer');
var timelineOffset = timeline.offset();
var timelineLeft = timelineOffset.left;
var timelineRight = timelineLeft + $('#horiz_container_outer').width();
//Find right edge of current .mediaInstance
var instanceOffset = mediaElement.offset();
var instanceLeft = instanceOffset.left;
var instanceRight = instanceLeft + mediaElement.width();
if ( (timelineRight-instanceRight) < 35 ) {
var timelineCurrentLeft = Number(track.css('left').replace('px',''));
var timelineNewLeft = timelineCurrentLeft - 70;
track.css('left',timelineNewLeft);
mediaCurrentWidth = mediaElement.width();
mediaElement.width(mediaCurrentWidth+35);
if (currentMousePos.x > timelineRight) {
while (currentMousePos.x > timelineRight) {
var timelineCurrentLeft = Number(track.css('left').replace('px',''));
var timelineNewLeft = timelineCurrentLeft - 35;
track.css('left',timelineNewLeft);
mediaCurrentWidth = mediaElement.width();
mediaElement.width(mediaCurrentWidth+35);
}
}
}
}
You'll notice I even tried a loop at the end there based on the mouse position being farther right than the right edge of the framing DIV, but I didn't think it would work, and it didn't ... just seemed to put me in an infinite loop.
In any case, I'd really appreciate any help anyone can offer on this. I'm working on a project with a really short turnaround time and I've never really done any of this particular stuff before.
It turns out I solved my own problem. I just needed to use a setTimeout within the 'resize' function checking if the mouse was beyond the right edge of the framing DIV every 250 milliseconds and, if so, move the inner DIV left and increase the width of the asset. Here's what I used...
EDIT: It turns out my solution didn't work as I'd hoped, so I could use some help after all.
Here's the HTML:
<div id="horiz_container_outer">
<div id="horiz_container_inner" style="position: absolute; left: 0px; top: 0px; visibility: visible;">
<div id="horiz_container" style="width: 10500px;">
<div id="transitionTrack"></div>
<div id="mediaTrack" class="ui-sortable ui-droppable">
<div class="transitionBoxContainer first ui-droppable"></div>
<div class="mediaInstance" assetid="001" assettype="video" thumb="video1_thumb.jpg" style="display: block; width: 419px;" type="asset" duration="12">
<div class="mediaThumbnail" style="background-image: url(./images/assets/thumbnails/video1_thumb.jpg);"></div>
<div class="mediaInfo">
<div class="mediaFilename">video1.avi</div>
<div class="mediaDuration">12s</div>
<div class="mediaHandle"></div>
</div>
<div class="transitionBoxContainer ui-droppable"></div>
<div class="deleteButton"></div>
</div>
</div><!-- End of #mediaTrack -->
</div><!-- End of #horiz_container -->
</div><!-- End of #horiz_container_inner -->
</div><!-- End of #horiz_container_outer -->
And here's my code to make the mediaInstance on the timeline resizable, snapping to 35px increments:
//Watch timeline assets for click on resize handle
$("#mediaTrack").on('mousedown','.mediaInstance .mediaHandle', function(e) {
e.stopPropagation();
e.preventDefault();
//Find offset location of right edge of the timeline viewport
var timelineViewport = $('#horiz_container_outer');
var timelineViewportLeft = timelineViewport.offset().left;
var timelineViewportRight = timelineViewportLeft + timelineViewport.width();
//Assign track object to variable
var track = $('#horiz_container_inner');
var thisInstance = $(this).parents('.mediaInstance');
//Store the mouse position before we start moving
var startMousePos = e.pageX;
$(document).mousemove(function(e){
var currentInstanceWidth = thisInstance.width();
//Find right edge offset of current .mediaInstance
var instanceLeft = thisInstance.offset().left;
var instanceRight = instanceLeft + currentInstanceWidth;
if ( (e.pageX < (startMousePos-35)) || (e.pageX > (startMousePos+35)) ) {
if ( e.pageX < (startMousePos-35) ) {
thisInstance.width(currentInstanceWidth-35);
} else {
thisInstance.width(currentInstanceWidth+35);
}
startMousePos = e.pageX;
recalcDuration(thisInstance);
calcTotalDuration();
}
});
});
$(document).mouseup(function(e){
$(document).unbind('mousemove');
});
That works great for the actual resizing, but the problem I'm having is that when I move the mouse past the right edge of #horiz_container_outer, which acts as a frame for the timeline, I want the #horiz_container_inner DIV to start moving its left position to the left by increments of 35px while also continuing to resize the .mediaInstance div to make it 35px wider ... and I want both those things to happen every 0.25 seconds ... HOWEVER, I don't want the "scrolling" of #horiz_container_inner to continuously fire with the mousemoves. Once the mouse passes the right edge of #horiz_container_outer, I want some function to take over and start scrolling and resizing the .mediaInstance DIV at a set interval until the mouse once again moves left, past the right edge of #horiz_container_outer, at which point the original resizing shown above takes over again.
The problem is that I have no idea how to achieve this. I tried using a flag variable and conditional to tell me when my mouse is "in the zone", with inTheZone = false to begin with, running a conditional to run my initial code only when inTheZone == false, then setting it to true once the mouse enters the right area and having a setTimeout takeover to loop the scrolling and resizing. This worked to a certain degree, but the mouse position suddenly became unavailable so I couldn't tell when I moved outside the zone and the div just kept scrolling indefinitely.
Any ideas?
This may come as a huge surprise to some people but I am having an issue with the IE browser when I am using the $(window).scroll method.
My goal:
I would like to have the menu located on the left retain it's position until the scroll reaches > y value. It will then fix itself to the top of the page until the scroll returns to a < y value.
My error:
Everything seems just fine in Chrome and Firefox but when I go to Internet Explorer it would seem the browser is moving #scroller every time the scroll value changes, this is causing a moving/flickering event.
If someone could point me to a resource or give me a workaround for this I would be very grateful!
Here is a fiddle:
http://jsfiddle.net/CampbeII/nLK7j/
Here is a link to the site in dev:
http://squ4reone.com/domains/ottawakaraoke/Squ4reone/responsive/index.php
My script:
$(window).scroll(function () {
var navigation = $(window).scrollTop();
if (navigation > 400) {
$('#scroller').css('top',navigation - 220);
} else {
$('#scroller').css('top',183);
$('#scroller').css('position','relative');
}
});
You might want to take a look at the jQuery Waypoints plugin, it lets you do sticky elements like this and a lot more.
If you want to stick with your current method, like the other answers have indicated you should toggle fixed positioning instead of updating the .top attribute in every scroll event. However, I would also introduce a flag to track whether or not it is currently stuck, this way you are only updating the position and top attributes when it actually make the transition instead of every scroll event. Interacting with the DOM is computationally expensive, this will take a lot of load off of the layout engine and should make things even smoother.
http://jsfiddle.net/WYNcj/6/
$(function () {
var stuck = false,
stickAt = $('#scroller').offset().top;
$(window).scroll(function () {
var scrollTop = $(window).scrollTop();
if (!stuck && scrollTop > stickAt) {
$('#scroller').css('top', 0);
$('#scroller').css('position','fixed');
stuck = true;
} else if (stuck && scrollTop < stickAt) {
$('#scroller').css('top', stickAt);
$('#scroller').css('position','absolute');
stuck = false;
}
});
});
Update
Switching the #scroller from relative to fixed removes it from the normal flow of the page, this can have unintended consequences for the layout as it re-flows without the missing block. If you change #scroller to use an absolute position it will be removed from the normal flow and will no longer cause these side-effects. I've updated the above example and the linked jsfiddle to reflect the changes to the JS/CSS.
I also changed the way that stickAt is calculated as well, it uses .offset() to find the exact position of the top of #scoller instead of relying on the CSS top value.
Instead of setting the top distance at each scroll event, please consider only switching between a fixed position and an absolute or relative position.All browsers will appreciate and Especially IE.
So you still listen to scroll but you now keep a state flag out of the scroll handler and simply evaluate if it has to switch between display types.
That is so much more optimized and IE likes it.
I can get flickers in Chrome as well if I scroll very quickly. Instead of updating the top position on scroll, instead used the fixed position for your element once the page has scrolled below the threshold. Take a look at the updated fiddle: http://jsfiddle.net/nLK7j/2/
I try to make a mousewheel event script, but getting some issues since I'm using an Apple Magic Mouse and its continue-on-scroll function.
I want to do this http://jsfiddle.net/Sg8JQ/ (from jQuery Tools Scrollable with Mousewheel - scroll ONE position and stop, using http://brandonaaron.net/code/mousewheel/demos), but I want a short animation (like 250ms) when scrolling to boxes, AND ability to go throught multiple boxes when scrolling multiple times during one animation. (If I scroll, animation start scrolling to second box, but if I scroll again, I want to go to the third one, and if I scroll two times, to the forth, etc.)
I first thought stopPropagation / preventDefault / return false; could "stop" the mousewheel velocity (and the var delta) – so I can count the number of new scroll events (maybe with a timer) –, but none of them does.
Ideas?
EDIT : If you try to scroll in Google Calendars with these mouses, several calendars are switched, not only one. It seems they can't fix that neither.
EDIT 2 : I thought unbind mousewheel and bind it again after could stop the mousewheel listener (and don't listen to the end of inertia). It did not.
EDIT 3 : tried to work out with Dates (thanks to this post), not optimal but better than nothing http://jsfiddle.net/eZ6KE/
Best way is to use a timeout and check inside the listener if the timeout is still active:
var timeout = null;
var speed = 100; //ms
var canScroll = true;
$(element).on('DOMMouseScroll mousewheel wheel', function(event) {
// Timeout active? do nothing
if (timeout !== null) {
event.preventDefault();
return false;
}
// Get scroll delta, check for the different kind of event indexes regarding delta/scrolls
var delta = event.originalEvent.detail ? event.originalEvent.detail * (-120) : (
event.originalEvent.wheelDelta ? event.originalEvent.wheelDelta : (
event.originalEvent.deltaY ? (event.originalEvent.deltaY * 1) * (-120) : 0
));
// Get direction
var scrollDown = delta < 0;
// This is where you do something with scrolling and reset the timeout
// If the container can be scrolling, be sure to prevent the default mouse action
// otherwise the parent container can scroll too
if (canScroll) {
timeout = setTimeout(function(){timeout = null;}, speed);
event.preventDefault();
return false;
}
// Container couldn't scroll, so let the parent scroll
return true;
});
You can apply this to any scrollable element and in my case, I used the jQuery tools scrollable library but ended up heavily customizing it to improve browser support as well as adding in custom functionality specific to my use case.
One thing you want to be careful of is ensuring that the timeout is sufficiently long enough to prevent multiple events from triggering seamlessly. My solution is effective only if you want to control the scrolling speed of elements and how many should be scrolled at once. If you add console.log(event) to the top of the listener function and scroll using a continuous scrolling peripheral, you will see many mousewheel events being triggered.
Annoyingly the Firefox scroll DOMMouseScroll does not trigger on magic mouse or continuous scroll devices, but for normal scroll devices that have a scroll and stop through the clicking cycle of the mouse wheel.
I had a similar problem on my website and after many failed attempts, I wrote a function, which calculated total offset of selected box and started the animation over. It looked like this:
function getOffset() {
var offset = 0;
$("#bio-content").children(".active").prevAll().each(function (i) {
offset += $(this)[0].scrollHeight;
});
offset += $("#bio-content").children(".active")[0].scrollHeight;
return offset;
}
var offset = getOffset();
$('#bio-content').stop().animate( {
scrollTop: offset
}, animationTime);
I hope it gives you an idea of how to achieve what you want.
you can try detecting when wheel stops moving, but it would add a delay to your response time
$(document).mousewheel(function() {
clearTimeout($.data(this, 'timer'));
$.data(this, 'timer', setTimeout(function() {
alert("Haven't scrolled in 250ms!");
//do something
}, 250));
});
source:
jquery mousewheel: detecting when the wheel stops?
or implement flags avoiding the start of a new animation
var isAnimating=false;
$(document).bind("mousewheel DOMMouseScroll MozMousePixelScroll", function(event, delta) {
event.preventDefault();
if (isAnimating) return;
navigateTo(destination);
});
function navigateTo(destination){
isAnimating = true;
$('html,body').stop().animate({scrollTop: destination},{complete:function(){isAnimating=false;}});
}
I'm relatively new to programming and am working on a Chrome extension with a popup. I want to save the scroll position between popup invocations. It seems like I've found a lot of info on the internet, but so far I haven't been able to solve my problem. Saving the scroll position almost works within my extension, but I'm seeing 2 issue:
1) To save the scroll position at each scroll event, I use:
addEventListener('scroll', function(){
localStorage.scrollTop = document.body.scrollTop;
});
When the popup is opened, I use:
document.body.scrollTop = localStorage.scrollTop
This seems to work fine until my scroll position exceeds the popup height. The max popup height for visible content in a Chrome extension is 600px. document.body.style.height is also always a fixed value greater than 600px. When the scroll position is greater than document.body.style.height - 600px, say 900px - 600px, document.body.scrollTop is reset to 300px. Even if the last scroll position (document.body.scrollTop) was 400px before the popup is closed, scroll position is reset to 300px when the popup is re-opened. Obviously, I get the wrong scroll position because the proper scroll position value of 400px (for example) is then overwritten by 300px.
However, it doesn't always happen. Sometimes I can properly save a scroll position of, say 500px with a window height of 900px, and other times I can't. I don't know for sure why this has any effect, but as the content in the popup is taller the problem seems to magically go away and the proper scroll position is saved.
How can I properly save the scroll position when the scroll position in this situation? Perhaps I'm doing something fundamentally wrong?
(This seems confusing to read. I hope it's possible to help with the code above.)
2) I think might be intertwined with the above issue, but I'm not sure. For every scroll event (first code block in this post), I see a pair of scroll events fired.
If I just open the popup but don't scroll the mousewheel, I see code inside my event listener fire. When the first event happens, document.body.scrollTop is reset to the "wrong" value (300px in the example above). I think this might be the root cause of both issues.
Shouldn't the event listener only fire if the mousewheel is moved, and thus the code inside the event listener doesn't execute if the popup is opened but the mousewheel isn't touched?
The issue was actually in the corresponding CSS code. The <div> wasn't scrollable, and so it didn't allow saving a scroll position bigger than the visible area of the popup.
The solution was to add the position: relative; and overflow-y: auto; (overflow-y: scroll; works as well) properties to the CSS element.
Once those properties were added, I could properly save the scroll position to an object.
I know this shouldn't be an answer, but I can't format code in a comment:
I'm going to go out on a limb and suggest you try test one of the basic assumptions: Are the scroll events firing in order?
Try this, see if adds some useful debugging info:
var scrollCounter = 0;
addEventListener('scroll', function(){
localStorage.scrollTop = document.body.scrollTop;
console.log({counter: scrollCounter, scrollTop: document.body.scrollTop});
scrollCounter++
})
See if the numbers seem to jump around, or if they go in progressive order.
Edit: P-code for a possible solution
I think I have an idea for a fix, though I use the word fix kind of loosely here.
var captureSemaphore; // Use this to flag exactly when we want the `scrollTop` captured.
captureSemaphore = true; // Go into catch mode
addEventListener('scroll', function(){
if (captureSemaphore) {
localStorage.scrollTop = document.body.scrollTop;
}
});
// then later, right before you open your popup
captureSemaphore = false; // Disable catch mode because the screen is about to change
openPopup(); // Open the popup
// Elsewhere
closePopup(); // Close the popup
captureSemaphore = true; // Go into catch mode