Ionic 4: Gestures Made Easy
Bringing tap, double-tap, press and swipe, back to Ionic 4 — by Jordan Benge
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.