
jQuery.viewport or how I searched for items on the screen

Just like every girl should have a “little black dress”, every front-end developer should have a “little black plug-in” ... somehow it doesn’t sound very good, let there be a “little functional plug-in”, so what am I talking about, I mean that I want to share one such.
The presented plugin allows you to determine the position of any element / set of elements relative to the viewing area. Functionally, it extends the set of pseudo-selectors, and also adds an element tracker.
Also, under the cut, I will talk about the process of writing a plug-in, what difficulties I encountered, etc., if I am interested in you, I ask you to be welcome under cut.
The problem arose just before me, to catch and process elements at the moment of their appearance in the scope, moreover, the scope is not always the whole screen, sometimes it is a block with
overflow: auto;
, and sometimes it is necessary to process the elements only when they appear on the whole screen and moreover, scrolling there in all directions (vertically and / or horizontally). I went to googling in search of something ready and, to my surprise, I didn’t find anything that would completely satisfy my needs, or the task was partially solved, like here (to be honest, the idea of expanding pseudo-selectors was stolen from there, but thisthe picture doesn’t lie, right?), or the plugin was not at all about that. So I faced the fact that I need to write my own, then I decided to share this business on a github, and even later decided to write this article, than actually, having successfully completed the first two points of my plan
If you are not interested in the development process, poke - >> here << - and you get immediately to the place about where they distribute it.
Prologue
If you don’t know about writing plugins for jQuery, but really want to learn how to do this, I highly recommend that you read this article first , everything is intelligible and understandable (it requires at least basic knowledge of JS and JQ).
UPDATE 1 (10/13/2014)
- The part of the plugin has been rewritten, see the changes on the github.
- A problem has been discovered whose solutions I can’t find yet:
If the parent does not have any limiting factors (padding, border, overflow! = Visible), then the margin will go from the inner element to the outer one, and the offsetHeight of the parent element will be calculated without taking into account the margin of its descendants, while scrollHeight will correctly determine the height with considering margin of child elements. As a result, such a parent element is defined as having scrolling, because content height <height of the element itself.
UPDATE 2 (10.16.2014)
- As a solution to the above problem, the ability to set the viewport selector to be tracked, details on the github, was added.
- The speed of the plugin has been significantly increased.
Let's get started with
So, the task: it is necessary to somehow determine the position of the element relative to the scope, and depending on the context, the scope can be not only the browser window, but also smaller elements that have scrolling.
What is scope?
Let's start by defining what is scope for this context.
For my task, as I wrote the plugin, first of all, to satisfy my needs, the scope is the closest parent that has scrolling.

Unfortunately, there is no guaranteed and cross-browser way to determine the presence of a scrollbar (at least I don’t know about it), and besides, I use a custom scrollbar, it can be properly styled, but it’s applied to the container
overflow: hidden;
and, as a result, The stock scrollbar is hiding. But there is a way out, you can compare the height of the container (
containerElem.offsetHeight
) and the height of its contents (containerElem.scrollHeight
) and if the height of the content exceeds the height of the container, then most likely, and for my projects - always, such a container has a scroll. We make this case into the code:
(function( $ ) { // используем замыкание, дабы не конфликтовать с другими расширениями
var methods = { // все методы оформляем в литерал дабы не засорять глобальное пространство имен
haveScroll: function() {
return this.scrollHeight > this.offsetHeight
|| this.scrollWidth > this.offsetWidth;
}
};
$.extend( $.expr[':'], { // расширяем литерал выражений ':' своими методами, дефакто это будущий селектор ":have-scroll"
"have-scroll": function( obj ) {
return methods['haveScroll'].call( obj ); // посредством .call() определяем для выполняемого метода контекст
}
} );
})( jQuery );
From now on, we can use .is (": have-scroll") to determine if an element has scroll (or the premise for its presence) or not.
Item positioning
The next step is to determine the location of the block of interest to us relative to the scope.
The first thing that comes to mind:
top = $( element ).offset().top;
left = $( element ).offset().left;
But no, it .offset()
positions any element relative to the upper left corner of the browser window, and the scope, as they said, is not always the browser window - it doesn’t fit, we’ll sweep. The second thing that comes to mind:
top = $( element ).position().top;
left = $( element ).position().left;
Also, it .position()
positions the element only relative to the upper left corner of its nearest parent, it would seem that it is, but consider the structure:
And the task is to track precisely in span
relation to #viewport
, in this case, it .position()
will position span
relative to .element
what does not suit us, we drove on. The solution will be its own method, which will bypass all parents up the DOM tree, down to the scope of this context.
getFromTop: function() {
var fromTop = 0;
for( var obj = $( this ).get( 0 ); obj && !$( obj ).is( ':have-scroll' ); obj = obj.offsetParent ) {
fromTop += obj.offsetTop;
}
return Math.round( fromTop );
}
Why $( this ).get( 0 ).offsetTop
not $( this ).position().top
? - some will ask. There are two reasons for this:
- .position () takes into account the offset (top, bottom, left, right), does not take into account margins, and therefore you will have to use it
.css('margin-top')
and then another.css('margin-bottom')
- these same
.css('margin-top')
and.css('margin-bottom')
return the value in the form13px
, that is, one also has to parseInt (str, 10) do to make basic mathematical operations, the option of subtracting heights, given the different indentation (.innerHeight (), .outerHeight, .outerHeight (true)) I don’t even consider it, because they can be specified asymmetrically, but this is important for us.
In the end, taking into account all these unnecessary operations, the use case$( this ).position().top
works one and a half to two times slower than the one withthis.offsetTop
my overclocked i7, and the user can sit on some stump, and it’s scary to imagine what it will result in.
We add a similar method to determine the position from the left edge.
So, now we know where, with respect to the scope, the tracked object is located, but the received data
.scrollTop()
and those .scrollLeft()
who know how to get the value of vertical and horizontal scrolling, respectively. Moreover, we need to know the position of all sides of the tracked block and the size of the scope.
We issue in the next method:
getElementPosition: function() {
var _scrollableParent = $( this ).parents( ':have-scroll' ); // ищем ближайшего родителя со скроллингом, .parents() потому, что при использовании .closest(), span, порой, находит в качестве скроллабельного элемента самого себя.
if( !_scrollableParent.length ) { //на случай, если у нас все уместилось.
return false;
}
var _topBorder = methods['getFromTop'].call( this ) - _scrollableParent.scrollTop(); // здесь вычисляется положение верхней границы элемента относительно верхней же границы области вижмости
var _leftBorder = methods['getFromLeft'].call( this ) - _scrollableParent.scrollLeft(); // аналогично предыдущему только левые границы
return {
"elemTopBorder": _topBorder,
"elemBottomBorder": _topBorder + $( this ).height(), // тут еще проще, правая граница = левая + ширина элемента
"elemLeftBorder": _leftBorder,
"elemRightBorder": _leftBorder + $( this ).width(),
"viewport": _scrollableParent,
"viewportHeight": _scrollableParent.height(), // нижняя граница области видимости
"viewportWidth": _scrollableParent.width() // првая граница области видимости
};
}
This function returns a hash table, it is simply more convenient to work with it later. All with the relative positioning of the block figured out.
And yet, above or below
Go to the code:
aboveTheViewport: function( threshold ) {
var _threshold = typeof threshold == 'string' ? parseInt( threshold, 10 ) : 0;
var pos = methods['getElementPosition'].call( this );
return pos ? pos.elemTopBorder - _threshold < 0 : false;
}
Here, I think, everything is clear, the only thing I will clarify about the threshold and the strict minority.
Threshold - a parameter that sets the indent from the edge of the scope, for some tasks it may be necessary to process it a little earlier than the object enters the scope or a little later.

A strict minority is indicated for the reason that if the boundaries coincide, then the element has not yet crossed the border and so far fits into the visibility zone, which means it is inside.
Also, for a partial stay in the scope, it is already a little more complicated, but still simple. just this time we are already checking that the corresponding border has gone beyond the scope and the opposite is still within it.
partlyAboveTheViewport: function( threshold ) {
var _threshold = typeof threshold == 'string' ? parseInt( threshold, 10 ) : 0;
var pos = methods['getElementPosition'].call( this );
return pos ? pos.elemTopBorder - _threshold < 0
&& pos.elemBottomBorder - _threshold >= 0 : false;
}
It makes no sense to describe checking the remaining boundaries, everything is the same there, except for the code of the method that checks whether an element is inside the scope:
inViewport: function( threshold ) {
var _threshold = typeof threshold == 'string' ? parseInt( threshold, 10 ) : 0;
var pos = methods['getElementPosition'].call( this );
return pos ? !( pos.elemTopBorder - _threshold < 0 )
&& !( pos.viewportHeight < pos.elemBottomBorder + _threshold )
&& !( pos.elemLeftBorder - _threshold < 0 )
&& !( pos.viewportWidth < pos.elemRightBorder + _threshold ) : true;
}
But what about selectors?
Everything is fine with them, no one forgot about them.
So, we have prescribed all the methods in the object literal
methods
, what is the next step to make a boom? Fan out, expand the pseudo-selector literal: "in-viewport": function( obj, index, meta ) {
return methods['inViewport'].call( obj, meta[3] );
},
"above-the-viewport": function( obj, index, meta ) {
return methods['aboveTheViewport'].call( obj, meta[3] );
},
"below-the-viewport": function( obj, index, meta ) {
return methods['belowTheViewport'].call( obj, meta[3] );
},
"left-of-viewport": function( obj, index, meta ) {
return methods['leftOfViewport'].call( obj, meta[3] );
},
"right-of-viewport": function( obj, index, meta ) {
return methods['rightOfViewport'].call( obj, meta[3] );
},
"partly-above-the-viewport": function( obj, index, meta ) {
return methods['partlyAboveTheViewport'].call( obj, meta[3] );
},
"partly-below-the-viewport": function( obj, index, meta ) {
return methods['partlyBelowTheViewport'].call( obj, meta[3] );
},
"partly-left-of-viewport": function( obj, index, meta ) {
return methods['partlyLeftOfViewport'].call( obj, meta[3] );
},
"partly-right-of-viewport": function( obj, index, meta ) {
return methods['partlyRightOfViewport'].call( obj, meta[3] );
},
"have-scroll": function( obj ) {
return methods['haveScroll'].call( obj );
}
} );
It is worth noting one chip, remember I spoke about the input parameter
threshold
? Do you remember the standard parametric pseudo-selector :not(selector)
? So, we can also use such a construction to specify a thrashold directly in the pseudo-selector:
$( element ).is( ":in-viewport(10)" );
In this case, the trashold will expand the scope by 10 px.Tracking
Dachshunds, pseudo-selectors have been expanded, now we would have to somehow monitor the whole thing in a convenient way.
Ideally, of course, we would have to create our own event, but it so happened historically that
jQuery.event.special
we are in extremely bad relations, and .trigger()
- in my opinion, an idea so-so, not for this case - for sure. Therefore, we will have the most brutal function that calls the callBack function in no less brutal way.Tracker Code
$.fn.viewportTrack = function( callBack, options ) {
var settings = $.extend( {
"threshold": 0,
"allowPartly": false,
"allowMixedStates": false
}, options ); // настройки по-дефолту
if( typeof callBack != 'function' ) { // в случае если первым параметром пришла не функция - отказываемся делать что либо
$.error( 'Callback function not defined' );
return this;
}
return this.each( function() { // цепочки вызовов никто не отменял
var $this = this;
callBack.apply( $this, [ methods['getState'].apply( $this, [ settings ] ) ] ); // проверяем положение на момент инициалзации
var _scrollable = $( $this ).parents( ':have-scroll' );
if( !_scrollable.length ) {
callBack.apply( $this, 'inside' );
return true;
}
if( _scrollable.get( 0 ).tagName == "BODY" ) { // в случае, если скроллинг имеет body, событие scroll будет генерировать window, а не сам body, как может показаться на первый взгляд
$( window ).bind( "scroll.viewport", function() {
callBack.apply( $this, [ methods['getState'].apply( $this, [ settings ] ) ] );
} );
} else {
_scrollable.bind( "scroll.viewport", function() { // в противном же случае, событие scroll генерируется самим элементом
callBack.apply( $this, [ methods['getState'].apply( $this, [ settings ] ) ] );
} );
}
} );
};
NAILED IT!
Actually, no, it would be necessary to teach our brutal to “unscrew” ... to hell with these fictitious words, in short, stop tracking one or another element, the moment is connected with this, that you create your own scroll event handler to track each individual element. If all callback functions are called from one scroll event handler, we will not be able to influence the set of monitored elements without reinstalling the handler again.
Here the event namespaces will help us, if we produce
.bind( "scroll.viewport")
both .bind( "scroll")
on the same element and then .unbind( ".viewport")
on the same element, then only the event handler will be untied scroll.viewport
but not easy scroll
.And how does this help in the current task? - you ask, I answer, you will certainly have to lick up the namespace (this is such a tautology), but the goal will be achieved, so we add a method that generates a random id. here I’ll just not even comment:
generateEUID: function() {
var result = "";
for( var i = 0; i < 32; i++ ) {
result += Math.floor( Math.random() * 16 ).toString( 16 );
}
return result;
}
Further, during initialization for each element, push this same generated euid (element's unique id) in .data (), and when we hang up the scroll handlers, we create a namespace .viewport + EUID
. And of course, a destructor that iterates over the EUID of the set and removes unnecessary handlers, without hitting those that we still need. In the final version we get:Tracker Code Final
$.fn.viewportTrack = function( callBack, options ) {
var settings = $.extend( {
"threshold": 0,
"allowPartly": false,
"allowMixedStates": false
}, options );
if( typeof callBack == 'string' && callBack == 'destroy' ) { // деструктор
return this.each( function() {
var $this = this;
var _scrollable = $( $this ).parent( ':have-scroll' );
if( !_scrollable.length || typeof $( this ).data( 'euid' ) == 'undefined' ) {
return true; // если нет скроллабельного элемента, значит мы ничего и не привязывали
} //так же если euid отсутствует, значит либо обработчик уже отвязан, либо он и не привязывался
if( _scrollable.get( 0 ).tagName == "BODY" ) {
$( window ).unbind( ".viewport" + $( this ).data( 'euid' ) );
$( this ).removeData( 'euid' );
} else {
_scrollable.unbind( ".viewport" + $( this ).data( 'euid' ) );
$( this ).removeData( 'euid' );
}
} );
} else if( typeof callBack != 'function' ) {
$.error( 'Callback function not defined' );
return this;
}
return this.each( function() {
var $this = this;
if( typeof $( this ).data( 'euid' ) == 'undefined' )
$( this ).data( 'euid', methods['generateEUID'].call() );//присваиваем EUID если оный уже не присвоен
callBack.apply( $this, [ methods['getState'].apply( $this, [ settings ] ) ] );
var _scrollable = $( $this ).parents( ':have-scroll' );
if( !_scrollable.length ) {
callBack.apply( $this, 'inside' );
return true;
}
if( _scrollable.get( 0 ).tagName == "BODY" ) {
$( window ).bind( "scroll.viewport" + $( this ).data( 'euid' ), function() { // как видно, в неймспейс подмешивается EUID
callBack.apply( $this, [ methods['getState'].apply( $this, [ settings ] ) ] );
} );
} else {
_scrollable.bind( "scroll.viewport" + $( this ).data( 'euid' ), function() {
callBack.apply( $this, [ methods['getState'].apply( $this, [ settings ] ) ] );
} );
}
} );
};
I skipped one method, leaving it to the very end, due to the fact that this is a stupid trigger with a cloud of branches depending on the settings that were passed during initialization. True, there are a couple of things worth noting:
- The logic by which the relative position of the element was determined is duplicated in this method in order not to do unnecessary calculations and selections, but only once to get all the necessary data and already work with them, there is more code, but there are 8 less samples.
- The state is returned as an object with three parameters
If itvar ret = { "inside": false, "posY": '', "posX": '' };
inside
returns with a valuetrue
, thenposY
itposX
remains empty, because the tracked item is fully within the viewport.
Otherwise, the state of the element relative to each axis is indicated, see the github for details.
Method code methods ['getState']
getState: function( options ) {
var settings = $.extend( {
"threshold": 0,
"allowPartly": false
}, options );
var ret = { "inside": false, "posY": '', "posX": '' };
var pos = methods['getElementPosition'].call( this );
if( !pos ) {
ret.inside = true;
return ret;
}
var _above = pos.elemTopBorder - settings.threshold < 0;
var _below = pos.viewportHeight < pos.elemBottomBorder + settings.threshold;
var _left = pos.elemLeftBorder - settings.threshold < 0;
var _right = pos.viewportWidth < pos.elemRightBorder + settings.threshold;
if( settings.allowPartly ) {
var _partlyAbove = pos.elemTopBorder - settings.threshold < 0 && pos.elemBottomBorder - settings.threshold >= 0;
var _partlyBelow = pos.viewportHeight < pos.elemBottomBorder + settings.threshold && pos.viewportHeight > pos.elemTopBorder + settings.threshold;
var _partlyLeft = pos.elemLeftBorder - settings.threshold < 0 && pos.elemRightBorder - settings.threshold >= 0;
var _partlyRight = pos.viewportWidth < pos.elemRightBorder + settings.threshold && pos.viewportWidth > pos.elemLeftBorder + settings.threshold;
}
if( !_above && !_below && !_left && !_right ) {
ret.inside = true;
return ret;
}
if( settings.allowPartly ) {
if( _partlyAbove && _partlyBelow ) {
ret.posY = 'exceeds';
} else if( ( _partlyAbove && !_partlyBelow ) || ( _partlyBelow && !_partlyAbove ) ) {
ret.posY = _partlyAbove ? 'partly-above' : 'partly-below';
} else if( !_above && !_below ) {
ret.posY = 'inside';
} else {
ret.posY = _above ? 'above' : 'below';
}
if( _partlyLeft && _partlyRight ) {
ret.posX = 'exceeds';
} else if( ( _partlyLeft && !_partlyRight ) || ( _partlyLeft && !_partlyRight ) ) {
ret.posX = _partlyLeft ? 'partly-above' : 'partly-below';
} else if( !_left && !_right ) {
ret.posX = 'inside';
} else {
ret.posX = _left ? 'left' : 'right';
}
} else {
if( _above && _below ) {
ret.posY = 'exceeds';
} else if( !_above && !_below ) {
ret.posY = 'inside';
} else {
ret.posY = _above ? 'above' : 'below';
}
if( _left && _right ) {
ret.posX = 'exceeds';
} else if( !_left && !_right ) {
ret.posX = 'inside';
} else {
ret.posX = _left ? 'left' : 'right';
}
}
return ret;
}
Fuffff, that's all. Here is such a plug-in, I got it on the 301 line.
References
You can pick up the plugin from my github: https://github.com/xobotyi/jquery.viewport
How to use it in more detail is described in readme.
I sincerely hope that this article will benefit someone and tell something new.
For sim, I bow, all code, sleep and lack of desire to write an article at 4 nights.
Ps Is it worth it to check the "training material"?