Tuning site navigation for mobile devices
Making design responsive means not only certain representations on different screens, but also particular behavior on different devices. Usually on mobile devices mouse is not available, keyboard with navigation keys is out of reach. So we are short of controls to provide a useful navigation.
Image viewer
Let’s take image viewer. User taps a thumbnail and gets an overlay with the image. We don’t have much screen space on the smartphone. So the image goes fullscreen. Well, how is the user supposed to close the overlay? On desktop we would place close button on the frame. Besides, we would use Esc and any click outside the frame. Here on mobile we just have this fullscreen image and no keyboard. If we place “close” button on the image, it will hide a part of image. Let’s add to the list “previous” and “next” buttons, all big enough to tap easily by a finger. So what are we to do?
On-demand navigation
We display the navigation buttons when user taps the image. They fade in, stay available for a few seconds and fade out.
Touch gestures navigation
As an alternative navigation we can use swipe gesture. W3C specification among others defines following touch events:
- touchstart - triggered when a touch is initiated. Mouse equivalent - mouseDown
- touchmove - triggered when a touch moves. Mouse equivalent - mouseMove
- touchend - triggered when a touch ends. Mouse equivalent - mouseUp.
For example, you can obtain on gesture start position like that:
document.addEventListener('touchstart', function( event ) {
// At least one finger touch
if ( event.touches.length > 0 ) {
var startX = event.touches[0].pageX;
}
}, false);
Note that for some reason under iOS touchend event removes the current touch from the event.touches array. Instead, we have to look inside the event.changedTouches.
As you see there is no event like “swiped left”, “swiped right”. However we can find out where the movement was directed comparing event target’s start and end position coordinates.
getDirection = function() {
return endX > startX ? "prev" : "next";
}
Let’s now encapsulate the logic handling swipe gesture into a separate object:
/*global window */
(function( window ){
"use strict";
var document = window.document,
screen = window.screen,
touchSwipeListener = function( options ) {
// Private members
var track = {
startX: 0,
endX: 0
},
defaultOptions = {
moveHandler: function( direction ) {},
endHandler: function( direction ) {},
minLengthRatio: 0.3
},
getDirection = function() {
return track.endX > track.startX ? "prev" : "next";
},
isDeliberateMove = function() {
var minLength = Math.ceil( screen.width * options.minLengthRatio );
return Math.abs(track.endX - track.startX) > minLength;
},
extendOptions = function() {
for (var prop in defaultOptions) {
if ( defaultOptions.hasOwnProperty( prop ) %26%26 !options[ prop ] ) {
options[ prop ] = defaultOptions[ prop ];
}
}
},
handler = {
touchStart: function( event ) {
// At least one finger has touched the screen
if ( event.touches.length > 0 ) {
track.startX = event.touches[0].pageX;
}
},
touchMove: function( event ) {
if ( event.touches.length > 0 ) {
track.endX = event.touches[0].pageX;
options.moveHandler( getDirection(), isDeliberateMove() );
}
},
touchEnd: function( event ) {
var touches = event.changedTouches || event.touches;
if ( touches.length > 0 ) {
track.endX = touches[0].pageX;
if ( isDeliberateMove() ) {
options.endHandler( getDirection() );
}
}
}
};
extendOptions();
// Graceful degradation
if ( !document.addEventListener ) {
return {
on: function() {},
off: function() {}
};
}
return {
on: function() {
document.addEventListener('touchstart', handler.touchStart, false);
document.addEventListener('touchmove', handler.touchMove, false);
document.addEventListener('touchend', handler.touchEnd, false);
},
off: function() {
document.removeEventListener('touchstart', handler.touchStart);
document.removeEventListener('touchmove', handler.touchMove);
document.removeEventListener('touchend', handler.touchEnd);
}
};
};
// Expose global
window.touchSwipeListener = touchSwipeListener;
}( window ));
Now we can address it from our ImageViewer as easy as that:
imageViewer = (function() {
var swipeListener;
return {
init: function() {
var that = this;
swipeListener = new window.touchSwipeListener({
endHandler: function( direction ) {
that[ direction ] %26%26 that[ direction ]();
}
});
swipeListener.on();
},
prev: function() {
// go to the previous page
},
next: function() {
// go to the next page
},
close: function() {
swipeListener.off();
// close the overlay
}
}
}());
Page swipe navigation
Well, gestures work fine on ImageViewer and you probably wonder what about using gestures for sibling page navigation (e.g. on news). If you just add handlers for touch events that swap pages, the user is going to puzzle what’s wrong with the site every time he makes an accident move. I found suitable following solution. As the user starts swiping, the gesture direction icon slides in. It lets user know the gesture is being captured. It also hints on which sibling (previous or next) page gets loaded. Besides, having the icon displayed, user can cancel the gesture swiping back before finishing the gesture.
So how to make it working? We need a script, which uses touchSwipeListener to assign handlers to touchmove and touchend events. The first handler will display the direction icon when user swiping. The second will redirect to the sibling page depending on to where the swipe was directed. Well, but how does the script know about sibling pages? We can provide this information via link elements in head section.
<head>
...
<link rel="prev" title="Page 1" href="page1.html" />
<link rel="next" title="Page 3" href="page3.html" />
Now let’s examine the implementation:
/*global window */
(function( window ){
"use strict";
var document = window.document,
// Element helpers
Util = {
addClass: function( el, className ) {
el.className += " " + className;
},
hasClass: function( el, className ) {
var re = new RegExp("\\s?" + className, "gi");
return re.test( el.className );
},
removeClass: function( el, className ) {
var re = new RegExp("\\s?" + className, "gi");
el.className = el.className.replace(re, "");
}
},
swipePageNav = (function() {
// Page sibling links like <link rel="prev" title=".." href=".." />
// See also http://diveintohtml5.info/semantics.html
var elLink = {
prev: null,
next: null
},
// Arrows, which slide in to indicate the shift direction
elArrow = {
prev: null,
next: null
},
swipeListener;
return {
init: function() {
this.retrievePageSiblings();
// Swipe navigation makes sense only if any of sibling page link available
if ( !elLink.prev %26%26 !elLink.next ) {
return;
}
this.renderArows();
this.syncUI();
},
syncUI: function() {
var that = this;
// Assign handlers for swipe "in progress" / "complete" events
swipeListener = new window.touchSwipeListener({
moveHandler: function( direction, isDeliberateMove ) {
if ( isDeliberateMove ) {
if ( elArrow[ direction ] %26%26 elLink[ direction ] ) {
if ( !Util.hasClass( elArrow[ direction ], "visible" ) ) {
Util.addClass( elArrow[ direction ], "visible" );
}
}
} else {
Util.removeClass( elArrow.next, "visible" );
Util.removeClass( elArrow.prev, "visible" );
}
},
endHandler: function( direction ) {
if ( that[ direction ] ) {
that[ direction ]();
}
}
});
swipeListener.on();
},
retrievePageSiblings: function() {
elLink.prev = document.querySelector( "head > link[rel=prev]");
elLink.next = document.querySelector( "head > link[rel=next]");
},
renderArows: function() {
var renderArrow = function( direction ) {
var div = document.createElement("div");
div.className = "spn-direction-sign " + direction;
document.getElementsByTagName( "body" )[ 0 ].appendChild( div );
return div;
};
elArrow.next = renderArrow( "next" );
elArrow.prev = renderArrow( "prev" );
},
// When the shift (page swap) is requested, this overlay indicates that
// the current page is frozen and a new one is loading
showLoadingScreen: function() {
var div = document.createElement("div");
div.className = "spn-freezing-overlay";
document.getElementsByTagName( "body" )[ 0 ].appendChild( div );
},
// Request the previous sibling page
prev: function() {
if ( elLink.prev ) {
this.showLoadingScreen();
window.location.href = elLink.prev.href;
}
},
// Request the next sibling page
next: function() {
if ( elLink.next ) {
this.showLoadingScreen();
window.location.href = elLink.next.href;
}
}
};
}()),
fn;
// Apply when document is ready
document.addEventListener( "DOMContentLoaded", fn = function(){
document.removeEventListener( "DOMContentLoaded", fn, false );
try {
swipePageNav.init();
} catch ( e ) {
// Suppress errors. That's an auxiliary module
}
}, false );
}( window ));
Do you want direction icon not just popping up, but slide and fade in? Here is the CSS:
.spn-direction-sign {
position: fixed;
display: inline-block;
border: 0;
width: 66px;
height: 68px;
top: 50%;
margin-top: -34px;
z-index: 121;
color: white;
font-size: 32px;
text-align: center;
line-height: 68px;
background-color: #00a4e4;
opacity: 0;
-moz-opacity: 0;
-webkit-transition: opacity 0.3s linear, -webkit-transform 0.3s linear;
-moz-transition: opacity 0.3s linear, -mox-transform 0.3s linear;
-ms-transition: opacity 0.3s linear, -ms-transform 0.3s linear;
-o-transition: opacity 0.3s linear, -o-transform 0.3s linear;
transition: opacity 0.3s linear, transform 0.3s linear;
}
.spn-direction-sign.next {
right: 0;
-webkit-transform: translate(100%);
-moz-transform: translate(100%);
-ms-transform: translate(100%);
-o-transform: translate(100%);
transform: translate(100%);
}
.spn-direction-sign.next::after {
content: "\25b6";
}
.spn-direction-sign.prev {
left: 0;
-webkit-transform: translate(-100%);
-moz-transform: translate(-100%);
-ms-transform: translate(-100%);
-o-transform: translate(-100%);
transform: translate(-100%);
}
.spn-direction-sign.prev::after {
content: "\25c0";
}
.spn-direction-sign.visible {
opacity: 1;
-webkit-transform: translate(0);
-moz-transform: translate(0);
-ms-transform: translate(0);
-o-transform: translate(0);
transform: translate(0);
}
.spn-freezing-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #000;
opacity: 0.5;
-moz-opacity:0.5;
-khtml-opacity: 0.5;
-ms-filter:"progid:DXImageTransform.Microsoft.Alpha(Opacity=50)";
filter: alpha(opacity=50);
}
.spn-freezing-overlay::after {
position: absolute;
width: 100%;
top: 50%;
font-size: 32px;
text-align: center;
color: white;
content: "Loading..."
}
You can find the described script at https://github.com/dsheiko/spn
Demo page is here http://demo.dsheiko.com/spn