August 20, 2018
When developing new applications many developers start with a desktop interface in mind. Having hover states for the mouse, having transitions between the different routes, showing delightful animations to keep the user engaged. But often we forget that most people will access an application on their phone and interaction patterns that work well on desktop, might not be optimal on mobile. Although nowadays most applications are responsive, many aren’t reactive to user interactions like touch gestures. This article will explain how we can take care of touch gestures in a Vue application. I made a simple touch application on Codepen, so I can explain some of the code snippets in this article.
Luke Wroblewski developed a set of Touch Gesture Cardsto get an overview what touch gestures are available and for which functionality to use them. In this example we focus on the core touch gestures that can be carried out with one thumb, since that’s how most people hold their phone. Common touch gestures for when you’re only using one thumb are: tap, drag, swipe, press, doubletap.
For this example HammerJs was used, since it’s a very established library that isn't too big. But there are many other Touch Libraries like: ZingTouch, Draggable, LayerJs, PressureJs and others. What library you use depends on what touch interaction you want incorporate in your application. For some simple tap interactions you could also write some native code to support it.
A very powerful and simple way to add touch support in Vue with HammerJS are Custom Directives. There is an excellent detailed article by Sarah Drasner on how to use custom directives. So let’s say you want to add support for dragging / panning in your application, you could add the following directive to your Vue application.
Vue.directive("pan", {
bind: function(el, binding) {
if (typeof binding.value === "function") {
const mc = new Hammer(el);
mc.get("pan").set({ direction: Hammer.DIRECTION_ALL });
mc.on("pan", binding.value);
}
}
});
And then to use the custom directive, you would add v-pan=“onPan”
on your element:
<ul v-pan="onPan" ref="list" class="slider__list">
...
</ul>
This would bind a HammerJs Pan Recognizer to this element and whenever a pan gesture happens on our element, the onPan
function will be called.
Whenever a pan touch event happens on our element, our onPan
function will be called with the the HammerJs event
object as a parameter. This event
object has a lot of different properties with information for the current touch event. You can log the event to see what properties are available. Depending on what you’re trying to do you’re going to need different properties. For the slider example in this article, we only wanted to animate the slider on the horizontal axis, so all we needed were event.deltaX
and event.isFinal
.
const app = new Vue({
el: "#app",
methods: {
onPan(event) {
console.log(event);
const deltaX = event.deltaX; // moved distance on x-axis
const deltaY = event.deltaY; // moved distance on y-axis
const isFinal = event.isFinal; // pan released
const direction = event.direction; // 0 = none, 2 = left, 4 = right, 8 = up, 16 = down,
}
});
If an element should be moved according to the speed and distance of the drag gesture, that has to be done in Javascript, because this user input is dynamic and we can’t predefine static animations in CSS, since the output will always be slightly different.
What can be done however is just updating a CSS Variable in Javascript instead of updating the specific property. To animate our slider in a performant way we’re animating the CSS transform
property by updating the —x
CSS Variable. Animating transform
is more performant, since it will only trigger changes in the composite layer instead of they layout layer. If we animate left or width, we would trigger changes in the layout layer, which in return would cause style recalculations and is less performant.
.slider__list {
transform: translateX(calc(var(--x, 0) * 1%));
}
Be aware that if you’re continuously updating the transform
property in Javascript, you shouldn’t have a transition defined for this property like for example transition: transform 0.3s ease;
. If you had a transition defined, your browser will try transitioning between these fast transform changes, which will result in bad performance. Now for our example we’re calculating how far the slider has been dragged in a percentage from 0 to 100% and then adding this percentage to where the slider had been before. Then we’re updating our CSS Variable accordingly.
onPan(event) {
// how far the slider has been dragged in percentage of the visible container
const dragOffset = 100 / this.itemWidth * e.deltaX / this.animals.length * this.overflowRatio;
// transforming from where the slider currently
const transform = this.currentOffset + dragOffset;
// updating the transform with CSS Variables
this.$refs.list.style.setProperty("--x", transform);
}
By now our slider gets updated whenever we drag it, but what if we drag it off the screen? We don’t want the slider to stay dragged of the screen, which is why we want to animate it back whenever the user stopped dragging it. This can be done with the event.isFinal
parameter of our touch event. In the following code sample, when the event is the final event, we check how far the user dragged the slide. If the slider was dragged further than it should go, it will go back to the last item. If it was dragged to the right further than the first item, it will go back to transform: translateX(0%);
and if the user hasn’t dragged it out of the window, it will go to the next item to the left or right depending on the drag direction. Then once we calculated where the slider should go when the touch event has ended, we animate it back with GSAP or whatever other animation tool you prefer, adding a little delightful bounce by defining ease: Elastic.easeOut.config(1, 0.8)
on the animation.
onPan(event) {
// continuous update when user is touching
this.$refs.list.style.setProperty("--x", transform);
// user stopped touching, this is the last event
if (e.isFinal) {
// how far we can drag depends on how much our slider is overflowing
const maxScroll = 100 - this.overflowRatio * 100;
// animate to last item
if (this.currentOffset <= maxScroll) {
finalOffset = maxScroll;
} else if (this.currentOffset >= 0) {
// animate to first item
finalOffset = 0;
} else {
// animate to next item according to pan direction
const index = this.currentOffset / this.overflowRatio / 100 * this.count;
const nextIndex = e.deltaX <= 0 ? Math.floor(index) : Math.ceil(index);
finalOffset = 100 * this.overflowRatio / this.count * nextIndex;
}
// animate it!
TweenMax.fromTo(this.$refs.list, 0.5,
{ '--x': this.currentOffset },
{ '--x': finalOffset,
ease: Elastic.easeOut.config(1, 0.8),
onComplete: () => {
this.currentOffset = finalOffset;
}
}
);
}
}
Animation and touch gestures like sliding or dragging are tightly linked and adding some reactions to touch gestures will make it more engaging and fun to use. However there is also a downsides to adding touch interactions. This dynamic reaction to touch input can only be done in Javascript. So whenever a touch event is happening, Javascript has to be parsed and how fast the Javascript can be parsed heavily depends on the computing power of the mobile device. If your Javascript calculations are very complex and the device isn’t very strong, it can result in bad performance. That's why it’s important to always test your interactions on different, average devices and to check if the performance is still acceptable.