3

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).

Basj
  • 41,386
  • 99
  • 383
  • 673
  • Apple charges you to develop applications on their operating system. Their OS is locked down and their browsers can support what they want. In other words you'll want to contact the shake developer for an added shim support for latest or head on over and start building your app in iOS where you'll get all that support. :) – BGPHiJACK Dec 31 '21 at 18:44
  • "_crossbrowser and crossdevice_": I have no idea what this means. Can you provide a list of the specific browsers and the specific devices that you want to support? – jsejcksn Jan 04 '22 at 07:38
  • @jsejcksn I mean: working on main browsers (Firefox, Chrome, Safari), and main mobile devices (iOS, Android). I edited the question and title to include this. – Basj Jan 04 '22 at 10:02
  • @Basj (1) What is "etc." in browsers, and which versions of each listed browser do you want to support? (2) What is "etc." in devices, and which versions of each listed operating system do you want to support? – jsejcksn Jan 04 '22 at 10:25
  • @jsejcksn "etc" removed. Something like Firefox 80+, Chrome 80+, Safari 13+, Android 7+, [iOS 13+](https://dev.to/li/how-to-requestpermission-for-devicemotion-and-deviceorientation-events-in-ios-13-46g2). Something that works for 90% of Android users and 90% of iOS users would be enough. – Basj Jan 04 '22 at 10:36
  • 1
    @Basj That narrows the possible combinations _considerably_! – jsejcksn Jan 04 '22 at 10:41

2 Answers2

6

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>
jsejcksn
  • 27,667
  • 4
  • 38
  • 62
1

Device shake detection w/ plain JS, no libraries

An attempt at universal shake detection.

For non-iOS: Shaking the first-time will show a permission prompt to the user asking to allow use of sensor.

For iOS (or any device strict about requestPermission API): An extra step is needed in the user's experience. The user must invoke the sensor permission prompt themselves rather than the permission prompt coming up on it's own on first shake. This is done by you providing a button somewhere in the experience, perhaps in a toolbar or a modal, where the button invokes the requestPermission API.

In addition to the above, you need to host this on an HTTPS server (I used github-pages). I have it working on localhost/local-wifi too, but that's another thread. For this problem specifically, I would avoid testing this in online IDE's (like Codepen) even if they're https, requestPermission may not work.

Recommendation: Whatever you do, in your app (or website) it would be good if you independently store state for the user, ie whether they allowed permission or not. If they hit "Cancel", then you can reliably know this and possibly periodically tell them "Hey, you're missing out on this awesome functionality!" within your experience, and offer the permission prompt again (through explicit UI control).

HTML

<button id="btn_reqPermission" style="display: none;padding: 2em">
    Hey! This will be much better with sensors. Allow?
</button>
<div id="output_message"></div>

Javascript:

// PERMISSION BUTTON
var btn_reqPermission = document.getElementById("btn_reqPermission")
btn_reqPermission.addEventListener("click", () => { this.checkMotionPermission() })


// ON PAGE LOAD
this.checkMotionPermission()


// FUNCTIONS
async function checkMotionPermission() {

    // Any browser using requestPermission API
    if (typeof DeviceOrientationEvent.requestPermission === 'function') {

        // If previously granted, user will see no prompts and listeners get setup right away.
        // If error, we show special UI to the user.
        // FYI, "requestPermission" acts more like "check permission" on the device.
        await DeviceOrientationEvent.requestPermission()
        .then(permissionState => {
            if (permissionState == 'granted') {
                // Hide special UI; no longer needed
                btn_reqPermission.style.display = "none"
                this.setMotionListeners()
            }
        })
        .catch( (error) => {
            console.log("Error getting sensor permission: %O", error)
            // Show special UI to user, suggesting they should allow motion sensors. The tap-or-click on the button will invoke the permission dialog.
            btn_reqPermission.style.display = "block"
        })

    // All other browsers
    } else {
        this.setMotionListeners()
    }

}

async function setMotionListeners() {

    // ORIENTATION LISTENER
    await window.addEventListener('orientation', event => {
        console.log('Device orientation event: %O', event)
    })

    // MOTION LISTENER
    await window.addEventListener('devicemotion', event => {
        console.log('Device motion event: %O', event)

        // SHAKE EVENT
        // Using rotationRate, which essentially is velocity,
        // we check each axis (alpha, beta, gamma) whether they cross a threshold (e.g. 256).
        // Lower = more sensitive, higher = less sensitive. 256 works nice, imho.
        if ((event.rotationRate.alpha > 256 || event.rotationRate.beta > 256 || event.rotationRate.gamma > 256)) {
            this.output_message.innerHTML = "SHAKEN!"
            setTimeout(() => {
                this.message.innerHTML = null
            }, "2000")
        }
    })
}
Kalnode
  • 9,386
  • 3
  • 34
  • 62