jQuery.viewport или как я искал элементы на экране

в 3:45, , рубрики: javascript, jquery, jquery plugins, positioning, viewport, Веб-разработка, позиционирование

jQuery.viewport или как я искал элементы на экране
Равно как у каждой девушки должно быть «маленькое черное платьице», у каждого front-end разработчика должен быть «маленький черный плагинчик»… как-то не очень звучит, пусть будет «маленький функциональный плагинчик», так о чем это я, я это о том, что хочу одним таким поделиться.

Представленный плагин позволяет определять положение какого-либо элемента/набора элементов, относительно области просмотра. Функционально, он расширяет набор псевдо-селекторов, а так же добавляет трекер элементов.

Так же, под катом, я расскажу о процессе написания плагина, с какими трудностями столкнулся и т.д., если я Вас заинтересовал — милости прошу под кат.

Встала давеча передо мной задача, ловить и обрабатывать элементы в момент их появления в области видимости, причем, область видимости — не всегда весь экран, иногда это блок с overflow: auto;, а иногда надо обрабатывать элементы только когда они появятся на экране целиком и более того прокрутка там во все стороны( вертикально или/и горизонтально ).
Пошел я значится в гугол на поиски чего-либо готового и к моему удивлению, ничего, что полностью удовлетворяло бы моим запросам, я не нашел, либо задача решалась частично, как например тут (признаться честно, идею с расширением псевдо-селекторов стырил оттуда, но ведь эта картинка не врет, правда?), либо вовсе плагин был не про то. Вот я и встал перед фактом что надо писать свое, потом решил поделиться этим делом на гитхабе, а еще позже решил написать эту статью, чем собственно, успешно выполнив первые два пункта моего плана по становлению знаменитостью, и занимаюсь.

Если Вам не интересен процесс разработки тыкаете ->>вот сюда<<- и попадаете сразу к месту о том, где раздают.

Пролог

Если Вы не знаете про написание плагинов для jQuery, но очень хотите этому научиться — крайне советую, для начала, прочитать эту статью, все доходчиво и понятно (предполагает наличие хотя бы базовых знаний JS и JQ).

Приступим-с

Итак, задача: надо как-то определять положение элемента относительно области видимости, причем в зависимости от контекста, областью видимости может быть не только окно браузера, но и более мелкие элементы имеющие прокрутку.

Что есть область видимости?

Начнем, пожалуй, с определения того, что же для данного контекста является областью видимости.
Для моей задачи, а писал я плагин, в первую очередь, для удовлетворения своих нужд, областью видимости является ближайший родитель, имеющий прокрутку.
jQuery.viewport или как я искал элементы на экране
К сожалению, нет гарантированного и кросс-браузерного способа определить наличие полосы прокрутки (по крайней мере я о таком не знаю), да и к тому же я использую кастомный скроллбар, его можно адекватно оформлять, но, к контейнеру применяется overflow: hidden; и, как следствие, стоковый скроллбар скрывается.
Но выход есть, можно сравнивать высоту контейнера( containerElem.offsetHeight ) и высоту его содержимого( containerElem.scrollHeight ) и в случае, если высота содержимого превышает высоту контейнера, то, скорее всего, а для моих проектов — всегда, такой контейнер имеет прокрутку.
Оформляем это дело в код:

(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 );

С этого момента мы можем использовать .is( ":have-scroll" ) для определения имеет ли элемент прокрутку (или предпосылки к ее наличию) или нет.

Позиционирование элемента

Следующий этап — определение местоположения интересующего нас блока относительно области видимости.
Первое что приходит на ум:

top = $( element ).offset().top;
left = $( element ).offset().left;

Но нет, .offset() позиционирует любой элемент относительно левого верхнего угла окна браузера, а область видимости, как говорилось, не всегда окно браузера — не подходит, отметаем.

Второе что приходит на ум:

top = $( element ).position().top;
left = $( element ).position().left;

Тоже нет, .position() позиционирует элемент только относительно левого верхнего угла своего ближайшего родителя, казалось бы вот оно, но рассмотрим структуру:

<div id="viewport">
	<div class="element">
		<span></span>
	</div>
</div>

А задача — отслеживать именно span относительно #viewport, в таком случае, .position() будет позиционировать span относительно .element что нам не подходит, погнали дальше.

Решением будет свой метод, который будет обходить всех родителей вверх по дереву DOM, вплоть до области видимости данного контекста.

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 );
}

Почему же $( this ).get( 0 ).offsetTop, а не $( this ).position().top? — спросят некоторые.
Причин на то две:

  • .position() учитывает смещение (top, bottom, left, right), не учитывает margin-ы, а поэтому придется использовать .css('margin-top') и потом еще .css('margin-bottom')
  • эти самые .css('margin-top') и .css('margin-bottom') возвращают значение в виде 13px, то есть надо еще и parseInt( str, 10) делать, чтобы производить базовые математические операции, вариант с вычитанием высот с учетом разных отступов( .innerHeight(), .outerHeight, .outerHeight(true) ) я даже не рассматриваю, ибо они могут быть заданы несимметрично, а нам это важно.
    В конечном итоге, с учетом всех этих излишних операций, вариант с использованием $( this ).position().top работает в полтора-два раза медленнее нежели вариант с this.offsetTop на моем разогнанном i7, а юзер может сидеть на каком-нибудь пеньке, и страшно представить во что это вообще выльется.

Дописываем аналогичный метод для определения положения от левого края. Хорошая мысля приходит опосля, их же можно в один метод объединить, так получится в два раза меньшее количество обходов DOM дерева, потом доделаю..

Итак, теперь мы знаем где, относительно области видимости, расположен отслеживаемый объект, но полученные данные, при скроллинге меняться не будут, тут нам помогут .scrollTop() и .scrollLeft(), умеющие получать значение вертикального и горизонтального скроллинга соответственно.
Более того, нам необходимо знать положение всех сторон отслеживаемого блока и размеры области видимости.
Оформляем в очередной метод:

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()	// првая граница области видимости
	};
}

Возвращает данная функция хэш-таблицу, с ней просто удобнее работать впоследствии. Все, с относительным позиционированием блока разобрались.

И все-таки, сверху или снизу

Сразу к коду:

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;
}

Тут, я думаю, все ясно, единственное уточню по поводу threshold и строгого меньшинства.
Threshold — параметр задающий отступ от края области видимости, для некоторых задач может быть необходимой обработка немного раньше, чем объект войдет в область видимости или немного позже.
jQuery.viewport или как я искал элементы на экране
А строгое меньшинство указано по причине того, что если границы совпадают, то элемент еще не пересек границу и пока вписывается в зону видимости, а значит находится внутри.
Так же, для частичного нахождения в области видимости, тут уже чуть сложнее, но по прежнему просто. просто на этот раз уже проверяем что соответствующая границы вышла за пределы области видимости а противоположная еще находится в ее пределах.

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;
}

Нет смысла описывать проверку остальных границ, там все аналогично, можно разве что привести код метода проверяющего нахождение элемента внутри области видимости:

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;
}
А как же селекторы?

С ними все хорошо, никто про них не забыл.
Итак, прописали мы все методы в объектном литерале methods, че дальше делать бум? Веерно, расширять литерал псевдо-селекторов:

	"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 );
	}
} );

Стоит отметить одну фишку, помните я говорил про входной параметр threshold? А помните стандартный параметрический псевдо-селектор :not(selector)?
Так вот, мы тоже можем использовать такую конструкцию, для указания трешолда прямо в псевдо-селекторе:

$( element ).is( ":in-viewport(10)" );

В данном случае трешолд будет расширять область видимости на 10 px.

Трекинг

Такс, псевдо-селекторы расширили, надо бы теперь все это дело как-то удобным образом отслеживать что ли.
В идеале, конечно надо бы создать свое событие, но так уж исторически сложилось, что с jQuery.event.special мы в крайне плохих отношениях, а .trigger() — на мой взгляд так себе затея, не для данного случая — точно. Поэтому, у нас будет самая что ни на есть брутальная функция, которая не менее брутальным образом вызывает callBack функцию.

Код трэкера

$.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!
На самом деле — нет, надо бы научить нашего брутала «откручиваться»… к черту эти выдумывания слов, короче прекращать отслеживание того или иного элемента, с этим как-раз связан тот момент, что для отслеживания каждого отдельного элемента создается свой обработчик события scroll. В случае если все callback функции будут вызываться из одного обработчика события scroll, у нас не будет возможности влиять на набор отслеживаемых элементов, не переустанавливая обработчик заново.
Тут нам помогут пространства имен событий, если произвести .bind( "scroll.viewport") и .bind( "scroll") на один и тот же элемент, а затем .unbind( ".viewport") на тот же элемент, то отвязан будет только обработчик события scroll.viewport но, не просто scroll.
И как же это поможет в текущей задаче? — спросите Вы, отвечаю, придется конечно подзасрать пространство пространства имен(вот такая тавтология), но цель будет достигнута, итак, добавляем метод генерирующий случайный id. тут все просто даже комментировать не буду:

generateEUID: function() {
	var result = "";
	for( var i = 0; i < 32; i++ ) {
		result += Math.floor( Math.random() * 16 ).toString( 16 );
	}

	return result;
}

далее, при инициализации для каждого элемента, пушим в .data() этот самый, сгенерированный euid (element's unique id), а когда навешиваем обработчики скролла, то создаем пространство имен .viewport + EUID. Ну и конечно же деструктор, который перебирает EUID набора и удаляет ненужные обработчики, не задевая те которые нам еще понадобятся. В конечном варианте получаем:

Код трэкера, финальный вариант

$.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 ] ) ] );
			} );
		}
	} );
};

Один метод я пропустил, оставив на самый конец, в силу того что это тупо триггер с тучей ветвлений, зависящих от настроек которые были переданы при инициализации. Правда есть пара вещей, которые стоит отметить:

  • Логика, по которой определялось относительное положение элемента, дублирована в данном методе для того, чтобы не делать лишних вычислений и выборок, а лишь один раз получить все нужные данные и уже с ними работать, кода больше но зато и выборок на 8 меньше.
  • Состояния above и below любого вида, имеют приоритет над left и right.
    jQuery.viewport или как я искал элементы на экране
  • В случае если активировано смешние состояний ("allowMixedStates": true), цельные состояния имеют приоритет над смешанными.
    jQuery.viewport или как я искал элементы на экране
Код метода methods['getState']

getState: function( options ) {
	var settings = $.extend( {
		"threshold": 0,
		"allowPartly": false,
		"allowMixedStates": false
	}, options );

	var pos = methods['getElementPosition'].call( this );

	if( !pos ){
		return 'inside';
	}

	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;
	var state = '';

	if( !_above && !_below && !_left && !_right ) {
		state = 'inside';
	} else {
		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( _partlyAbove && !_partlyBelow ) {
				if( settings.allowMixedStates && ( _partlyLeft || _partlyRight ) ) {
					state = _partlyLeft ? 'partly-above partly-left' : 'partly-above partly-right';
				} else if( settings.allowMixedStates && ( _left || _right ) ) {
					state = _left ? 'left partly-above' : 'right partly-above';
				} else {
					state = 'partly-above';
				}
			} else if( _partlyBelow && !_partlyAbove ) {
				if( settings.allowMixedStates && ( _partlyLeft || _partlyRight ) ) {
					state = _partlyLeft ? 'partly-below partly-left' : 'partly-below partly-right';
				} else if( settings.allowMixedStates && ( _left || _right ) ) {
					state = _left ? 'left partly-below' : 'right partly-below';
				} else {
					state = 'partly-below';
				}
			} else if( _partlyLeft && !_partlyAbove && !_partlyBelow && !_partlyRight ) {
				if( settings.allowMixedStates && ( _above || _below ) ) {
					state = _above ? 'above partly-left' : 'below partly-left';
				} else {
					state = 'partly-left';
				}
			} else if( _partlyRight && !_partlyAbove && !_partlyBelow && !_partlyLeft ) {
				if( settings.allowMixedStates && ( _above || _below ) ) {
					state = _above ? 'above partly-right' : 'below partly-right';
				} else {
					state = 'partly-right';
				}
			}
		}
		if( state == '' ) {
			if( _above && !_below ) {
				if( settings.allowMixedStates && ( _left || _right ) ) {
					state = _left ? 'above-left' : 'above-right';
				} else {
					state = 'above';
				}
			} else if( _below && !_above ) {
				if( settings.allowMixedStates && ( _left || _right ) ) {
					state = _left ? 'below-left' : 'below-right';
				} else {
					state = 'below';
				}
			} else if( _left && !_above && !_below && !_right ) {
				state = 'left';
			} else if( _right && !_above && !_below && !_left ) {
				state = 'right';
			} else {
				state = 'outside';
			}
		}
	}

	return state;
}

Фьюффф, вот вроде и все. Вот такой вот плагин, на 301 строку у меня получился.

Ссылки


Забрать плагин можно с моего гитхаба: https://github.com/xobotyi/jquery.viewport
Как пользоваться подробнейшим образом описано в readme.

Искренне надеюсь, что данная статья кому-то принесет пользу и расскажет что-нибудь новое.
За сим откланяюсь, всем кода, сна и отсутствия желания писать статью в 4 ночи.

P.s. стоит ли ставить галку «обучающий материал»?

Автор: xobotyi

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js