3

What I want

… is to detect panning (or swiping) using Hammer.js in Angular 9.

It should should work like this:

  • horizontal panning should be detected
  • diagonal panning with certain degrees should be detected1
  • vertical scrolling shoud not be prevented
  • pinch to zoom should not be prevented as this will cause accessibility problems

enter image description here

In the image green shows everything that is default by the browser and should not be prevented. blue shows everything that should be prevented and be handled by Hammer.

Not sure what I mean? Take a look at Swiper's demos. They work exactly like this.

1 It's clear how to detect the angle using event.angle. But I'm not sure how to distinguish between preventing or not preventing the event if necessary.

What I've tried

… are all these things which I found on multipee questions on Stackoverflow and other blog articles:

import { BrowserModule, HammerModule, HammerGestureConfig, HAMMER_GESTURE_CONFIG } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import * as Hammer from 'hammerjs';

class MyHammerConfig extends HammerGestureConfig {
  // Test #1
  overrides = <any>{
    swipe: { direction: hammer.DIRECTION_HORIZONTAL },
    pinch: { enable: false },
    rotate: { enable: false }
  };

  // Test #2
  overrides = <any>{
    swipe: { direction: Hammer.DIRECTION_ALL  }
  };

  // Test #3
  options = {
    domEvents: true
  }

  // Test #4
  buildHammer(element: HTMLElement) {
    const mc = new Hammer(element, {
      touchAction: 'pan-y'
    });

    return mc;
  }

  // Test #5
  buildHammer(element: HTMLElement) {
    const mc = new Hammer(element, {
        touchAction: 'auto'
     });

     return mc;
  }
}

@NgModule({
  declarations: [
    AppComponent,
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    HammerModule
  ],
  providers: [
    {
      provide: Window,
      useValue: window
    },
    {
      provide: HAMMER_GESTURE_CONFIG,
      useClass: MyHammerConfig
    }
  ],
  bootstrap: [
    AppComponent
  ]
})

export class AppModule { }

All these things didn't work as they had different results and were not consistent through different devices (e.g. iPhone vs iPad):

  • ✗ pinch to zoom did not work
  • ✗ vertical scrolling did not work
  • ✗ diagonal panning could not be detected

The current solution

… creates Hammer directly in the component, like this:

import { Component, OnInit, ViewChild, ElementRef, OnDestroy, ViewChildren, QueryList } from '@angular/core';

import * as Hammer from 'hammerjs';

@Component({
  selector: 'app-hero',
  templateUrl: './hero.component.html',
  styleUrls: ['./hero.component.scss']
})

export class HeroComponent implements OnInit, OnDestroy {
  @ViewChild('list', { static: true }) list: ElementRef;

  private hammertime: Hammer;
  private isLocked: boolean = false;

  ngOnInit() {
    this.initHammer();
  }

  ngOnDestroy() {
    // todo destroy hammertime
  }

  initHammer(): void {
    this.hammertime = new Hammer(this.list.nativeElement, {touchAction : 'auto'});
    this.hammertime.get('pan').set({ direction: Hammer.DIRECTION_HORIZONTAL, threshold: 60 });
    this.hammertime.on('panleft panright', event => {
      if (this.isLocked || event.maxPointers !== 1) {
        return
      };

      this.goto(event.type === 'panLeft' ? 'next' : 'prev');
      this.isLocked = true;

      setTimeout(() => this.isLocked = false, 1000)
    });
  }
}

This does a lot of things:

  • ✓ pinch to zoom works
  • ✓ vertical scrolling works
  • ✓ horizontal swipes are detected

But these things don't work or are not good:

  • ✗ not the Angular way by not using the HammerModule
  • ✗ no diagonal detection
  • Hammer-instance this.hammertime and attached events are never destroyed
  • ✗ A timeout is used to prevent the pan-event from firing multiple times
  • ✗ pointer count must be dectected manually

Questions?

  • How can I make this work "the Angular way"?
  • How can I detect the diagonal panning?
  • How to correctly destroy this.hammertime when the goal is not possible "the Angular way"?
7
  • stackoverflow.com/a/27758337/2702894 does this help with the diagonal part Commented Mar 30, 2020 at 11:41
  • 1
    also zingchart.github.io/zingtouch is actually a great library, couple of minor bugs I had to fix but I used this as it also includes two finger rotate. Commented Mar 30, 2020 at 11:44
  • 1
    you would select angles between certain values i.e. between 22.5 degrees and 77.5 degrees as diagonal - think of it like an 8 piece pie chart, only if the angle is in the diagonal section do you want it to register as diagonal. So you would have -22.5 degree (equivalent) to 22.5 degrees as vertical, 22.5 to 77.5 degrees as diagonal up right, 77.5 to 112.5 degrees as right etc. Using the zingtouch library it does give you the angle so it might be easier rolling your own with that (plus it is 7kb gzipped vs about 60kb gzipped for hammer so bonus points there, even if you need more custom code). Commented Mar 30, 2020 at 20:15
  • 1
    in zingtouch it is 'directionFromOrigin' that you would use. Commented Mar 30, 2020 at 20:18
  • 2
    @GrahamRitchie Thank you very much for your help and directing my into a great direction. I found a solution that works perfectly and added it as an answer to help future visitors.
    – lampshade
    Commented Apr 1, 2020 at 9:12

2 Answers 2

1

An alternative approach

… using ZingTouch as proposed by Graham Ritchie.

It does all these things:

  • ✓ horizontal and diagonal panning is detected
  • ✓ vertical scrolling is not prevented
  • ✓ pinch to zoom should is not prevented

The new solution

import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core';

import ZingTouch from 'zingtouch';

@Component({
  selector: 'app-hero',
  templateUrl: './hero.component.html',
  styleUrls: ['./hero.component.scss']
})

export class HeroComponent implements OnInit, OnDestroy {
  @ViewChild('list', { static: true }) list: ElementRef;

  private zingTouchRegion: ZingTouch.Region;
  private isLocked: boolean = false;

  ngOnInit() {
    this.initZingTouch();
  }

  ngOnDestroy() {
    this.zingTouchRegion.unbind(this.list.nativeElement);
  }

  initZingTouch(): void {
    const area = this.list.nativeElement;
    const gesture = new ZingTouch.Pan({threshold: 10});

    this.zingTouchRegion = new ZingTouch.Region(area, false, false);
    this.zingTouchRegion.bind(area, gesture, event => {
      const angle = event.detail.data[0].directionFromOrigin;

      if ((angle >= 40 && angle <= 140) || (angle >= 220 && angle <= 320)) {
        return;
      }

      event.detail.events[0].originalEvent.preventDefault();

      if (this.isLocked) {
        return
      };

      if (angle > 140 && angle < 220) {
        this.goto('next');
      }

      if (angle > 320 || angle < 40) {
        this.goto('prev');
      }

      this.isLocked = true;
      setTimeout(() => this.isLocked = false, 600)
    });
  }
}
0

I took the approach of enabling Hammer's pinch recognizer "on-demand". That is, detect when the user has two touches on a content container div and then enable the pinch recognizer. The pinch event handler set up through hammer.on('pinchstart pinchend', listener) responds right after the pinch recognizer is enabled through a touchstart event listener. The pinch recognizer is switched off in a touchend event listener to ensure it doesn't interfere with a vertical scroll or horizontal pan.

Initially, I used the 'pointerdown' and 'pointerup' listeners. However, 'pointerup' did not fire consistently.

    var containerEl = document.getElementById("content-container");

    // Use the 'touchstart' listener to enable Hammer's 'pinch' recognizer.
    // 'touchstart' and 'touchend' are supported on Safari on iOS, Chrome, Firefox
    // caniuse.com/?search=touches
    // developer.mozilla.org/en-US/docs/Web/API/TouchEvent/touches
    containerEl.addEventListener('touchstart', touchstartListener);
    containerEl.addEventListener('touchend', touchendListener);

    // Create a 'Hammer' instance.
    // By default the 'Hammerjs' library adds horizontal recognizers only.
    // Hammer sets the 'domEvents' option to 'false' by default.
    // It's set in the following code for clarity that Hammer will
    // not fire DOM events on the 'containerEl' element. That is,
    // there's no event delegation. (hammerjs.github.io/api/#hammer-defaults)
    var hammer = new Hammer(containerEl, { domEvents: false });

    // hammerjs.github.io/recognizer-pan
    // From the 'Notes' heading:
    // "When calling Hammer() to create a simple instance,
    // the pan and swipe recognizers are configured to only detect
    // horizontal gestures."
    // Therefore, setting the 'direction' option to
    // 'Hammer.DIRECTION_HORIZONTAL' is unnecessary. It's
    // set here to make it clear while reading this code that
    // the pan gesture is captured in the horizontal direction only.
    hammer.get('pan').set({ direction: Hammer.DIRECTION_HORIZONTAL });

    var events = "panleft panright panend pinchstart pinchend";
    hammer.on(events, hammerListener);

    var gestureMessage = "";

    // TO DO
    // Add a more robust filter for a one-time detection of the 'panleft' or 'panright'
    // event type when the user first touches the screen to begin
    // a vertical scroll. Evaluating the 'e.angle' value in the
    // 'hammerListener()' function is an alternative option for
    // filtering out the first touch of a vertical scroll as
    // @lampshade adds in the ZingTouch alternative approach answer.
    var firstTouchCounter = 0;

    function hammerListener(e) {
        switch (e.type) {
            case 'panleft':
                if (firstTouchCounter < 2) { firstTouchCounter++; return; }
                gestureMessage = "Pan left";
                break;
            case 'panright':
                gestureMessage = "Pan right";
                break;
            case 'panend':
                firstTouchCounter = 0; // Reset counter
                gestureMessage = "Pan end";
                break;
            case 'pinchstart':
                gestureMessage = "Pinch start";
                break;
            case 'pinchend':
                gestureMessage = "Pinch end";
                break;
        }
        console.log(gestureMessage);
    }

    var pinchEnabled = false;

    function touchstartListener(e) {
        if (e.touches.length === 2) {
            hammer.get('pinch').set({ enable: true });
            pinchEnabled = true;
            console.log("pinch ON");
        }
    }

    function touchendListener(e) {
        if (pinchEnabled && e.touches.length === 0) {
            hammer.get('pinch').set({ enable: false });
            pinchEnabled = false;
            console.log("pinch OFF");
        }
    }

Sidenote

I added the preceding vanilla JavaScript because it addresses 90% of the question's text: "How to keep vertical scrolling and zooming and detect horizontal and diagonal panning/swiping with Hammer.js"; and @lampshade added an alternative approach as well.

Also note that the vanilla JavaScript approach above is different than the solution @David references in his comment to @lampshade's question.

I upvoted the question because it's detailed oriented, it provides a clear picture of what's needed and what was attempted. It made it easier for me to iterate on a solution.

Not the answer you're looking for? Browse other questions tagged or ask your own question.