Search code examples
javascriptmobileaccelerometershake

Detect shake event with Javascript, with all major browsers/devices (iOS, Android)


I have read Javascript. Listen for iPhone shake event? and Detecting shaking in html5 mobile which gives a good solution to detect a mobile phone "shake" event:

<script src="shake.js"></script>
<script>
var myShakeEvent = new Shake({threshold: 15, timeout: 1000});
myShakeEvent.start(); 
window.addEventListener('shake', function() { alert('shake!'); }, false); 
</script>

Unfortunately, this does not seem to work with recent iOS devices, and this issue shows that special permission should be granted for recent iOS versions. Note that the code from here is not easily usable in the library shake.js.

Question: which method is available, as of 2022, to detect a "shake" event with Javascript, working on the main browsers (Firefox, Chrome, Safari) and mobile devices (iOS, Android)?

It's ok if there is a popup asking for permission first (like popups asking for permission for geolocation requests).


Solution

  • There is no shake event: the closest event that exists is devicemotion.

    Based on the content of your question, I infer that you just want to subscribe to events which are fired when device acceleration exceeds a certain threshold, with a debounce delay between possible triggers (timeout).

    Using the "shake.js" library you linked to as a reference, I wrote a TypeScript module which you can use to accomplish essentially the same thing. It includes getting user permission approval on start, but keep in mind that you'll have to call the ShakeInstance.start() method in response to a user-initiated event (e.g. a button click).

    Note: The methods used in the module are supported by the environments you listed according to the compatibility data on their related documentation pages at MDN. (Remarkably, desktop Safari simply does not support the DeviceMotionEvent whatsoever.) However, I don't have access to all of those combinations of environments you listed in order to perform the testing myself, so I'll leave that to you.

    TS Playground

    function createEvent <Type extends string, Detail>(
      type: Type,
      detail: Detail,
    ): CustomEvent<Detail> & {type: Type} {
      return new CustomEvent(type, {detail}) as CustomEvent<Detail> & {type: Type};
    }
    
    function getMaxAcceleration (event: DeviceMotionEvent): number {
      let max = 0;
      if (event.acceleration) {
        for (const key of ['x', 'y', 'z'] as const) {
          const value = Math.abs(event.acceleration[key] ?? 0);
          if (value > max) max = value;
        }
      }
      return max;
    }
    
    export type ShakeEventData = DeviceMotionEvent;
    export type ShakeEvent = CustomEvent<ShakeEventData> & {type: 'shake'};
    export type ShakeEventListener = (event: ShakeEvent) => void;
    
    export type ShakeOptions = {
      /**
       * Minimum acceleration needed to dispatch an event:
       * meters per second squared (m/s²).
       *
       * https://developer.mozilla.org/en-US/docs/Web/API/DeviceMotionEvent/acceleration
       */
      threshold: number;
      /**
       * After a shake event is dispatched, subsequent events will not be dispatched
       * until after a duration greater than or equal to this value (milliseconds).
       */
      timeout: number;
    };
    
    export class Shake extends EventTarget {
      #approved?: boolean;
      #threshold: ShakeOptions['threshold'];
      #timeout: ShakeOptions['timeout'];
      #timeStamp: number;
    
      constructor (options?: Partial<ShakeOptions>) {
        super();
        const {
          threshold = 15,
          timeout = 1000,
        } = options ?? {};
        this.#threshold = threshold;
        this.#timeout = timeout;
        this.#timeStamp = timeout * -1;
      }
      
      // @ts-ignore
      addEventListener (
        type: 'shake',
        listener: ShakeEventListener | null,
        options?: boolean | AddEventListenerOptions
      ): void {
        type Arg1 = Parameters<EventTarget['addEventListener']>[1];
        super.addEventListener(type, listener as Arg1, options);
      }
    
      dispatchEvent (event: ShakeEvent): boolean {
        return super.dispatchEvent(event);
      }
    
      // @ts-ignore
      removeEventListener (
        type: 'shake',
        callback: ShakeEventListener | null,
        options?: EventListenerOptions | boolean
      ): void {
        type Arg1 = Parameters<EventTarget['removeEventListener']>[1];
        super.removeEventListener(type, callback as Arg1, options);
      }
    
      async approve (): Promise<boolean> {
        if (typeof this.#approved === 'undefined') {
          if (!('DeviceMotionEvent' in window)) return this.#approved = false;
          try {
            type PermissionRequestFn = () => Promise<PermissionState>;
            type DME = typeof DeviceMotionEvent & { requestPermission: PermissionRequestFn };
            if (typeof (DeviceMotionEvent as DME).requestPermission === 'function') {
              const permissionState = await (DeviceMotionEvent as DME).requestPermission();
              this.#approved = permissionState === 'granted';
            }
            else this.#approved = true;
          }
          catch {
            this.#approved = false;
          }
        }
        return this.#approved;
      }
    
      #handleDeviceMotion = (event: DeviceMotionEvent): void => {
        const diff = event.timeStamp - this.#timeStamp;
        if (diff < this.#timeout) return;
        const accel = getMaxAcceleration(event);
        if (accel < this.#threshold) return;
        this.#timeStamp = event.timeStamp;
        this.dispatchEvent(createEvent('shake', event));
      };
    
      async start (): Promise<boolean> {
        const approved = await this.approve();
        if (!approved) return false;
        window.addEventListener('devicemotion', this.#handleDeviceMotion);
        return true;
      }
    
      stop (): void {
        window.removeEventListener('devicemotion', this.#handleDeviceMotion);
      }
    }
    

    Use like this:

    const shake = new Shake({threshold: 15, timeout: 1000});
    
    shake.addEventListener('shake', ev => {
      console.log('Shake!', ev.detail.timeStamp, ev.detail.acceleration);
    });
    
    // Then, in response to a user-initiated event:
    const approved = await shake.start();
    

    I'm not sure whether the SO snippet environment will cause a problem for demoing this or not, but I've included the compiled JS from the TS Playground link just in case:

    "use strict";
    function createEvent(type, detail) {
        return new CustomEvent(type, { detail });
    }
    function getMaxAcceleration(event) {
        let max = 0;
        if (event.acceleration) {
            for (const key of ['x', 'y', 'z']) {
                const value = Math.abs(event.acceleration[key] ?? 0);
                if (value > max)
                    max = value;
            }
        }
        return max;
    }
    class Shake extends EventTarget {
        constructor(options) {
            super();
            this.#handleDeviceMotion = (event) => {
                const diff = event.timeStamp - this.#timeStamp;
                if (diff < this.#timeout)
                    return;
                const accel = getMaxAcceleration(event);
                if (accel < this.#threshold)
                    return;
                this.#timeStamp = event.timeStamp;
                this.dispatchEvent(createEvent('shake', event));
            };
            const { threshold = 15, timeout = 1000, } = options ?? {};
            this.#threshold = threshold;
            this.#timeout = timeout;
            this.#timeStamp = timeout * -1;
        }
        #approved;
        #threshold;
        #timeout;
        #timeStamp;
        // @ts-ignore
        addEventListener(type, listener, options) {
            super.addEventListener(type, listener, options);
        }
        dispatchEvent(event) {
            return super.dispatchEvent(event);
        }
        // @ts-ignore
        removeEventListener(type, callback, options) {
            super.removeEventListener(type, callback, options);
        }
        async approve() {
            if (typeof this.#approved === 'undefined') {
                if (!('DeviceMotionEvent' in window))
                    return this.#approved = false;
                try {
                    if (typeof DeviceMotionEvent.requestPermission === 'function') {
                        const permissionState = await DeviceMotionEvent.requestPermission();
                        this.#approved = permissionState === 'granted';
                    }
                    else
                        this.#approved = true;
                }
                catch {
                    this.#approved = false;
                }
            }
            return this.#approved;
        }
        #handleDeviceMotion;
        async start() {
            const approved = await this.approve();
            if (!approved)
                return false;
            window.addEventListener('devicemotion', this.#handleDeviceMotion);
            return true;
        }
        stop() {
            window.removeEventListener('devicemotion', this.#handleDeviceMotion);
        }
    }
    ////////////////////////////////////////////////////////////////////////////////
    // Use:
    const shake = new Shake({ threshold: 15, timeout: 1000 });
    shake.addEventListener('shake', ev => {
        console.log('Shake!', ev.detail.timeStamp, ev.detail.acceleration);
    });
    const button = document.getElementById('start');
    if (button) {
        button.addEventListener('click', async () => {
            const approved = await shake.start();
            const div = document.body.appendChild(document.createElement('div'));
            div.textContent = `Approved: ${String(approved)}`;
            button.remove();
        }, { once: true });
    }
    <button id="start">Approve</button>