Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ React Native Sortables is a powerful and easy-to-use library that brings smooth,

- **Auto-scrolling** beyond screen bounds
- Customizable **layout animations** for items addition and removal
- Built-in **haptic feedback** integration via [expo-haptics](https://docs.expo.dev/versions/latest/sdk/haptics/) or [react-native-haptic-feedback](https://github.com/mkuczera/react-native-haptic-feedback)
- Built-in **haptic feedback** integration via [react-native-pulsar](https://github.com/software-mansion/pulsar), [expo-haptics](https://docs.expo.dev/versions/latest/sdk/haptics/) or [react-native-haptic-feedback](https://github.com/mkuczera/react-native-haptic-feedback)
- Different **reordering strategies** (insertion, swapping)

- 💡 **Developer Experience**
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/docs/flex/props.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,7 @@ Whether haptics are enabled. Vibrations are fired when the **pressed item become

:::important

To use built-in haptics, install `expo-haptics` (Expo apps, including Expo Go) or `react-native-haptic-feedback` (bare React Native). The library auto-detects whichever is available. See this [Getting Started](../getting-started#optional-dependencies) section for more details.
To use built-in haptics, install one of `react-native-pulsar`, `expo-haptics` or `react-native-haptic-feedback`. The library auto-detects whichever is available. See this [Getting Started](../getting-started#optional-dependencies) section for more details.

You can also use any other haptics library but you will have to trigger haptics manually when callbacks are called. See the [Callbacks](#callbacks) section for more details.

Expand Down
7 changes: 4 additions & 3 deletions packages/docs/docs/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ Before getting started, you need to install and configure the following dependen

### Optional Dependencies

Haptic feedback is optional. The library automatically detects which haptics package is available, preferring **expo-haptics**:
Haptic feedback is optional. The library automatically detects which haptics package is available and uses the first one it finds, in this order:

- **expo-haptics**: recommended for Expo apps (already bundled in Expo Go). Install with `npx expo install expo-haptics`
- **react-native-haptic-feedback**: for bare React Native apps. Follow the installation guide in its [README](https://github.com/mkuczera/react-native-haptic-feedback)
- [react-native-pulsar](https://github.com/software-mansion/pulsar) - rich haptic presets, requires the New Architecture
- [expo-haptics](https://github.com/expo/expo/tree/main/packages/expo-haptics) - for Expo apps
- [react-native-haptic-feedback](https://github.com/mkuczera/react-native-haptic-feedback) - for the bare React Native workflow

## 2. Installation

Expand Down
2 changes: 1 addition & 1 deletion packages/docs/docs/grid/props.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -785,7 +785,7 @@ Whether haptics are enabled. Vibrations are fired when the **pressed item become

:::important

To use built-in haptics, install `expo-haptics` (Expo apps, including Expo Go) or `react-native-haptic-feedback` (bare React Native). The library auto-detects whichever is available. See this [Getting Started](../getting-started#optional-dependencies) section for more details.
To use built-in haptics, install one of `react-native-pulsar`, `expo-haptics` or `react-native-haptic-feedback`. The library auto-detects whichever is available. See this [Getting Started](../getting-started#optional-dependencies) section for more details.

You can also use any other haptics library but you will have to trigger haptics manually when callbacks are called. See the [Callbacks](#callbacks) section for more details.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@

import { runOnJS } from 'react-native-reanimated';

const load = () => {
import type { HapticsAdapter, HapticTrigger } from './types';

const load = (): HapticTrigger | null => {
const expoHaptics = (globalThis as any).expo?.modules?.ExpoHaptics;

if (!expoHaptics?.impactAsync) {
Expand All @@ -35,7 +37,7 @@ const load = () => {
}
};

const trigger = (type = 'impactLight') => {
const trigger: HapticTrigger = (type = 'impactLight') => {
'worklet';
// expo-haptics runs on the JS thread, so hop over from the UI worklet
runOnJS(impact)(type === 'impactMedium' ? 'medium' : 'light');
Expand All @@ -44,6 +46,6 @@ const load = () => {
return trigger;
};

const ExpoHaptics = { load };
const ExpoHaptics: HapticsAdapter = { load, name: 'expo-haptics' };

export default ExpoHaptics;
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
import ExpoHaptics from './expo-haptics';
import Pulsar from './pulsar';
import ReactNativeHapticFeedback from './react-native-haptic-feedback';
import type { HapticsAdapter } from './types';

// Tried in priority order; the first available backend provides the trigger.
const ADAPTERS: Array<HapticsAdapter> = [
Pulsar,
ExpoHaptics,
ReactNativeHapticFeedback
];

export const Haptics = {
// Prefer expo-haptics (available in any Expo app, including Expo Go) and
// fall back to react-native-haptic-feedback for bare React Native apps.
load: () => ExpoHaptics.load() ?? ReactNativeHapticFeedback.load()
load: () => {
for (const adapter of ADAPTERS) {
const trigger = adapter.load();
if (trigger) {
return trigger;
}
}
return null;
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { TurboModuleRegistry } from 'react-native';

import Pulsar from './pulsar';

describe('Pulsar haptics adapter', () => {
const getSpy = jest.spyOn(TurboModuleRegistry, 'get');

afterEach(() => {
getSpy.mockReset();
});

it('looks the module up by its registered name and bails when absent', () => {
getSpy.mockReturnValue(null);

expect(Pulsar.load()).toBeNull();
expect(getSpy).toHaveBeenCalledWith('RNPulsar');
});

it('bails when the module does not expose Pulsar_play', () => {
getSpy.mockReturnValue({} as never);

expect(Pulsar.load()).toBeNull();
});

it('maps impact types to the matching Pulsar system presets', () => {
const play = jest.fn();
// eslint-disable-next-line camelcase -- Pulsar's native Turbo Module method name
getSpy.mockReturnValue({ Pulsar_play: play } as never);

const trigger = Pulsar.load();
expect(trigger).not.toBeNull();

trigger?.('impactMedium');
trigger?.('impactLight');
trigger?.();

expect(play.mock.calls).toEqual([
['SystemImpactMedium'],
['SystemImpactLight'],
['SystemImpactLight']
]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Optional react-native-pulsar adapter.
*
* Pulsar exposes its haptics through a Turbo Module ('RNPulsar'), so it only
* works on the New Architecture and never in Expo Go. Like the expo-haptics
* adapter, we never import the package: we look its native module up in the
* Turbo Module registry, so Pulsar is used automatically when a consumer has
* installed it and ignored everywhere else. That keeps it out of the
* dependency tree and leaves bare old-architecture builds untouched.
*
* `Pulsar_play` is synchronous and Pulsar's own presets call it from worklets,
* so we fire it directly on the UI thread without hopping to JS.
*/

import { type TurboModule, TurboModuleRegistry } from 'react-native';

import type { HapticsAdapter, HapticTrigger } from './types';

// Native preset names backing Pulsar's `Presets.System.impact*` helpers.
const PRESET = {
impactLight: 'SystemImpactLight',
impactMedium: 'SystemImpactMedium'
} as const;

interface PulsarModule extends TurboModule {
Pulsar_play(name: string): void;
}

const load = (): HapticTrigger | null => {
const pulsar = TurboModuleRegistry.get<PulsarModule>('RNPulsar');

// Touching the method on the JS thread also primes the prototype cache so it
// stays callable from the UI worklet (matches Pulsar's own workaround).
if (!pulsar?.Pulsar_play) {
return null;
}

const trigger: HapticTrigger = (type = 'impactLight') => {
'worklet';
// eslint-disable-next-line new-cap -- external Turbo Module method name
pulsar.Pulsar_play(
type === 'impactMedium' ? PRESET.impactMedium : PRESET.impactLight
);
};

return trigger;
};

const Pulsar: HapticsAdapter = {
load,
name: 'react-native-pulsar'
};

export default Pulsar;
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { HapticOptions } from 'react-native-haptic-feedback';
import { runOnJS } from 'react-native-reanimated';

import { logger } from '../../../utils';
import type { HapticsAdapter } from './types';

const WARNINGS = {
notAvailable: 'react-native-haptic-feedback is not available'
Expand Down Expand Up @@ -88,8 +89,9 @@ const load = () => {
}
};

const ReactNativeHapticFeedback = {
load
const ReactNativeHapticFeedback: HapticsAdapter = {
load,
name: 'react-native-haptic-feedback'
};

export default ReactNativeHapticFeedback;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export type HapticType = 'impactLight' | 'impactMedium';

/** Worklet-safe haptic trigger, fired on the UI thread. */
export type HapticTrigger = (type?: HapticType) => void;

/**
* A haptics backend. `load` returns a trigger when the backing library is
* available at runtime, or `null` so the next adapter in the registry can
* take over.
*/
export type HapticsAdapter = {
name: string;
load: () => HapticTrigger | null;
};
Loading