Skip to content

Commit d9785e8

Browse files
committed
Add react-native-pulsar as a top-priority haptics adapter
1 parent 828c6d6 commit d9785e8

10 files changed

Lines changed: 147 additions & 14 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ React Native Sortables is a powerful and easy-to-use library that brings smooth,
4444

4545
- **Auto-scrolling** beyond screen bounds
4646
- Customizable **layout animations** for items addition and removal
47-
- 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)
47+
- 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)
4848
- Different **reordering strategies** (insertion, swapping)
4949

5050
- 💡 **Developer Experience**

packages/docs/docs/flex/props.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -899,7 +899,7 @@ Whether haptics are enabled. Vibrations are fired when the **pressed item become
899899

900900
:::important
901901

902-
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.
902+
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.
903903

904904
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.
905905

packages/docs/docs/getting-started.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ Before getting started, you need to install and configure the following dependen
1919

2020
### Optional Dependencies
2121

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

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

2728
## 2. Installation
2829

packages/docs/docs/grid/props.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -785,7 +785,7 @@ Whether haptics are enabled. Vibrations are fired when the **pressed item become
785785

786786
:::important
787787

788-
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.
788+
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.
789789

790790
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.
791791

packages/react-native-sortables/src/integrations/haptics/adapters/expo-haptics.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515

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

18-
const load = () => {
18+
import type { HapticsAdapter, HapticTrigger } from './types';
19+
20+
const load = (): HapticTrigger | null => {
1921
const expoHaptics = (globalThis as any).expo?.modules?.ExpoHaptics;
2022

2123
if (!expoHaptics?.impactAsync) {
@@ -35,7 +37,7 @@ const load = () => {
3537
}
3638
};
3739

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

47-
const ExpoHaptics = { load };
49+
const ExpoHaptics: HapticsAdapter = { load, name: 'expo-haptics' };
4850

4951
export default ExpoHaptics;
Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,25 @@
11
import ExpoHaptics from './expo-haptics';
2+
import Pulsar from './pulsar';
23
import ReactNativeHapticFeedback from './react-native-haptic-feedback';
4+
import type { HapticsAdapter } from './types';
5+
6+
// Tried in priority order; the first available backend provides the trigger.
7+
const ADAPTERS: Array<HapticsAdapter> = [
8+
Pulsar,
9+
ExpoHaptics,
10+
// Must stay last - always returns a trigger (never null), so it is the
11+
// terminal fallback.
12+
ReactNativeHapticFeedback
13+
];
314

415
export const Haptics = {
5-
// Prefer expo-haptics (available in any Expo app, including Expo Go) and
6-
// fall back to react-native-haptic-feedback for bare React Native apps.
7-
load: () => ExpoHaptics.load() ?? ReactNativeHapticFeedback.load()
16+
load: () => {
17+
for (const adapter of ADAPTERS) {
18+
const trigger = adapter.load();
19+
if (trigger) {
20+
return trigger;
21+
}
22+
}
23+
return null;
24+
}
825
};
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { TurboModuleRegistry } from 'react-native';
2+
3+
import Pulsar from './pulsar';
4+
5+
describe('Pulsar haptics adapter', () => {
6+
const getSpy = jest.spyOn(TurboModuleRegistry, 'get');
7+
8+
afterEach(() => {
9+
getSpy.mockReset();
10+
});
11+
12+
it('looks the module up by its registered name and bails when absent', () => {
13+
getSpy.mockReturnValue(null);
14+
15+
expect(Pulsar.load()).toBeNull();
16+
expect(getSpy).toHaveBeenCalledWith('RNPulsar');
17+
});
18+
19+
it('bails when the module does not expose Pulsar_play', () => {
20+
getSpy.mockReturnValue({} as never);
21+
22+
expect(Pulsar.load()).toBeNull();
23+
});
24+
25+
it('maps impact types to the matching Pulsar system presets', () => {
26+
const play = jest.fn();
27+
// eslint-disable-next-line camelcase -- Pulsar's native Turbo Module method name
28+
getSpy.mockReturnValue({ Pulsar_play: play } as never);
29+
30+
const trigger = Pulsar.load();
31+
expect(trigger).not.toBeNull();
32+
33+
trigger?.('impactMedium');
34+
trigger?.('impactLight');
35+
trigger?.();
36+
37+
expect(play.mock.calls).toEqual([
38+
['SystemImpactMedium'],
39+
['SystemImpactLight'],
40+
['SystemImpactLight']
41+
]);
42+
});
43+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Optional react-native-pulsar adapter.
3+
*
4+
* Pulsar exposes its haptics through a Turbo Module ('RNPulsar'), so it only
5+
* works on the New Architecture and never in Expo Go. Like the expo-haptics
6+
* adapter, we never import the package: we look its native module up in the
7+
* Turbo Module registry, so Pulsar is used automatically when a consumer has
8+
* installed it and ignored everywhere else. That keeps it out of the
9+
* dependency tree and leaves bare old-architecture builds untouched.
10+
*
11+
* `Pulsar_play` is synchronous and Pulsar's own presets call it from worklets,
12+
* so we fire it directly on the UI thread without hopping to JS.
13+
*/
14+
15+
import { type TurboModule, TurboModuleRegistry } from 'react-native';
16+
17+
import type { HapticsAdapter, HapticTrigger } from './types';
18+
19+
// Native preset names backing Pulsar's `Presets.System.impact*` helpers.
20+
const PRESET = {
21+
impactLight: 'SystemImpactLight',
22+
impactMedium: 'SystemImpactMedium'
23+
} as const;
24+
25+
interface PulsarModule extends TurboModule {
26+
Pulsar_play(name: string): void;
27+
}
28+
29+
const load = (): HapticTrigger | null => {
30+
const pulsar = TurboModuleRegistry.get<PulsarModule>('RNPulsar');
31+
32+
// Touching the method on the JS thread also primes the prototype cache so it
33+
// stays callable from the UI worklet (matches Pulsar's own workaround).
34+
if (!pulsar?.Pulsar_play) {
35+
return null;
36+
}
37+
38+
const trigger: HapticTrigger = (type = 'impactLight') => {
39+
'worklet';
40+
// eslint-disable-next-line new-cap -- external Turbo Module method name
41+
pulsar.Pulsar_play(
42+
type === 'impactMedium' ? PRESET.impactMedium : PRESET.impactLight
43+
);
44+
};
45+
46+
return trigger;
47+
};
48+
49+
const Pulsar: HapticsAdapter = {
50+
load,
51+
name: 'react-native-pulsar'
52+
};
53+
54+
export default Pulsar;

packages/react-native-sortables/src/integrations/haptics/adapters/react-native-haptic-feedback.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { HapticOptions } from 'react-native-haptic-feedback';
1818
import { runOnJS } from 'react-native-reanimated';
1919

2020
import { logger } from '../../../utils';
21+
import type { HapticsAdapter } from './types';
2122

2223
const WARNINGS = {
2324
notAvailable: 'react-native-haptic-feedback is not available'
@@ -88,8 +89,9 @@ const load = () => {
8889
}
8990
};
9091

91-
const ReactNativeHapticFeedback = {
92-
load
92+
const ReactNativeHapticFeedback: HapticsAdapter = {
93+
load,
94+
name: 'react-native-haptic-feedback'
9395
};
9496

9597
export default ReactNativeHapticFeedback;
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export type HapticType = 'impactLight' | 'impactMedium';
2+
3+
/** Worklet-safe haptic trigger, fired on the UI thread. */
4+
export type HapticTrigger = (type?: HapticType) => void;
5+
6+
/**
7+
* A haptics backend. `load` returns a trigger when the backing library is
8+
* available at runtime, or `null` so the next adapter in the registry can
9+
* take over.
10+
*/
11+
export type HapticsAdapter = {
12+
name: string;
13+
load: () => HapticTrigger | null;
14+
};

0 commit comments

Comments
 (0)