Динамическое меню c поддержкой touch move и mouse move на RevolveR

Наверняка многие из вас хотели бы научиться создавать красивые и подвижные меню в духе Android Java и Kotlin приложений. Скорее всего даже многие из вас ради этого уходили в области программирования отдельных приложений и были вынуждены осваивать инородный стек.

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

И так: Simple Dynamic Menu by RevolveR Labs.

image

Начинается все с верстки. Она должна быть семантической, легкой и современной.



<nav class="dynamic-menu">

	<ul>
		<li><a href="https://revolvercmf.ru">RevolveR Labs</a></li>
		<li><a href="#">Ultra newest solutions</a></li>
		<li><a href="#">The way of incredible</a></li>
		<li><a href="#">In search of the best</a></li>
		<li><a href="#">Progressive RevolveR frontends</a></li>
		<li><a href="#">Developing of new era</a></li>
	</ul>
</nav>

Мы используем стандартный маркированный список и HTML 5 в качестве элемента враппера, а чтобы сделать меню плавающим сразу пропишем CSS стили вытягивающие меню на за пределы экрана на всю ширину списка элементов и скроем все лишнее до области видимости:



.dynamic-menu {

	display: inline-block;
	text-align: center;
	overflow: hidden;
	margin: 0 auto;
	height: 3vw;
	width: 80%;

}

	.dynamic-menu ul {

		transition: all 2.5s ease-in-out;
		position: relative;
		list-style: none;
		width: 900vw;
		padding: 0;
		margin: 0;
		left: 0vw;

	}

		.dynamic-menu ul li {

			box-shadow: 0 0 0.1vw #333;
			border: .1vw dashed #fff;
			background: #a2a2a2;
			margin-bottom: 1vw;
			display: inline-block;
			border-radius: .2vw;
			margin-right: .5vw;
			padding: .2vw 1vw;
			background: #888;
			float: left;

		}

			.dynamic-menu ul li a {

				text-shadow: 0 0 0.2vw #fff;
				font: normal 2vw Helvetica;
				text-decoration: none;
				color: #006400;

			}

			.dynamic-menu ul li a:hover {

				text-decoration: underline;
				color: #674c2be0;

			}


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



Handler для desktop версии


Для работы хэндлера нам понадобится инициализировать RevolveR инстанс и использовать некоторое встроенное API работы с событиями:



let launch = RR.browser;

RR.menuMove = null;

if( !RR.isM ) {

	RR.event('.dynamic-menu ul', 'mousedown', (e) => {

		e.preventDefault();

		if( !RR.menuMove ) {

			RR.menuLeft = RR.curxy[0];

			RR.MenuMoveObserver = RR.event('body', 'mousemove', (e) => {

				e.preventDefault();

				RR.styleApply('.dynamic-menu ul', ['transition: all 0s ease']);

				RR.menuMove = true;

				RR.menuPosition = ( RR.menuLeft - RR.curxy[0] ) *-1;

				RR.styleApply('.dynamic-menu ul', ['left:'+ RR.menuPosition +'px']);

				RR.event('body', 'mouseup', (e) => {

					e.preventDefault();

					if( e.target.tagName === 'A' && !RR.touchFreeze ) {

						//R.loadURI(target.href, target.title);

						console.log(e.target.href);

						RR.touchFreeze = true;

						RR.menuMove = null;

					}

					void setTimeout(() => { 

						RR.menuMove = null;

					}, 50);

					void setTimeout(() => {

						if( !RR.menuMove ) {

							RR.styleApply('.dynamic-menu ul', ['left: 0px', 'transition: all 2.5s cubic-bezier(0.175, 0.885, 0.32, 1.275)']);

						}

					}, 2500);

				});

			});

		}

	});

}

Большинство необходимых event уже работают после запуска гетера RR.browser(). Это например отслеживание событий изменения размера окна и постоянно обновление положения указателя мыши RR.curxy.



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



RR.MenuMoveObserver является собой event стеком, который хранит MD5 hash события для того, чтобы можно было выключить часть хэндлера отвечающего за смену положения по оси X. Мы выключаем обсерверы каждый раз когда событие клик завершилось в пользу mouseup.



Готово. При нажатии на левую клавишу мыши, если держать кнопку утопленной будет происходить отслеживание положения курсора мыши по оси X, а обсервер обеспечит своевременное обновление положения left контейнера списка меню внутри враппера области видимости и лента меню начнет двигаться открывая не поместившиеся элементы списка.



Мобильный handler меню


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



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



if( RR.isM ) {

	RR.event('.dynamic-menu ul', 'touchstart', (e) => {

		e.preventDefault();

		RR.menuMove = null;

		RR.event('body', 'touchend', (e) => {

			e.preventDefault();

			if( !RR.menuMove ) {

				RR.touchFreeze = null;

				let target = e.changedTouches[0].target;

				if( RR.isO(RR.MenuMoveObserver) ) {

					for( i of RR.MenuMoveObserver ) {

						RR.detachEvent( i[ 2 ] );

					}

				}

				if( target.tagName === 'A' && !RR.touchFreeze ) {

					//R.loadURI(target.href, target.title);

					console.log(e.target.href);

					RR.touchFreeze = true;

					RR.menuMove = null;

				}

				void setTimeout(() => {

					if( !RR.menuMove ) {

						RR.styleApply('.dynamic-menu ul', ['left: 0px', 'transition: all 2.5s cubic-bezier(0.175, 0.885, 0.32, 1.275)']);
						//RR.animate('.dynamic-menu ul', ['left:0px:1000:wobble']);

					}

				}, 2500);

			}

		});

		if( !RR.menuMove ) {

			RR.menuLeft = e.changedTouches[0].screenX;

			RR.MenuMoveObserver = RR.event('body', 'touchmove', (e) => {

				e.preventDefault();

				RR.styleApply('.dynamic-menu ul', ['transition: all 0s ease']);

				RR.menuMove = true;

				RR.menuPosition = ( RR.menuLeft - e.changedTouches[0].screenX ) *-1; 

				RR.styleApply('.dynamic-menu ul', ['left:'+ RR.menuPosition +'px']);

					RR.event('body', 'touchend', (e) => {

						RR.menuMove = null;

					});

			});

		}

	});

}

В коде вы увидите небольшую разницу. Во первых event.target теперь не работает и нужно следить за сериями touch. Я добавил анимацию возвращения меню с эффектом easing и теперь меню само плавно возвращается в начальное положение спустя некоторое время бездействия с меню:



void setTimeout(() => {

	if( !RR.menuMove ) {

		RR.styleApply('.dynamic-menu ul', ['left: 0px', 'transition: all 2.5s cubic-bezier(0.175, 0.885, 0.32, 1.275)']);

	}

}, 2500);

Demo


Чтобы посмотреть как работает Dynamic Menu на базе библиотеки RevolveR вы можете пройти по ссылке.



Итог


Многие из вас используют готовые плагины или встраивают блоки верстки с меню гамбургера, однако динамические линейные меню ни чуть не хуже и позволяют экономить место в интерфейсах. Многим из вас наверняка показалось бы сложным реализовать такие меню из-за разницы в слушателях событий и неумения использовать флаги. Этот пример был направлен показать, что средствами обычного JavaScript можно не обладая какой-то запредельной квалификацией реализовывать такие же вещи, как и в удобных Android приложениях, а мастерская работа с анимациями позволяет создавать потрясные эффекты переходов и разные хуки событий на какие-либо срабатывания.

Комментарии 20

    +2

    Да тут просто ничего не работает, от слова совсем!


    Сразу вспомнилась история с перезапуском кинопоиска
      0
      Что именно не работает. Подробнее.
        0

        Ну на словах сложно объяснить. Тут видео нужно записывать экрана. Под рукой такого софта нет.


        Попробую объяснить на словах: сдвигаю меню влево примерно на 100-200px, далее бросаю курсор и снова двигаю меню на столько же, когда я третий раз пытаюсь передвинуть меню, после бросания курсора оно сразу уезжает на начальную позицию. Предполагается что меню не должно езжать пока курсор висит над меню. А если пунктов будет не 5 а 50?


        Но я по стилю кода вижу, что баги будут 100%. Например, если вы создаете setTimeout но не как не используете clearTimeout. Если вы рассчитываете, что проверка if( !RR.menuMove ) спасет от повторного вызова, то вы глубоко ошибаетесь!


        Хороший тон для каждого setTimeout писать где то рядышком clearTimeout чтобы если вдруг нужно отменить действие, это можно было сделать легко быстро!


        Пример из мира React

          0

          Спасибо. Как то не учел лихорадочных и очень длинные списки. Будет время — добавлю ещё один флаг блокировки возврата. Если void стоит таймер типа setTimeout уничтожается после первого и единственного тика. Если void добавить к setIntetval, то таймер потеряет id и его невозможно будет остановить. Флагом просто проще сделать, если блокировку отката на hover повешать.

            0

            Вы ничего не поняли! Вот такой код:
            void setTimeout(() => {
            это плохо! Нужно писать:
            const timer = setTimeout(() => {
            и где-то должен быть возможен вызов
            clearTimeout(timer)


            Иначе у вас будет бесконечное число сайд эффектов, и вы только и дальше будите их плодить.

            0
            Вот чуть доработанный код. Теперь у рыпнутых нет возврата меню когда они рыпают мышью, пальцами и в общем-то можно применять к длинным спискам. Если подскажете что можно улучшить — обновлю в публикацию.

            let launch = RR.browser;
            
            RR.menuMove = null;
            
            let turnBack = () => {
            
            	return void setTimeout(() => {
            
            		if( !RR.menuMove && RR.turnBack && RR.allowReturn ) {
            
            			RR.styleApply('.dynamic-menu ul', ['left: 0px', 'transition: all 2.5s cubic-bezier(0.175, 0.885, 0.32, 1.275)']);
            
            		}
            
            	}, 2500);
            
            }
            
            if( !RR.isM ) {
            
            	RR.event('.dynamic-menu ul', 'click', (e) => {
            
            		e.preventDefault();
            
            	});
            
            	RR.event('.dynamic-menu ul', 'mousedown', (e) => {
            
            		e.preventDefault();
            
            		if( !RR.menuMove ) {
            
            			RR.menuLeft = RR.curxy[0];
            
            			RR.touchFreeze = null;
            
            			RR.MenuMoveObserver = RR.event('body', 'mousemove', (e) => {
            
            				e.preventDefault();
            
            				RR.styleApply('.dynamic-menu ul', ['transition: all 0s ease']);
            
            				RR.menuMove = true;
            
            				RR.menuPosition = ( RR.menuLeft - RR.curxy[0] ) *-1;
            
            				RR.styleApply('.dynamic-menu ul', ['left:'+ RR.menuPosition +'px']);
            
            				RR.event('body', 'mouseup', (e) => {
            
            					e.preventDefault();
            
            					for( i of RR.MenuMoveObserver ) {
            
            						RR.detachEvent(i[ 2 ]);
            
            					}
            
            					if( e.target.tagName === 'A' && !RR.touchFreeze ) {
            
            						//R.loadURI(target.href, target.title);
            
            						console.log(e.target.href);
            
            						RR.touchFreeze = true;
            
            						RR.menuMove = null;
            
            					}
            
            					void setTimeout(() => { 
            
            						RR.menuMove = null;
            
            					}, 50);
            
            					RR.event('.dynamic-menu ul', 'mouseenter', () => {
            
            						RR.turnBack = null;
            
            						RR.event('.dynamic-menu ul', 'mouseleave', () => {
            
            							RR.turnBack = true;
            
            							RR.allowReturn = true;
            
            							turnBack();
            
            						});
            
            					});
            
            				});
            
            			});
            
            		}
            
            	});
            
            }
            
            if( RR.isM ) {
            
            	RR.event('.dynamic-menu ul', 'touchstart', (e) => {
            
            		e.preventDefault();
            
            		RR.menuMove = null;
            
            		RR.turnBack = null;
            
            		RR.allowReturn = true;
            
            		RR.event('body', 'touchend', (e) => {
            
            			e.preventDefault();
            
            			if( !RR.menuMove ) {
            
            				RR.touchFreeze = null;
            
            				let target = e.changedTouches[0].target;
            
            				if( RR.isO(RR.MenuMoveObserver) ) {
            
            					for( i of RR.MenuMoveObserver ) {
            
            						RR.detachEvent( i[ 2 ] );
            
            					}
            
            				}
            
            				if( target.tagName === 'A' && !RR.touchFreeze ) {
            
            					//R.loadURI(target.href, target.title);
            
            					console.log(e.target.href);
            
            					RR.touchFreeze = true;
            
            					RR.menuMove = null;
            
            				}
            
            			}
            
            		});
            
            		if( !RR.menuMove ) {
            
            			RR.menuLeft = e.changedTouches[0].screenX;
            
            			RR.MenuMoveObserver = RR.event('body', 'touchmove', (e) => {
            
            				void setInterval(() => {
            
            					if(RR.menuMove) {
            
            						RR.allowReturn = null;
            
            					} 
            					else {
            
            						RR.allowReturn = true;
            
            					}
            					
            
            				}, 300);
            
            				e.preventDefault();
            
            				RR.turnBack = null;
            
            				RR.event('.dynamic-menu ul, body', 'touchend', () => {
            
            					RR.turnBack = true;
            
            					setTimeout(() => {
            
            						RR.allowReturn = true;
            
            					}, 300);
            
            					turnBack();
            
            				});
            
            				RR.styleApply('.dynamic-menu ul', ['transition: all 0s ease']);
            
            				RR.menuMove = true;
            
            				RR.menuPosition = ( RR.menuLeft - e.changedTouches[0].screenX ) *-1; 
            
            				RR.styleApply('.dynamic-menu ul', ['left:'+ RR.menuPosition +'px']);
            
            					RR.event('body', 'touchend', (e) => {
            
            						RR.menuMove = null;
            
            					});
            
            			});
            
            		}
            
            	});
            
            }
            
              0

              Это называется спагетти код! У меня было пару программистов, которые писали подобные вещи. Они не хотели учиться. Уволены. Мой вам совет, выкиньте на помойку свои знания по Javascript и начните учиться современным методам программирования. То что вы пишите, писали в начале 2000 годов. Сейчас уже есть куча всего готового и удобного. А перед тем как изобретать свой велосипед нужно сначала изучить все остальные велосипеды: Typescript, React, Vue, Angular — список можно продолжать бесконечно.

        0

        Это такая реклама вашей штуки под названием РевольвеР? Вы сами же пробовали открыть свои демо? И сайт cmf дальше? С телефона тоже?


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


        В данном случае там будет немой вопрос "за что?"… Не все разделяют вашу любовь к странным штриховке на прелоадере, думаю. А когда 3 прелоадера вместе вдруг появляются, это блин вообще жесть что творится.


        По существу: вы сделали меню, которое можно было сделать одним css (overflow-x: scroll и если надо то можно ещё скроллбар убрать потом). А ещё ваше творение выглядит как поделка школьника, уж простите (

          0
          Какой у вас телефон? Я смартфон использую с Edge. У меня все работает. Недавно перестал обращать внимание на косяки FireFox из-за частых багов самого браузера и ориентируюсь в основном на Chromium браузеры. Preloader к статье не относится. Он просто версткой сделан и CSS и любой желающий может его убрать или переделать.
            0
            Меню получилось очень плохим. Совсем. Совершенно не пригодно ни для чего. Идеальный антипаттерн по всем параметрам.

            — я не могу листать его боковым скроллом на тачпаде ноутбука
            — листать горизонтально меню на десктопе зажав кнопку это жесть с точки зрения интерфейса и удобства
            — поведение на мобиле это не свайп (который совсем другую физику имеет), а именно таскание, что ну абсолютно не удобно — я не могу быстро провести пальцем и прокрутить
            — на десктопе легче сделать стрелки или любой другой аналог слайдера выбрать (да даже просто нативный скролл лучше в 100500 раз)
            — вы наоверинжинирили длиннющий JS когда вообще можно было обойтись несколькими строками CSS (и то если думать про IE где надо скроллбар спрятать, а на хромиум так вообще одним стилем)
            — при этом оверинжиниринге вы не предусмотрели 100500 в 100500 степени условий, за который юзеры будут вас не любить (попробуйте нажать правой кнопкой в любом месте вашего меню, а потом в любое другое место — не могу проверить с мобилы, но я уверен что вы дальше будете водить мышкой во все стороны и меню будет крутиться, хотя кнопку вы уже не жмете)
            — да только за код написанный через строчку я бы джуна как минимум попросил так больше не делать, но вообще настройки вашего линтера я реально не хочу знать — мусье явно знает толк в извращениях
            — страница с демо — блок меню с фоном #888, с темно зеленым шрифтом, и блок и шрифт с тенями, да еще у блока белая обводка dashed?! Не, на вкус и цвет, само собой, не хочу быть занудой и придираться к вкусовщине… Но блин!!!
            — раз затронул стили — border толщина заданная в vh? Это зачем вообще?
            — посмотрите пример с табами (https://materializecss.com/tabs.html) — второй блок как раз скроллится как и ваш, но выглядит по людски и управлять им легче. Я молчу уж что читсый CSS без костылей и забытых вами на каждом шагу листнеров (уже выше написали)

            Ну и оффтом (статья правда не про прелоадеры была) — вот как выглядит ваша страница cmf в хроме на мобиле: image
              0

              Спасибо за ценное замечание про touchpad. У меня было ориентировано на touch экраны и полноценную мышь. Хотя я вообще то на touchpad всю Ghotic 3 прошел с двумя мечами и луком.


              Я бы свами согласился, что на CSS лучше, если бы вы показали годный рабочий пример.


              Что до дизайна сайта моей компании — это к статье не относится. В chrome под Android у меня все прекрасно загружается и прелоадер исчезает. Дальше при промотке страницы срабатывает Lazy Load для изображений, как и задумано.


              Критиковать же дизайн, который я для примера статьи не делал даже, вообще не стоит. Стили остаются на усмотрение разработчика.

          0
          Полезно однако)
            0
            Какой у вас прекрасный удивительный сайт! Три разных лоадера!
              0
              Где вы увидели три разных лоадера? Один и тот же эффект и код используется для DOMContentLoaded и fetch запросов. И тот же самый SVG для lazy load картинок.
                0
                Как бы вам объяснить… У вас плохо все, абсолютно все.
                  0

                  Это вы мне настроение перед сном испортить пытаетесь или есть что то по делу. В данном случае, если хотите покритиковать, то прошу не отвлекаться от темы статьи или хотя бы быть конструктивным. Уже хватает одного, который первым комментарием выкладывает прелые мемы с мексиканцами.

                    0
                    Я вот расписал выше подробнее, но ощущение, что если человек сам этого не видит, то ваш ответ был более логичный… )))
                      0

                      Господин мазафаков, ой, вмказакоффЪ. Какой никнейм сложновоспринимаемый. Я вам ответил выше. Предлагаю схлопнуться в одну веточку, а то полезное осязаемое пользователями пространство транжириться на 80% не относящимся к теме вопросам.

                        0
                        Сударь, вы неизлечимы :)

                        Раз медицина тут бессильна, то и я не стану мешать эволюции — авось сама разберется.
                          0
                          Вам нормальный фидбек выше дали, вы от него как то мастерски уклонились и зачем то просите ещё

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое