Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

This is Learning profile imageAlexander Goncharuk
Alexander Goncharuk forThis is Learning

Posted on • Edited 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 thedocs.

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 aSubscription instance:

exportfunctioncreateSwipeSubscription({domElement,onSwipeMove,onSwipeEnd}:SwipeSubscriptionConfig):Subscription{// ...}
Enter fullscreen modeExit fullscreen mode

Where configuration object implements the following interface:

exportinterfaceSwipeSubscriptionConfig{domElement:HTMLElement;onSwipeMove?:(event:SwipeEvent)=>void;onSwipeEnd?:(event:SwipeEvent)=>void;}exportinterfaceSwipeEvent{direction:SwipeDirection;distance:number;}exportenumSwipeDirection{X='x',Y='y'}
Enter fullscreen modeExit 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 ourcreateSwipeSubscription method we want to check two things:

  • ProvideddomElement 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(!(domElementinstanceofHTMLElement)){thrownewError('Provided domElement should be instance of HTMLElement');}if((typeofonSwipeMove!=='function')&&(typeofonSwipeEnd!=='function')){thrownewError('At least one of the following swipe event handler functions should be provided: onSwipeMove and/or onSwipeEnd');}
Enter fullscreen modeExit fullscreen mode

Tracking touch events

Here are all four event types we need to track:

consttouchStarts$:Observable<SwipeCoordinates>=fromEvent(domElement,'touchstart').pipe(map(getTouchCoordinates));consttouchMoves$:Observable<SwipeCoordinates>=fromEvent(domElement,'touchmove').pipe(map(getTouchCoordinates));consttouchEnds$:Observable<SwipeCoordinates>=fromEvent(domElement,'touchend').pipe(map(getTouchCoordinates));consttouchCancels$:Observable<Event>=fromEvent(domElement,'touchcancel');
Enter fullscreen modeExit fullscreen mode

RxJS provides a useful utility functionfromEvent that works similar to the nativeaddEventListener but returns anObservable instance which is exactly what we need.

We use thegetTouchCoordinates helper function to transform touch events to the format we need:

functiongetTouchCoordinates(touchEvent:TouchEvent):SwipeCoordinates{return{x:touchEvent.changedTouches[0].clientX,y:touchEvent.changedTouches[0].clientY};}
Enter fullscreen modeExit fullscreen mode

Since we are only interested in event coordinates, we pickclientX andclientY fields and discard the rest. Note we only care about the first touchpoint as stated earlier, so we ignore elements in thechangedTouches array other than the first one.

Detecting the swipe start

Next we need to detect the start and the direction of the swipe:

consttouchStartsWithDirection$: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 modeExit fullscreen mode

ThetouchStartsWithDirection$ inner observable waits for the third consecutivetouchmove event following thetouchstart 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 newtouchstart is emitted before the 3rdtouchmove was received, theswitchMap inner observable will start waiting for three consecutivetouchmove events again.

When the thirdtouchmove event is received, we map the initially recordedtouchstart event toSwipeStartEvent form by taking thex andy coordinates of the original event and detecting the swipe direction with the following helper function:

functiongetTouchDirection(startCoordinates:SwipeCoordinates,moveCoordinates:SwipeCoordinates):SwipeDirection{const{x,y}=getTouchDistance(startCoordinates,moveCoordinates);returnMath.abs(x)<Math.abs(y)?SwipeDirection.Y:SwipeDirection.X;}
Enter fullscreen modeExit 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 thetouchStartsWithDirection$defined earlier to start trackingtouchmove andtouchend events:

returntouchStartsWithDirection$.pipe(switchMap(touchStartEvent=>touchMoves$.pipe(map(touchMoveEvent=>getTouchDistance(touchStartEvent,touchMoveEvent)),tap((coordinates:SwipeCoordinates)=>{if(typeofonSwipeMove!=='function'){return;}onSwipeMove(getSwipeEvent(touchStartEvent,coordinates));}),takeUntil(touchEnds$.pipe(map(touchEndEvent=>getTouchDistance(touchStartEvent,touchEndEvent)),tap((coordinates:SwipeCoordinates)=>{if(typeofonSwipeEnd!=='function'){return;}onSwipeEnd(getSwipeEvent(touchStartEvent,coordinates));})))))).subscribe();
Enter fullscreen modeExit fullscreen mode

We utilize theswitchMap operator again to start listening totouchmove events in an inner observable. If theonSwipeMove event handler has been provided, it gets called on every event emission.

Thanks to thetakeUntil operator our observable only lives until thetouchend event is received. When this happens, if theonSwipeEnd 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 thetouchStartEvent coordinates:

functiongetTouchDistance(startCoordinates:SwipeCoordinates,moveCoordinates:SwipeCoordinates):SwipeCoordinates{return{x:moveCoordinates.x-startCoordinates.x,y:moveCoordinates.y-startCoordinates.y};}
Enter fullscreen modeExit fullscreen mode

andgetSwipeEvent creates library output events containing the information about swipe direction and distance:

functiongetSwipeEvent(touchStartEvent:SwipeStartEvent,coordinates:SwipeCoordinates):SwipeEvent{return{direction:touchStartEvent.direction,distance:coordinates[touchStartEvent.direction]};}
Enter fullscreen modeExit fullscreen mode

Handling edge cases

Thetouchend event is not the only event that can signalize touch move interruption. As the documentation states, thetouchcancel 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 thetouchcancel events:

And use it in ourtakeUntil subscription:

takeUntil(race(touchEnds$.pipe(map(touchEndEvent=>getTouchDistance(touchStartEvent,touchEndEvent)),tap((coordinates:SwipeCoordinates)=>{if(typeofonSwipeEnd!=='function'){return;}onSwipeEnd(getSwipeEvent(touchStartEvent,coordinates));})),touchCancels$))
Enter fullscreen modeExit fullscreen mode

What happens here is we are creating a race with two participants:touchend andtouchcancel. We utilize the RxJSrace 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 innertouchmove subscription. In case it istouchcancel we don't need to emit theonSwipeEnd 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 RxJSSubscription that in its turn extends theUnsubscribable interface:

exportinterfaceUnsubscribable{unsubscribe():void;}
Enter fullscreen modeExit fullscreen mode

This way the consumer of the library will be able to hold the reference to the returnedSubscription in a variable or a class property and call theunsubscribe 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 bythis link.

And thenpm package bythis 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';constdomElement:HTMLElement=document.querySelector('#swipe-element');constswipeSubscription=createSwipeSubscription({domElement,onSwipeEnd:(event:SwipeEvent)=>{console.log(`SwipeEnd direction:${event.direction} and distance:${event.distance}`);},});
Enter fullscreen modeExit fullscreen mode

If you want to trackonSwipeMove as well, just add the corresponding handler function to thecreateSwipeSubscription configuration object.

And when swipe events should no longer be tracked:

swipeSubscription?.unsubscribe?.();
Enter fullscreen modeExit 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!

Top comments(3)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss
CollapseExpand
 
bwca profile image
Volodymyr Yepishev
I like coding :)
  • Education
    Kyiv National Linguistic University
  • Work
    Application Developer
  • Joined

Great article!

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

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

instead of

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

:)

CollapseExpand
 
agoncharuks profile image
Alexander Goncharuk
Web developer from Riga, Latvia.Javascript / Typescript / Node and a fistful of frameworks.
  • Location
    Riga, Latvia
  • Education
    Riga Technical University, M.S. in Electronics Engineering
  • Joined

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.

CollapseExpand
 
sojinsamuel profile image
Sojin Samuel
React developer | Next.js and Golang is awesome
  • Location
    India, Kerala
  • Education
    Jawaharlal Nehru Institute of Arts and Science
  • Work
    Full Stack Engineer, Freelancer
  • Joined

That was great (I mean it)

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

Free, open and honest software education.

Read our welcome letter which is an open invitation for you to join.

More fromThis is Learning

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp