DEV Community

Alexander Goncharuk for This is Learning

Posted on • Updated on

Detect swipes with reactive programming

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 {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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 or onSwipeEnd 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');
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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
  };
}
Enter fullscreen mode Exit fullscreen mode

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)
      })
    ))
  )
);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
  };
}
Enter fullscreen mode Exit fullscreen mode

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]
  };
}
Enter fullscreen mode Exit fullscreen mode

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$
))
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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}`);
  },
});
Enter fullscreen mode Exit fullscreen mode

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?.();
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (3)

Collapse
bwca profile image
Volodymyr Yepishev

Great article!

By the way, maybe the check blocks for function existence can be reduced with optional chaining?

swipeSubscription?.unsubscribe?.();
Enter fullscreen mode Exit fullscreen mode

instead of

if (typeof swipeSubscription?.unsubscribe === 'function') {
  swipeSubscription.unsubscribe();
}
Enter fullscreen mode Exit fullscreen mode

:)

Collapse
agoncharuks profile image
Alexander Goncharuk Author

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.

Collapse
sojinsamuel profile image
Sojin Samuel

That was great (I mean it)