On a responsive website where elements may shrink/expand according to the size of your viewport, the elements that was at the top of your viewport at the time may get pushed down/up as the elements above them get squished/expanded. Forcing you to scroll down/up again in order to get to the same area you were looking at.
This javascript code attempts to get rid of this problem by finding and updating the element at/near the top of the viewport on load and on scroll. Then it performs a scroll towards the current top element when the size of the viewport changes.
Based heavily on the answer I've received in my SO question for this, I got the following code in the end:
// Define detection area (where to pick element that we will lock on to)
var DETECT_TOP = 5,
DETECT_SIDE = 60,
DETECT_HEIGHT = 60;
// Step between each check in the detection box
// laggy if too small
var DETECT_STEP_X = 50,
DETECT_STEP_Y = 10;
// Cut-off is when the element we detected is sliced
// in-between by the top of the viewport
var CUTOFF_ADJUST = true;
var viewportWidth = $(window).width();
function Node () {
var that = this;
this.element = null;
// data
this.documentOffset = {
// jQuery element.offset().top is funny in old Safari
top: function () {
if (!that.element) { return 0; }
return that.element.offsetTop + that.element.offsetParent.offsetTop;
},
bottom: function () {
if (!that.element) { return 0; }
return this.top() + $(that.element).height();
}
};
this.viewportOffset = {
//offsets before and after resizing may be different in a responsive website
// (thinner <p> increases its height due to word-wrap, etc.)
oldTop: 0,
oldBottom: 0,
top: function () {
this.oldTop = that.documentOffset.top() - $(window).scrollTop();
return this.oldTop;
},
bottom: function () {
this.oldBottom = that.documentOffset.bottom() - $(window).scrollTop();
return this.oldBottom;
}
};
// Main functions
this.update = function () {
var sampleElement, timer;
clearTimeout(timer);
//
// Timeout stuff = fix for old IE
// https://stackoverflow.com/questions/8227345/elementfrompoint-returns-null-when-it-used-in-onscroll-event
//
timer = setTimeout(
function () {
for (var y = DETECT_TOP; y <= DETECT_TOP + DETECT_HEIGHT; y+= DETECT_STEP_Y) {
for (var x = DETECT_SIDE; x <= $(document).width() - DETECT_SIDE; x+= DETECT_STEP_X) {
//scans the detected box for an element
sampleElement = getElementAt(x, y);
// Change element criterions here
// e.g. we don't want it to pick underlying container div
// don't pick fixed sidebar, etc. etc.
if (sampleElement && sampleElement.children.length === 0) {
that.element = sampleElement;
that.updateOffsets();
return;
}
}
}
},
0
);
};
this.scrollTo = function (act) {
var scrollAmount = that.documentOffset.top();
//calculate scroll amount, then perform it
if (that.element) {
if (CUTOFF_ADJUST) {
if (that.wasBelowViewportTop()) {
scrollAmount -= that.viewportOffset.oldTop;
} else {
scrollAmount = that.documentOffset.bottom() - that.viewportOffset.oldBottom;
}
}
if (navigator.userAgent.indexOf('Safari') != -1 && navigator.userAgent.indexOf('Chrome') == -1) {
//
// Setting scrollTop() on Safari:
// $(document).scrollTop(scrollAmount) works only when expanding
// $("body").scrollTop(scrollAmount) works only when shrinking
//
if (act == "expanding") {
$(document).scrollTop(scrollAmount);
}
if (act == "shrinking") {
$("body").scrollTop(scrollAmount);
}
} else {
$("html, body").scrollTop(scrollAmount);
}
that.updateOffsets();
}
};
// helper functions
this.updateOffsets = function () {
that.viewportOffset.top();
that.viewportOffset.bottom();
};
this.wasBelowViewportTop = function () {
if (this.viewportOffset.oldTop >= 0) {
return true;
}
return false;
};
}
var elementFromPointFeatureCheck = false,
elementFromPointIsRelative = true;
function getElementAt (x, y) {
//
// Various fixes for document.elementFromPoint(x, y)
// null check is for IE8,
// !elementFromPointIsRelative is for browsers that uses x, y relative to document (some old Safari and Opera etc.)
// elementFromPointIsRelative is for browsers that uses x, y relative to viewport (default functionality)
//
// https://stackoverflow.com/questions/17953672/document-elementfrompoint-in-jquery/19363125#19363125
// http://www.zehnet.de/2010/11/19/document-elementfrompoint-a-jquery-solution/
// (see comment on zehnet)
//
var scroll, doc;
if (!document.elementFromPoint) {
return null;
}
if (!elementFromPointFeatureCheck) {
if ((scroll = $(window).scrollTop()) > 0) {
doc = document.elementFromPoint(0, scroll + $(window).height() -1);
if (doc !== null && doc.tagName == "HTML") {
doc = null;
}
elementFromPointIsRelative = (doc === null);
}
elementFromPointFeatureCheck = (scroll > 0);
}
if (!elementFromPointIsRelative) {
y += $(window).scrollTop();
}
return document.elementFromPoint(x, y);
}
That's the main definitions chunk of the code. Then I put it all together:
var detectedNode = new Node();
$(window)
.load(detectedNode.update)
.scroll(detectedNode.update)
.resize(function () {
var act = (viewportWidth <= $(window).width()) ? "expanding" : "shrinking";
viewportWidth = $(window).width();
detectedNode.scrollTo(act);
});
And everything is inside a jQuery(document).ready()
.
What are some changes I should make and is there any issues and/or best practices that I should be made aware of?
I think that the code is a little too cluttered with browser fixes, but I am not too sure how, or whether or not I should separate them to make the main part of the code more readable.
I am also rather new to oop, but I have a feeling that my Node object is doing more than it should. Although we scrollTo
the node, but it isn't the node that's doing the scrolling? But then what about the node update
method?
Demo
Blank mark-up for comparison.
jsbin Demo with the mark-up above.
Browser Support
It should at least work in recent versions of chrome, ff, ie, safari, and opera.
I am still trying to add fixes to make it work for slightly older versions like saf 4, opera 10 and/or beyond, but these aren't a priority.
Here's the quirksmode page containing the functions used in the code.
2 Answers 2
I just saw this
this.viewportOffset = { //offsets before and after resizing may be different in a responsive website // (thinner <p> increases its height due to word-wrap, etc.) oldTop: 0, oldBottom: 0, top: function () { this.oldTop = that.documentOffset.top() - $(window).scrollTop(); return this.oldTop; }, bottom: function () { this.oldBottom = that.documentOffset.bottom() - $(window).scrollTop(); return this.oldBottom; } };
I think that you can remove this.oldTop
and this.oldBottom
by writing it like this
this.viewportOffset = {
//offsets before and after resizing may be different in a responsive website
// (thinner <p> increases its height due to word-wrap, etc.)
top: function () {
return that.documentOffset.top() - $(window).scrollTop();
},
bottom: function () {
return that.documentOffset.bottom() - $(window).scrollTop();
}
};
Less variables being declared means less memory being taken up by the website equals a faster, more responsive website.
This could be a little shorter and more to the point
this.wasBelowViewportTop = function () {
if (this.viewportOffset.oldTop >= 0) {
return true;
}
return false;
};
By using a ternary expression
this.wasBelowViewportTop = function () { this.viewportOffset.oldTop >= 0 ? true : false; };
But this is Silly as @Konijn pointed out just set it
this.wasBelowViewportTop = function () { this.viewportOffset.oldTop >= 0;};
It will evaluate the conditional and set this.wasBelowViewportTop
to a true/false value.
Edit
Where you use this function can also be cleaned up by using a ternary statement
if (that.wasBelowViewportTop()) { scrollAmount -= that.viewportOffset.oldTop; } else { scrollAmount = that.documentOffset.bottom() - that.viewportOffset.oldBottom; }
Would become this (one line statement spread across lines for viewing)
scrollAmount = that.wasBelowViewportTop()
? scrollAmount - that.viewportOffset.oldTop
: that.documentOffset.bottom() - that.viewportOffset.oldBottom;
Another thing that you could do is to get rid of the function altogether, you are only using it in this one place, so you could write the ternary statement like this instead
scrollAmount = this.viewportOffset.oldTop >= 0
? scrollAmount - that.viewportOffset.oldTop
: that.documentOffset.bottom() - that.viewportOffset.oldBottom;
This lowers the code count quite a bit.
-
1\$\begingroup\$ Also a ternary for true/false does not make sense you would do
return this.viewportOffset.oldTop >= 0;
\$\endgroup\$konijn– konijn2014年08月13日 20:52:17 +00:00Commented Aug 13, 2014 at 20:52 -
\$\begingroup\$ You are mapping true/false with a ternary to true/false, instead of just using the evaluation of the condition. Do you see why that does not make sense ( or at least, is overkill ). \$\endgroup\$konijn– konijn2014年08月13日 20:57:24 +00:00Commented Aug 13, 2014 at 20:57
-
\$\begingroup\$ Ok, I'll make these changes in the code above. @top/bottom bit, in the scrolling function, the scroll amount is determined by the new
documentOffset
(as it may get pushed up or down) after window resize. But this aligns the top of the element to the top of the viewport, so I adjusted the scroll amount by the oldviewportOffset
before the window resize which is being kept updated with vpOffset.top() during scroll or load event, but when accessing it on resize, using top() again will get the new vpOffset? I also gather that my names are not very good, I'll attend to this as well. \$\endgroup\$Sylin– Sylin2014年08月14日 01:44:25 +00:00Commented Aug 14, 2014 at 1:44 -
\$\begingroup\$ If I only used
wasBelowViewportTop
once, is it even better still to use the expression in the if condition since it's a method that is unlikely to change? I've also tested out removing oldTop and oldBottom, but it breaks the code. Is there a pattern that implements what I've been describing in a better way? \$\endgroup\$Sylin– Sylin2014年08月14日 02:42:24 +00:00Commented Aug 14, 2014 at 2:42 -
\$\begingroup\$ I don't see why it wouldn't work... \$\endgroup\$Malachi– Malachi2014年10月07日 20:50:42 +00:00Commented Oct 7, 2014 at 20:50
Ok, here's my first revision :)
Simplifying
viewportOffset.top
/bottom
from Malachithis.viewport = { top: 0, bottom: 0, update: function () { this.top = that.documentOffset.top() - $(window).scrollTop(); this.bottom = that.documentOffset.bottom() - $(window).scrollTop(); } }
Since the scrolling needs to be adjusted by the viewport offset of the element before resizing,
top
andbottom
is kept as a property so we can access them without updating it, and then we call theupdate
method just on load and on scroll.I then use the same pattern when checking viewport width to detect expanding/scrolling:
$(window).resize(function () { viewport.isExpanding = (viewport.width <= $(window).width()); viewport.width = $(window).width(); viewport.scrollTo(detectedNode); });
But this uses a viewport object.
So I add it and use the chance to reorganise some functions around.
var detectedNode = new Node(); var viewport = new Viewport(); function Node () { this.element; this.documentOffset = { .. }; this.viewportOffset = { .. }; this.update = function () { .. }; } function Viewport () { this.width; this.isExpanding; //bool this.scrollTo = function (node) { .. }; this.elementFromPoint (x, y) { .. }; }
Fixed some names like
elementFromPointIsRelative
andelementFromPointFeatureCheck
as suggested in the comments.