Ionic 4: Gestures Made Easy

Bringing tap, double-tap, press and swipe, back to Ionic 4 — by Jordan Benge

Jordan Benge
6 min readJun 6, 2020
Photo by Gilles Lambert on Unsplash

Most users are trained to do things by instinct when interacting with apps. Ionic, despite being a great framework, doesn’t always have all of the interactions built in by default. For all of the niceties of Ionic, there is still a lot missing from the Framework. You’re probably familiar with the click directive and have probably used it all over your application like so:

<ion-card (click)="doSomething()">...</ion-card>

When Ionic 4 was originally in open-beta, I wrote a tutorial about how you could go about adding double taps to your app. Since writing that article, Ionic 4 has been fully released — though it is missing a lot of the previously exposed gesture directives (tap, double-tap, swipe, press).

In the previous tutorial, we leveraged hammerjs to help expose missing directives — but there are numerous issues that have been outlined by the Ionic team regarding its use.

So How Do We Solve This?

The Ionic team has put together a new controller for us to leverage when making user gestures — aptly named GestureController. There is little to no documentation on this though, and the docs that we do have are outdated as of June 5th, 2020.

After hours of Googling and testing out various methods I decided to create my own solution to the missing directives problem. I wanted to create something that was robust and singular. That way, I didn’t have to pollute my project with 5 different directive files, with duplicated code. I just wanted one file, and all of the subscribable events I could effectively utilize within my app.

The Solution

Step 1. Generate the Directive

run the command Ionic generate directive SocialGestures and when it’s finished replace the code in the newly generated file with the following:

import { Directive, ElementRef, EventEmitter, HostListener, Input, OnInit, Output } from '@angular/core';
import { GestureController } from '@ionic/angular';
export type gestureNames = 'tap' | 'doubleTap' | 'press' | 'swipe'
export type directionNames = 'up' | 'down' | 'left' | 'right';
export type reportInterval = 'start' | 'live' | 'end' ;
export interface Gesture {
name: gestureNames; // The gestureName that you want to use. Defined above.
interval?: number; // Maximum time in ms between multiple taps
enabled?: boolean; // Whether the gesture is enabled or not.
direction?: directionNames[]; // Direction - used to Swipe
threshold?: number;
reportInterval?: reportInterval;
}
@Directive({
selector: '[socialGestures]',
})
export class SocialGestureDirective implements OnInit {
@Input() gestureOpts: Gesture[]; // Events we can listen to.
@Output() tap = new EventEmitter();
@Output() doubleTap = new EventEmitter();
@Output() press = new EventEmitter();
@Output() swipe = new EventEmitter();
tapGesture: Gesture = {
name: 'tap',
enabled: false,
interval: 250,
};
doubleTapGesture: Gesture = {
name: 'doubleTap',
enabled: false,
interval: 300,
};
pressGesture: Gesture = {
name: 'press',
enabled: false,
interval: 251,
};
swipeGesture: Gesture = {
name: 'swipe',
enabled: false,
interval: 250,
threshold: 15, // the minimum distance before reporting a swipe.
reportInterval: undefined,
direction: [],
};
DIRECTIVE_GESTURES = [this.tapGesture, this.doubleTapGesture, this.pressGesture, this.swipeGesture]; GESTURE_CREATED = false; lastTap = 0;
tapCount = 0;
tapTimeout = null;
pressTimeout = null;
isPressing: boolean = false;
moveTimeout = null;
isMoving: boolean = false;
lastSwipeReport = null; constructor(private gestureCtrl: GestureController, private el: ElementRef) {}ngOnInit() {
// This will setup the gestures that the user has provided in their Gesture Options.
this.DIRECTIVE_GESTURES.filter((dGesture) => this.gestureOpts.find(({name}) => dGesture.name === name)).map((gesture) => {
gesture.enabled = true;
if (gesture.name === 'swipe') {
const swipeGestureOpts = this.gestureOpts.find(({name}) => name == 'swipe');
this.swipeGesture.direction = swipeGestureOpts.direction || ['left', 'right'];
this.createGesture();
}
});
if (this.pressGesture.enabled && this.swipeGesture.enabled) {
console.warn('Press and Swipe should not be enabled on the same element.');
}
if (this.gestures.length === 0) {
console.warn('No gestures were provided in Gestures array');
}
}
@HostListener('touchstart', ['$event'])
@HostListener('touchend', ['$event'])
onPress(e) {
if (!this.pressGesture.enabled) {
return;
} // Press is not enabled, don't do anything.
this.handlePressing(e.type);
}
@HostListener('click', ['$event'])
handleTaps(e) {
const tapTimestamp = Math.floor(e.timeStamp);
const isDoubleTap = this.lastTap + this.tapGesture.interval > tapTimestamp;
if ((!this.tapGesture.enabled && !this.doubleTapGesture.enabled) || this.isPressing || this.isMoving) {
return this.resetTaps();
}
this.tapCount++; if (isDoubleTap && this.doubleTapGesture.enabled) {
this.emitTaps();
} else if (!isDoubleTap) {
this.tapTimeout = setTimeout(() => this.emitTaps(), this.tapGesture.interval);
}
this.lastTap = tapTimestamp;
}
private handleMoving(moveType, $event) {
if (this.moveTimeout !== null) {
clearTimeout(this.moveTimeout);
this.moveTimeout = null;
}
const deltaX = $event.deltaX;
const deltaY = $event.deltaY;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
const reportInterval = this.swipeGesture.reportInterval || 'live';
const threshold = this.swipeGesture.threshold;
if (absDeltaX < threshold && absDeltaY < threshold) { // We haven't moved enough to consider it a swipe.
return;
}
const shouldReport = this.isMoving &&
(
(reportInterval === 'start' && this.lastSwipeReport === null) ||
(reportInterval === 'live') ||
(reportInterval === 'end' && moveType == 'moveend')
);
this.lastSwipeReport = $event.timeStamp; if (shouldReport) {
let emitObj = {
dirX: undefined,
dirY: undefined,
swipeType: moveType,
...$event,
};
if (absDeltaX > threshold) {
if (deltaX > 0) {
emitObj.dirX = 'right';
} else if (deltaX < 0) {
emitObj.dirX = 'left';
}
}
if (absDeltaY > threshold) {
if (deltaY > 0) {
emitObj.dirY = 'down';
} else if (deltaY < 0) {
emitObj.dirY = 'up';
}
}
const dirArray = this.swipeGesture.direction;
if (dirArray.includes(emitObj.dirX) || dirArray.includes(emitObj.dirY)) {
this.swipe.emit(emitObj);
}
}
if ((moveType == 'moveend' && this.lastSwipeReport !== null)) {
this.isMoving = false;
this.lastSwipeReport = null;
}
}
private handlePressing(type) { // touchend or touchstart
if (type == 'touchstart') {
this.pressTimeout = setTimeout(() => {
this.isPressing = true;
this.press.emit('start');
}, this.pressGesture.interval); // Considered a press if it's longer than interval (default: 251).
} else if (type == 'touchend') {
clearTimeout(this.pressTimeout);
if (this.isPressing) {
this.press.emit('end');
this.resetTaps(); // Just incase this gets passed as a tap event too.
}
// Clicks have a natural delay of 300ms, so we have to account for that, before resetting isPressing.
// Otherwise a tap event is emitted.
setTimeout(() => this.isPressing = false, 50);
}
}
private createGesture() {
if (this.GESTURE_CREATED) {
return;
}
const gesture = this.gestureCtrl.create({
gestureName: 'socialGesture',
el: this.el.nativeElement,
onStart: () => {
if (this.swipeGesture.enabled) {
this.isMoving = true;
this.moveTimeout = setInterval(() => {
this.isMoving = false;
}, 249);
}
},
onMove: ($event) => {
if (this.swipeGesture.enabled) {
this.handleMoving('moving', $event);
}
},
onEnd: ($event) => {
if (this.swipeGesture.enabled) {
this.handleMoving('moveend', $event);
}
},
}, true);
gesture.enable();
this.GESTURE_CREATED = true;
}
private emitTaps() {
if (this.tapCount === 1 && this.tapGesture.enabled) {
this.tap.emit();
} else if (this.tapCount === 2 && this.doubleTapGesture.enabled) {
this.doubleTap.emit();
}
this.resetTaps();
}
private resetTaps() {
clearTimeout(this.tapTimeout); // clear the old timeout
this.tapCount = 0;
this.tapTimeout = null;
this.lastTap = 0;
}
}

Step 2. Import the Directive into app.module.ts

You’ll need to import the new directive into your app.module.ts in order for it to be exposed properly. It should end up looking something like this:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';import { SocialGestures } from './directives/social-gestures/social-gestures.directive';@NgModule({
declarations: [
...
SocialGestures
]
exports: [
...
SocialGestures
]
})
export class AppModule {}

Step 3. Attach the Directive to a Component

The directive requires that we provide it with a list of what gestures we want to utilize as well as the events we want to be aware of. You can do this by creating a gesture array of the gestures you want exposed for that component:

gestureOpts: Gesture[] = [
{name: 'tap'},
{name: 'doubleTap'},
{name: 'press'},
// {name: 'swipe'},
];

This is the basic set of options required to get the directive working. We are currently exposing the tap, doubleTap and press events, and commenting out swipe for now.

Note: It is not recommended that you expose both swipe and press on the same component. As they can cause the other to fire unknowingly.

Step 4: Attach the Directive to your Template

Now all we have to do is attach it to our component, and we’re done! The important thing to remember is to include socialGestures on any component that you want to expose the gestures on, as well as the gestureOpts array that we created above. Then all you have to do is subscribe to the event(s) as shown below, and you’re good!

<ion-card socialGestures
[gestureOpts]="gestureOpts"
(tap)
="onTap($event)"
(doubleTap)="onDoubleTap($event)"
(press)="onPress($event)"
(swipe)="onSwipe($event)"
>...</ion-card

Note: I based a lot of the above off of thresholds/benchmarks set forth in the original hammerjs code. You are welcome to customize it how ever you like, I just felt like their timings & thresholds were battle tested.

Questions?

You can find me on:
- GitHub: https://github.com/bengejd/
- Medium: https://medium.com/@JordanBenge

Who am I? My name is Jordan Benge, I am a Software Developer who loves helping others and contributing to Open-Source. I’ve been working in the Ionic Framework since Ionic 1, and have tried to keep up to date on the latest and greatest when it comes to Hybrid Mobile App Development.

If you enjoyed this story, please click the 👏 button and share to help others find it! Feel free to leave a comment below if you need any help.

--

--

Jordan Benge

My name is Jordan Benge, I’m a freelance developer, who sometimes likes to write helpful articles on Medium for people who want to do things but don’t know how.