In this article, we are going to build a library for swipe detection on touchscreen devices with help of a popular library RxJS that brings functional-reactive programming to the Javascript world. I would like to show you the power of reactive programming that provides the right tools to deal with event streams in a very elegant manner.
Please note this article implies that you are familiar with some basic concepts of RxJS like observables, subscriptions, and operators.
The library will be built in Typescript and will be framework-agnostic i.e. can be used in any Typescript/Javascript project. But we will follow some good practices to make it easy to use our library in framework-specific wrappers that we are going to create later.
Defining the public interface of the library
We will get to the reactive part soon enough. But first, let's address some general things we should take care of when designing a library.
A good starting point for building any library (or any reusable module in your codebase) is to define the public interface i.e. how our library is going to be used by consumers.
The library we are building will only detect simple swipe events. That means we are not going to handle multitouch interactions such as two-finger or three-finger gestures but only react to the first point of contact in touch events. You can read more about touch events WEB API in the docs.
Our library will expose only one public function: createSwipeSubscription
. We want to attach the swipe listener to an HTML element and react to the following events emitted by the library
-
onSwipeMove
- fires on every touch move event during the user swiping the element. -
onSwipeEnd
- fires when swipe ends.
The function will accept a configuration object with three parameters and return a Subscription
instance:
export function createSwipeSubscription({
domElement,
onSwipeMove,
onSwipeEnd
}: SwipeSubscriptionConfig): Subscription {
// ...
}
Where configuration object implements the following interface:
export interface SwipeSubscriptionConfig {
domElement: HTMLElement;
onSwipeMove?: (event: SwipeEvent) => void;
onSwipeEnd?: (event: SwipeEvent) => void;
}
export interface SwipeEvent {
direction: SwipeDirection;
distance: number;
}
export enum SwipeDirection {
X = 'x',
Y = 'y'
}
And last but not least, as we are dealing with observables here, we have to think about the unsubscription logic. Whenever the swipe listener is no longer needed the subscription should be terminated. The right approach will be to delegate this action to the consumer of the library as the consumer will know when it is the right time to execute it. This is not part of the configuration object but is an important part of the public interface our library should expose. We will cover the unsubscription part in more detail in the dedicated section below.
Validating the user input
When the public interface of the library expects some input parameters to be passed from the library consumer side, we should treat those just like we would treat user input. Developers are library users after all, as well as human beings. 😄
That being said, at the very top of our createSwipeSubscription
method we want to check two things:
- Provided
domElement
should be a valid HTML element. Otherwise, we cannot attach any listeners to it. - At least one of the event handlers should be provided (
onSwipeMove
oronSwipeEnd
or both). Otherwise, there is no point in swipe event detection if we don't report anything back.
if (!(domElement instanceof HTMLElement)) {
throw new Error('Provided domElement should be instance of HTMLElement');
}
if ((typeof onSwipeMove !== 'function') && (typeof onSwipeEnd !== 'function')) {
throw new Error('At least one of the following swipe event handler functions should be provided: onSwipeMove and/or onSwipeEnd');
}
Tracking touch events
Here are all four event types we need to track:
const touchStarts$: Observable<SwipeCoordinates> = fromEvent(domElement, 'touchstart').pipe(map(getTouchCoordinates));
const touchMoves$: Observable<SwipeCoordinates> = fromEvent(domElement, 'touchmove').pipe(map(getTouchCoordinates));
const touchEnds$: Observable<SwipeCoordinates> = fromEvent(domElement, 'touchend').pipe(map(getTouchCoordinates));
const touchCancels$: Observable<Event> = fromEvent(domElement, 'touchcancel');
RxJS provides a useful utility function fromEvent
that works similar to the native addEventListener
but returns an Observable
instance which is exactly what we need.
We use the getTouchCoordinates
helper function to transform touch events to the format we need:
function getTouchCoordinates(touchEvent: TouchEvent): SwipeCoordinates {
return {
x: touchEvent.changedTouches[0].clientX,
y: touchEvent.changedTouches[0].clientY
};
}
Since we are only interested in event coordinates, we pick clientX
and clientY
fields and discard the rest. Note we only care about the first touchpoint as stated earlier, so we ignore elements in the changedTouches
array other than the first one.
Detecting the swipe start
Next we need to detect the start and the direction of the swipe:
const touchStartsWithDirection$: Observable<SwipeStartEvent> = touchStarts$.pipe(
switchMap((touchStartEvent: SwipeCoordinates) => touchMoves$.pipe(
elementAt(3),
map((touchMoveEvent: SwipeCoordinates) => ({
x: touchStartEvent.x,
y: touchStartEvent.y,
direction: getTouchDirection(touchStartEvent, touchMoveEvent)
})
))
)
);
The touchStartsWithDirection$
inner observable waits for the third consecutive touchmove
event following the touchstart
event. We do this to filter out accidental touches that we don't want to process. The third emission was picked experimentally as a reasonable threshold. If a new touchstart
is emitted before the 3rd touchmove
was received, the switchMap
inner observable will start waiting for three consecutive touchmove
events again.
When the third touchmove
event is received, we map the initially recorded touchstart
event to SwipeStartEvent
form by taking the x
and y
coordinates of the original event and detecting the swipe direction with the following helper function:
function getTouchDirection(startCoordinates: SwipeCoordinates, moveCoordinates: SwipeCoordinates): SwipeDirection {
const { x,y } = getTouchDistance(startCoordinates, moveCoordinates);
return Math.abs(x) < Math.abs(y) ? SwipeDirection.Y : SwipeDirection.X;
}
We will use this object further to calculate swipe move and swipe end events properties.
Handling touch move and touch end events
Now we can subscribe to the touchStartsWithDirection$
defined earlier to start tracking touchmove
and touchend
events:
return touchStartsWithDirection$.pipe(
switchMap(touchStartEvent => touchMoves$.pipe(
map(touchMoveEvent => getTouchDistance(touchStartEvent, touchMoveEvent)),
tap((coordinates: SwipeCoordinates) => {
if (typeof onSwipeMove !== 'function') {
return;
}
onSwipeMove(getSwipeEvent(touchStartEvent, coordinates));
}),
takeUntil(touchEnds$.pipe(
map(touchEndEvent => getTouchDistance(touchStartEvent, touchEndEvent)),
tap((coordinates: SwipeCoordinates) => {
if (typeof onSwipeEnd !== 'function') {
return;
}
onSwipeEnd(getSwipeEvent(touchStartEvent, coordinates));
})
))
))
).subscribe();
We utilize the switchMap
operator again to start listening to touchmove
events in an inner observable. If the onSwipeMove
event handler has been provided, it gets called on every event emission.
Thanks to the takeUntil
operator our observable only lives until the touchend
event is received. When this happens, if the onSwipeEnd
event handler has been provided, it gets called.
In both cases we use two helper functions:
getTouchDistance
calculates the swipe distance comparing the processed event's coordinates with the touchStartEvent
coordinates:
function getTouchDistance(startCoordinates: SwipeCoordinates, moveCoordinates: SwipeCoordinates): SwipeCoordinates {
return {
x: moveCoordinates.x - startCoordinates.x,
y: moveCoordinates.y - startCoordinates.y
};
}
and getSwipeEvent
creates library output events containing the information about swipe direction and distance:
function getSwipeEvent(touchStartEvent: SwipeStartEvent, coordinates: SwipeCoordinates): SwipeEvent {
return {
direction: touchStartEvent.direction,
distance: coordinates[touchStartEvent.direction]
};
}
Handling edge cases
The touchend
event is not the only event that can signalize touch move interruption. As the documentation states, the touchcancel
event will be fired when:
one or more touch points have been disrupted in an implementation-specific manner (for example, too many touch points are created).
We want to be prepared for this. That means we need to create one more event listener to capture the touchcancel
events:
And use it in our takeUntil
subscription:
takeUntil(race(
touchEnds$.pipe(
map(touchEndEvent => getTouchDistance(touchStartEvent, touchEndEvent)),
tap((coordinates: SwipeCoordinates) => {
if (typeof onSwipeEnd !== 'function') {
return;
}
onSwipeEnd(getSwipeEvent(touchStartEvent, coordinates));
})
),
touchCancels$
))
What happens here is we are creating a race with two participants: touchend
and touchcancel
. We utilize the RxJS race
operator for this:
race
returns an observable, that when subscribed to, subscribes to all source observables immediately. As soon as one of the source observables emits a value, the result unsubscribes from the other sources. The resulting observable will forward all notifications, including error and completion, from the "winning" source observable.
So whichever event fires first will win the race and terminate the inner touchmove
subscription. In case it is touchcancel
we don't need to emit the onSwipeEnd
event as we consider the swipe to be interrupted and don't want to handle this as a successful swipe end.
Let's stop here for a second to give some credit to Rx. Out of the box we have the right operator to solve the problem in one line. 💪
Unsubscribing
As it was mentioned earlier, the consumer of our library should be able to unsubscribe from swipe event listeners when the subscription is no longer needed. For example, when the component's destroy hook is called.
We achieve this by returning the instance of RxJS Subscription
that in its turn extends the Unsubscribable
interface:
export interface Unsubscribable {
unsubscribe(): void;
}
This way the consumer of the library will be able to hold the reference to the returned Subscription
in a variable or a class property and call the unsubscribe
method when it should be called.
We will make sure this happens automatically when creating framework-specific wrappers for our library.
Complete solution
You can find the complete library code on GitHub by this link.
And the npm
package by this link.
Usage
Time to see the library in action. Why did we build one in the first place? 😄
import { createSwipeSubscription, SwipeEvent } from 'ag-swipe-core';
const domElement: HTMLElement = document.querySelector('#swipe-element');
const swipeSubscription = createSwipeSubscription({
domElement,
onSwipeEnd: (event: SwipeEvent) => {
console.log(`SwipeEnd direction: ${event.direction} and distance: ${event.distance}`);
},
});
If you want to track onSwipeMove
as well, just add the corresponding handler function to the createSwipeSubscription
configuration object.
And when swipe events should no longer be tracked:
swipeSubscription?.unsubscribe?.();
Online preview:
https://typescript-hey3oq.stackblitz.io
Live editor to play around with:
💡 Don't forget to choose the right device type in DevTools if opening on the desktop.
Conclusion
This article covered the core logic of the swipe detection library. We used some of the RxJS powers to implement it in a neat reactive manner.
In the next articles, we are going to create wrappers around the library to make it a first-class citizen in popular Javascript frameworks.
Hope this one was useful to you. Thanks for reading and stay tuned!
Top comments (3)
Great article!
By the way, maybe the check blocks for function existence can be reduced with optional chaining?
instead of
:)
Makes sense. As long as we have committed to use Typescript, no reason to not use it at its full power:)
Updated both in the article text and in the Stackblitz example.
That was great (I mean it)