📜 ⬆️ ⬇️

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 much, let there be a “little functional plug-in”, so what is it about me, me 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 expands the set of pseudo-selectors, as well as 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 became interested in you - you are welcome under the cut.

I got up before me the task of catching and processing 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 screen as a whole and moreover, scrolling there in all directions (vertically or / and horizontally).
I went to googol in search of something ready and to my surprise, I couldn’t find anything that would fully satisfy my needs, or the task was partially solved, like for example (to confess honestly, the idea of ​​extending pseudo-selectors steal from there, but this picture is not lying, right?), or the plugin was not at all about that. So I got up to the fact that I had to write my own, then I decided to share this business on a githaba, and later decided to write this article, rather than actually completing the first two points of my plan for becoming a celebrity , and doing it.
')
If you are not interested in the development process you poke - >> here << - and you immediately get to the place about where they distribute.

Prologue

If you do not know about writing plugins for jQuery, but really want to learn it - I strongly advise you to read this article first , everything is intelligible and understandable (it assumes at least basic knowledge of JS and JQ).



UPDATE 1 (10/13/2014)

- Rewritten part of the plugin, see the changes on the githaba.
- A problem has been detected that I cannot find a solution to:
If the parent element has no limiting factors (padding, border, overflow! = Visible), then the margin moves 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 the scrollHeight correctly determines the height from considering the margin of the child elements. As a result, such a parent element is defined as having a scrolling, since content height <height of the element itself.



UPDATE 2 (10/16/2014)

- As a solution to the above problem, the ability was added to set the viewport selector to be monitored, details on the githaba.
- The speed of the plugin is markedly increased




Let's get started


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 the scope?

Let's start, perhaps, with the definition of what is for a given context is the scope.
For my task, and I wrote a plugin, first of all, to meet their needs, the scope is the nearest parent with scrolling.

Unfortunately, there is no guaranteed and cross-browser way to determine if there is a scrollbar (at least I don’t know about this), and besides, I use a custom scrollbar, it can be adequately designed, but overflow: hidden; applies to the container overflow: hidden; and, as a result, the stock scrollbar is hidden.
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 scrolling.
We make this case in 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 this point on, we can use .is (": have-scroll") to determine if an element has scrolling (or prerequisites for its presence) or not.

Item Positioning

The next step is to determine the location of the block of interest relative to the scope.
The first thing that comes to mind:
 top = $( element ).offset().top; left = $( element ).offset().left; 
But no, .offset() positions any element relative to the upper left corner of the browser window, and the scope, as mentioned, is not always the browser window - does not fit, we dismiss.

The second thing that comes to mind:
 top = $( element ).position().top; left = $( element ).position().left; 
Also not, .position() positions the element only relative to the upper left corner of its nearest parent, it would seem that here, but consider the structure:
 <div id="viewport"> <div class="element"> <span></span> </div> </div> 
And the task is to track the span with respect to #viewport , in this case, .position() will position the span with respect to the .element that does not suit us, we drove on.

The solution is a method that will bypass all parents up the DOM tree, up to the scope of the given 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 is $( this ).get( 0 ).offsetTop , not $( this ).position().top ? - some will ask.
There are two reasons for this:
We add a similar method to determine the position from the left edge. Good thinking comes afterwards, you can combine them into one method, this will result in half the number of DOM tree walkthroughs, then finish it ..

So, now we know where, relative to the scope, the tracked object is located, but the data obtained will not change when scrolling, here we will be helped by .scrollTop() and .scrollLeft() , able to get the value of vertical and horizontal scrolling, respectively.
Moreover, we need to know the position of all sides of the monitored block and the size of the scope.
We make out 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 just easier to work with it afterwards. Everything, with the relative positioning of the block sorted out.

And yet, top or bottom

Immediately 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 strict minority.
Threshold is a parameter that sets indent from the edge of the scope; for some tasks, processing may be necessary a little earlier than the object enters the scope or a little later.

And a strict minority is indicated for the reason that if the borders 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 partial finding in the field of visibility, there is already a little more complicated, but still simple. just this time we are checking that the corresponding border has gone beyond the scope of the visibility 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 the verification of the remaining boundaries, everything is the same there, except that you can quote the code of the method that checks for finding an element within 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 good with them, nobody has forgotten about them.
So, we wrote all the methods in the object literal methods , what next to do the boom? Fan, 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 thing, remember I was talking about the threshold input parameter? And remember the standard parametric pseudo-selector :not(selector) ?
So, we can also use this structure to indicate treshold directly in the pseudo-selector:
 $( element ).is( ":in-viewport(10)" ); 
In this case, treshold will expand the scope by 10 px.

Tracking


Tax, pseudo-selectors have expanded, we should now have the whole thing in some convenient way to track something.
Ideally, of course, we would have to create our own event, but historically it’s so that with jQuery.event.special we are in extremely bad relationships, and .trigger() is, in my opinion, a so- .trigger() idea, not for this case - for sure. Therefore, we will have the most brutal function that calls the callBack function in a 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, we need to teach our brutal to "unscrew" ... to hell with these inventions, shorter stop tracking of an element, this is precisely the point that to track each individual element you create your own scroll event handler. If all callback functions are called from one scroll event handler, we will not be able to influence the set of monitored items without reinstalling the handler again.
Here we will be helped by the event namespace, if we produce .bind( "scroll.viewport") and .bind( "scroll") on the same element, and then .unbind( ".viewport") on the same element, then it is untied there will be only a scroll.viewport event scroll.viewport but not just a scroll .
And how does this help in the current task? - You ask, I answer, of course, you will have to fade out the namespace space (such is the tautology), but the goal will be achieved, so add a method that generates a random id. everything is just not even going to comment:
 generateEUID: function() { var result = ""; for( var i = 0; i < 32; i++ ) { result += Math.floor( Math.random() * 16 ).toString( 16 ); } return result; } 
Further, when initializing for each element, we push this .generated euid (element's unique id) into .data (), and when we hang up the scroll handlers, we create the namespace .viewport + EUID . And of course, the destructor, who iterates over the EUID of the set and removes unnecessary handlers, without affecting those that we still need. In the final version, we obtain:

Code tracker, the final version
 $.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 missed 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:

Method code ['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; } 



Fuff, that's all. Here is such a plugin, I got 301 lines.

Links


You can pick up the plugin from my github: https://github.com/xobotyi/jquery.viewport
How to use in the most detailed image is described in readme.

I sincerely hope that this article will benefit someone and will tell something new.
For this I will take my leave, with all the code, sleep, and lack of desire to write an article at 4 in the morning.

Ps whether to put a daw "training material"?

Source: https://habr.com/ru/post/240083/


All Articles