patternjavascriptModerate
Handling Hover Events on a Touch Screen
Viewed 0 times
handlingeventsscreenhovertouch
Problem
A website I have designed uses a nav menu that shows submenus on
The big issue I was facing (and that many other people seem to have also faced) was the hover-based nav menu: it works great in a mouse environment, but on touch devices, there is no reliable way to trigger the hover, making the page difficult to use.
The goal is this:
My team is not willing to sacrifice the hover effect on mouse-based machines. They like it and do not want to make the menus click based on all devices. I agree with this.
After looking around the net, I couldn't find any single solution for my problem. I put a few of them together and developed something that has tested well on Android, getting exactly the functionality I wanted. Is there anything that can be improved and/or do you see any problems with this approach?
```
jQuery(document).ready(function() {
var touched=false;
jQuery(".nav").on('touchstart', 'li .has_children', function (e) { touched=true; });
jQuery("html").on('mousemove', function (e) { touched=false; });
jQuery("html").on('click', updatePreviousTouched );
jQuery(".nav").on('click', 'li .has_children', function (e) {
updatePreviousTouched(e);
if( touched ) {
if (jQuery(this).data('clicked_once')) {
jQuery(this).data('clicked_once', false);
return true;
} else {
:hover. The initial site did not use any responsive design: it targeted only the desktop environment. I am now using responsive-design techniques to target mobile devices and tablets, many of which are touch based rather than mouse based. The big issue I was facing (and that many other people seem to have also faced) was the hover-based nav menu: it works great in a mouse environment, but on touch devices, there is no reliable way to trigger the hover, making the page difficult to use.
The goal is this:
- When a menu is hovered by a mouse, show the sub-menu.
- When a menu is clicked with a mouse, open the link of the anchor tag.
- The first time a menu is clicked by touch, show the sub menu.
- The second time a menu is clicked by touch, open the link of the anchor tag.
- Switch functionality seamlessly for tablets that also have mouse devices attached.
My team is not willing to sacrifice the hover effect on mouse-based machines. They like it and do not want to make the menus click based on all devices. I agree with this.
After looking around the net, I couldn't find any single solution for my problem. I put a few of them together and developed something that has tested well on Android, getting exactly the functionality I wanted. Is there anything that can be improved and/or do you see any problems with this approach?
```
jQuery(document).ready(function() {
var touched=false;
jQuery(".nav").on('touchstart', 'li .has_children', function (e) { touched=true; });
jQuery("html").on('mousemove', function (e) { touched=false; });
jQuery("html").on('click', updatePreviousTouched );
jQuery(".nav").on('click', 'li .has_children', function (e) {
updatePreviousTouched(e);
if( touched ) {
if (jQuery(this).data('clicked_once')) {
jQuery(this).data('clicked_once', false);
return true;
} else {
Solution
First of all, lets clean up your code to really leverage the power of
Notice the following:
Now that we've got your original code all pretty and efficient, I'll offer a different alternative that should be much more lightweight.
Based on your currently use CSS to do the
Here is a working example:
This CSS that was this...
Becomes this...
And here is your working jQuery...
This should manage what you want in a much cleaner way:
I separated out a couple of actions into reusable functions, and separated events to avoid
I know this code is longer than the original, but guaranteed it will run faster and be easier to maintain. Hope it helps.
.on():$(function() {
var touched = false,
previous_touched;
function updatePreviousTouched(e){
if(typeof previous_touched !== 'undefined' && previous_touched !== null && !previous_touched.is($(e.target))){
previous_touched.data('clicked_once', false);
}
previous_touched = $(e.target);
}
$(".nav").on({
touchstart:function(e) {
touched=true;
},
click:function(e);
var $this = $(this);
updatePreviousTouched(e);
if(touched) {
if ($this.data('clicked_once')) {
$this.data('clicked_once', false);
return true;
} else {
e.preventDefault();
$this.trigger("mouseenter").data('clicked_once', true);
}
}
touched = false;
}
},'li .has_children');
$("html").on({
mousemove:function(e){
touched=false;
},
click:updatePreviousTouched
});
});Notice the following:
- Object use of
.on()rather than mere string use (only one binding instead of multiple)
- caching where appropriate
- variables and functions declared at top
Now that we've got your original code all pretty and efficient, I'll offer a different alternative that should be much more lightweight.
Based on your currently use CSS to do the
:hover piece. Instead, you may consider making that CSS a class, and then dynamically add / remove the class in the code. This maintains the speedy leveraging of CSS while avoiding dealing with the CSS and JS events fighting each other over what wins out. I have done something like this on a number of projects and it seems to work pretty well. The key is the custom event; by separating this out, you can control when it is fired rather than rely on some crazy if logic to determine whether it should be fired or not.Here is a working example:
This CSS that was this...
.header-nav-menu ul li:hover > ul { display: inline-block; }Becomes this...
.header-nav-menu ul li > ul.MenuActive { display: inline-block; }And here is your working jQuery...
$(function(){
var menuActive = false,
touched = false,
$nav = $('.nav');
function removeActive(callback){
$nav.find('.MenuActive').removeClass('MenuActive');
callback();
}
function newActive($this,menu){
removeActive(function(){
$this.next().addClass('MenuActive').queue(function(){
if(menu){
menuActive = true;
touched = false;
} else {
touched = true;
}
}).dequeue();
});
}
$nav.on({
touchstart:function(e){
e.stopPropagation();
newActive($(this),touched);
},
mouseenter:function(){
newActive($(this),true);
},
click:function(e){
e.preventDefault();
if(menuActive){
$(this).trigger('trueClick',e.target);
}
},
trueClick:function(e,$target){
$(this).parents('.nav').trigger('mouseleave');
window.location.href = $target;
}
},'li .has_children').on('mouseleave',function(){
removeActive(function(){
menuActive = false;
touched = false;
});
});
$('html').on('touchstart',function(e){
if(menuActive){
$nav.trigger('mouseleave');
}
});
});This should manage what you want in a much cleaner way:
menuActivevariable defaults tofalse, and is only set totruewhen submenu is open (by eithertouchstartormouseenter)
- actual click action is prevented, and verification is done to determine if menu is appropriately active
- if menu is active, custom event is triggered to go to the link's target
- touch event is not bubble up to
htmlby use ofe.stopPropagation();
- if user touches somewhere on the screen that is not the submenu, it will close the submenu and set
menuActiveto false
I separated out a couple of actions into reusable functions, and separated events to avoid
if checking. This code executes much faster than the original, and more importantly it is bulletproof. There is true separation of touch vs hover events, the show / hide of the menu still leverages CSS, and it accounts for browser inconsistencies (example: the reason for the use of the callback is because Safari 5.1 has a 300ms delay between adding a class and it displaying onscreen).I know this code is longer than the original, but guaranteed it will run faster and be easier to maintain. Hope it helps.
Code Snippets
$(function() {
var touched = false,
previous_touched;
function updatePreviousTouched(e){
if(typeof previous_touched !== 'undefined' && previous_touched !== null && !previous_touched.is($(e.target))){
previous_touched.data('clicked_once', false);
}
previous_touched = $(e.target);
}
$(".nav").on({
touchstart:function(e) {
touched=true;
},
click:function(e);
var $this = $(this);
updatePreviousTouched(e);
if(touched) {
if ($this.data('clicked_once')) {
$this.data('clicked_once', false);
return true;
} else {
e.preventDefault();
$this.trigger("mouseenter").data('clicked_once', true);
}
}
touched = false;
}
},'li .has_children');
$("html").on({
mousemove:function(e){
touched=false;
},
click:updatePreviousTouched
});
});.header-nav-menu ul li:hover > ul { display: inline-block; }.header-nav-menu ul li > ul.MenuActive { display: inline-block; }$(function(){
var menuActive = false,
touched = false,
$nav = $('.nav');
function removeActive(callback){
$nav.find('.MenuActive').removeClass('MenuActive');
callback();
}
function newActive($this,menu){
removeActive(function(){
$this.next().addClass('MenuActive').queue(function(){
if(menu){
menuActive = true;
touched = false;
} else {
touched = true;
}
}).dequeue();
});
}
$nav.on({
touchstart:function(e){
e.stopPropagation();
newActive($(this),touched);
},
mouseenter:function(){
newActive($(this),true);
},
click:function(e){
e.preventDefault();
if(menuActive){
$(this).trigger('trueClick',e.target);
}
},
trueClick:function(e,$target){
$(this).parents('.nav').trigger('mouseleave');
window.location.href = $target;
}
},'li .has_children').on('mouseleave',function(){
removeActive(function(){
menuActive = false;
touched = false;
});
});
$('html').on('touchstart',function(e){
if(menuActive){
$nav.trigger('mouseleave');
}
});
});Context
StackExchange Code Review Q#38239, answer score: 14
Revisions (0)
No revisions yet.