react-native-shake icon indicating copy to clipboard operation
react-native-shake copied to clipboard

Expo SDK 52 CNG Support

Open samducker opened this issue 1 year ago • 8 comments

Hi I am trying to run the latest beta version of this library, however when I shake only the expo dev client appears.

Could you clarify if this is expected behaviour or if I should get both the expo dev client appearing and also the shake event from react native shake.

samducker avatar Sep 02 '24 14:09 samducker

Hi @samducker I just made a quick test and it seems to work normally. You may have to run npx expo prebuild --clean and rebuild the app as it involves native code.

image

Doko-Demo-Doa avatar Sep 02 '24 17:09 Doko-Demo-Doa

Also, the shake behavior of Android and iOS are different: On iOS, you can just fling the phone in one direction and the event fires. On Android, you can do it gently, but back and forth, for the events to be fired.

Doko-Demo-Doa avatar Sep 02 '24 17:09 Doko-Demo-Doa

Hey @Doko-Demo-Doa unfortunately it doesn't work for me with the same implementation as you in my route layout file.

I'm on latest expo SDK with expo-router, continuous native generation mode and expo dev client.

Could you confirm if you are using latest Expo SDK with the dev client installed?

Do you have any debugging advice also?

samducker avatar Sep 03 '24 23:09 samducker

@samducker Can you create the minimum example so I can reproduce it? In fact this lib must be used with Expo CNG because it involves native code.

I'm using latest stable Expo version, which is 51 at the moment.

image

Doko-Demo-Doa avatar Sep 04 '24 02:09 Doko-Demo-Doa

Hi @Doko-Demo-Doa I'm sorry to report, I couldn't get this library to work maybe it was clashing with expo dev client I'm not really sure.

I produced this custom hook instead which seems to be working well,perhaps you could wrap the expo-sensors library in a future release into this library for expo users.

Sharing incase anyone else is having expo related issues for a solution.

import { useEffect, useCallback, useState } from 'react';
import { DeviceMotion } from 'expo-sensors';

export default function useShakeDetector() {
  const [isShaking, setIsShaking] = useState(false);

  const handleShake = useCallback(() => {
    console.log('Shake detected');
  }, []);

  useEffect(() => {
    const SHAKE_THRESHOLD = 800;
    const SHAKE_COOLDOWN = 1000;
    const UPDATE_INTERVAL = 100;
    let lastUpdate = 0;
    let lastShake = 0;
    let lastX = 0,
      lastY = 0,
      lastZ = 0;

    const subscription = DeviceMotion.addListener((deviceMotionData) => {
      const { x, y, z } = deviceMotionData.accelerationIncludingGravity;
      const currentTime = Date.now();

      if (currentTime - lastUpdate > UPDATE_INTERVAL) {
        const diffTime = currentTime - lastUpdate;
        lastUpdate = currentTime;

        const speed = (Math.abs(x + y + z - lastX - lastY - lastZ) / diffTime) * 10000;

        if (speed > SHAKE_THRESHOLD && currentTime - lastShake > SHAKE_COOLDOWN) {
          lastShake = currentTime;
          setIsShaking(true);
          handleShake();
          setTimeout(() => setIsShaking(false), 300);
        }

        lastX = x;
        lastY = y;
        lastZ = z;
      }
    });

    DeviceMotion.setUpdateInterval(UPDATE_INTERVAL);

    return () => {
      subscription && subscription.remove();
    };
  }, [handleShake]);

  return isShaking;
}

samducker avatar Oct 04 '24 18:10 samducker

Hi @samducker thanks for this. Which of these variables can one adjust to reduce on the sensitivity of this hook? I notice that on Android, even just putting the phone down triggers a shake.

Kasendwa avatar Nov 22 '24 14:11 Kasendwa

I also noticed this and needed to make some adjustments. I asked GPT to make some changes to this as well. It works relatively well. Here is the result:

import { DeviceMotion } from "expo-sensors";
import { useEffect, useState } from "react";

/**
 * Configuration options for the shake detection
 */
interface UseShakeOptions {
  /** Threshold for shake detection sensitivity (default: 800) */
  threshold?: number;
  /** Minimum time (in ms) between shake detections (default: 1000) */
  debounceMs?: number;
  /** Update interval for device motion (default: 100) */
  updateInterval?: number;
  /** Number of movements required for shake detection (default: 2) */
  requiredMovements?: number;
}

/**
 * A hook that detects device shake events using the device motion
 *
 * @param callback - Function to be called when a shake is detected
 * @param options - Configuration options for shake detection
 * @param options.threshold - Sensitivity threshold (lower = more sensitive)
 * @param options.debounceMs - Minimum time between shake detections
 * @param options.updateInterval - Update interval for device motion
 * @param options.requiredMovements - Number of movements required for shake
 *
 * @example
 * ```tsx
 * useShake(() => {
 *   console.log('Device was shaken!');
 * }, {
 *   threshold: 1200,
 *   debounceMs: 500,
 *   updateInterval: 50,
 *   requiredMovements: 2
 * });
 * ```
 *
 * @returns Whether the device is currently shaking
 */
function useShake(
  callback?: () => void,
  {
    threshold = 800,
    debounceMs = 1000,
    updateInterval = 100,
    requiredMovements = 2,
  }: UseShakeOptions = {},
) {
  const [isShaking, setIsShaking] = useState(false);

  useEffect(() => {
    let movementCount = 0;
    let lastDirection = 0;
    let lastUpdate = 0;
    let lastShake = 0;
    let lastX = 0;
    let lastY = 0;
    let lastZ = 0;

    const subscription = DeviceMotion.addListener((data) => {
      const { x, y, z } = data.accelerationIncludingGravity;
      const currentTime = Date.now();

      if (currentTime - lastUpdate > updateInterval) {
        const diffTime = currentTime - lastUpdate;
        const speed = (Math.abs(x + y + z - lastX - lastY - lastZ) / diffTime) * 10000;
        const direction = Math.sign(x + y + z - lastX - lastY - lastZ);

        if (speed > threshold) {
          if (direction !== lastDirection && currentTime - lastUpdate > 50) {
            movementCount++;
            lastDirection = direction;
          }

          if (movementCount >= requiredMovements && currentTime - lastShake > debounceMs) {
            lastShake = currentTime;
            setIsShaking(true);
            setTimeout(() => (setIsShaking(false), (movementCount = 0)), 300);
            callback?.();
          }
        } else if (currentTime - lastUpdate > 300) {
          movementCount = 0;
        }

        lastUpdate = currentTime;
        lastX = x;
        lastY = y;
        lastZ = z;
      }
    });

    DeviceMotion.setUpdateInterval(updateInterval);

    return () => subscription?.remove();
  }, [debounceMs, requiredMovements, threshold, updateInterval, callback]);

  return isShaking;
}

export { useShake };

djalmajr avatar Nov 22 '24 23:11 djalmajr

  ShakeDetector({
    onShake: () => {
      if (appIsReady) {
        console.log('Shake detected!');
        Sentry.showFeedbackWidget();
      }
    },
    sensitivity: 'medium',
  });
import { useEffect, useRef } from 'react';
import { EventSubscription } from 'expo-notifications';
import { DeviceMotion } from 'expo-sensors';

import { useAppState } from '../hooks/useAppState';

export function useShakeDetector(
  onShake: () => void,
  options: {
    timeBetweenShakes?: number;
    requiredForce?: number;
    minRecordedShakes?: number;
  },
) {
  const MIN_TIME_BETWEEN_SHAKES_MS = options.timeBetweenShakes || 600;
  const REQUIRED_FORCE = options.requiredForce || DeviceMotion.Gravity * 1.33;
  const MIN_RECORDED_SHAKES = options.minRecordedShakes || 3;

  const { isActive } = useAppState();

  const shakeState = useRef({
    numShakes: 0,
    accelerationX: 0,
    accelerationY: 0,
    accelerationZ: 0,
    lastShakeTimestamp: 0,
  });

  const resetShakeDetection = () => {
    shakeState.current.numShakes = 0;
    shakeState.current.accelerationX = 0;
    shakeState.current.accelerationY = 0;
    shakeState.current.accelerationZ = 0;
  };

  useEffect(() => {
    let subscription: EventSubscription | undefined;

    const startListening = async () => {
      const isAvailable = await DeviceMotion.isAvailableAsync();
      if (!isAvailable) {
        console.log('DeviceMotion is not available on this device');
        return;
      }

      DeviceMotion.setUpdateInterval(100);

      subscription = DeviceMotion.addListener((data) => {
        const currentTime = Date.now();
        const state = shakeState.current;

        // Skip if we're still in cooldown period
        if (currentTime - state.lastShakeTimestamp < MIN_TIME_BETWEEN_SHAKES_MS) {
          return;
        }

        // Get acceleration data
        const ax = data.accelerationIncludingGravity?.x || 0;
        const ay = data.accelerationIncludingGravity?.y || 0;
        const az = (data.accelerationIncludingGravity?.z || 0) - DeviceMotion.Gravity;

        // Check for direction changes with sufficient force
        // only count as shake when direction changes with enough force
        if (Math.abs(ax) > REQUIRED_FORCE && ax * state.accelerationX <= 0) {
          state.numShakes++;
          state.accelerationX = ax;
        } else if (Math.abs(ay) > REQUIRED_FORCE && ay * state.accelerationY <= 0) {
          state.numShakes++;
          state.accelerationY = ay;
        } else if (Math.abs(az) > REQUIRED_FORCE && az * state.accelerationZ <= 0) {
          state.numShakes++;
          state.accelerationZ = az;
        }

        // If we've recorded enough shakes, trigger the callback
        if (state.numShakes >= MIN_RECORDED_SHAKES) {
          resetShakeDetection();
          onShake();
          state.lastShakeTimestamp = currentTime;
        }
      });
    };

    const stopListening = () => {
      if (subscription) {
        subscription.remove();
        subscription = undefined;
      }
    };

    if (isActive) {
      startListening();
    } else {
      stopListening();
    }

    return () => {
      stopListening();
    };
  }, [onShake, MIN_RECORDED_SHAKES, MIN_TIME_BETWEEN_SHAKES_MS, REQUIRED_FORCE, isActive]);
}

// Usage component
export function ShakeDetector({
  onShake,
  sensitivity = 'medium',
}: {
  onShake: () => void;
  sensitivity?: 'low' | 'medium' | 'high';
}) {
  // Allow configuring sensitivity
  const sensitivityOptions = {
    low: { minRecordedShakes: 4, requiredForce: DeviceMotion.Gravity * 1.5 },
    medium: { minRecordedShakes: 3, requiredForce: DeviceMotion.Gravity * 1.33 },
    high: { minRecordedShakes: 2, requiredForce: DeviceMotion.Gravity * 1.2 },
  };

  useShakeDetector(onShake, sensitivityOptions[sensitivity] || sensitivityOptions.medium);

  return null;
}

This worked really well for me in expo sdk 52

import { useEffect, useState } from 'react';
import { AppState, type AppStateStatus } from 'react-native';

interface AppStateReturn {
  isActive: boolean;
  isBackground: boolean;
  isForeground: boolean;
  currentState: AppStateStatus;
}

/**
 * Hook to track application state with boolean flags
 * @returns Object containing boolean state flags and current state
 */
export function useAppState(): AppStateReturn {
  const [state, setState] = useState<AppStateStatus>(AppState.currentState);

  useEffect(() => {
    const sub = AppState.addEventListener('change', (nextAppState) => {
      setState(nextAppState);
    });
    return () => sub.remove();
  }, []);

  return {
    isActive: state === 'active',
    isBackground: state === 'background',
    isForeground: state === 'active' || state === 'inactive',
    currentState: state,
  };
}

LovesWorking avatar May 14 '25 13:05 LovesWorking