From 488556d7ae29ea534cdd8764dfb85c3ec9107cc8 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 24 Feb 2026 21:52:01 +0200 Subject: [PATCH] WIP: better haptics API --- src/lib/haptic-feedback.ts | 115 +++++++++++++++++++++++ src/lib/haptics.ts | 9 +- src/view/screens/Storybook/Haptics.tsx | 73 ++++++++++++++ src/view/screens/Storybook/Storybook.tsx | 2 + 4 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 src/lib/haptic-feedback.ts create mode 100644 src/view/screens/Storybook/Haptics.tsx diff --git a/src/lib/haptic-feedback.ts b/src/lib/haptic-feedback.ts new file mode 100644 index 0000000000..03f9e72558 --- /dev/null +++ b/src/lib/haptic-feedback.ts @@ -0,0 +1,115 @@ +import {useMemo} from 'react' +import { + AndroidHaptics, + impactAsync, + ImpactFeedbackStyle, + notificationAsync, + NotificationFeedbackType, + performAndroidHapticsAsync, +} from 'expo-haptics' + +import {useHapticsDisabled} from '#/state/preferences' +import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env' + +export enum IOS_HAPTICS { + Light = 'light', + Medium = 'medium', + Heavy = 'heavy', + Rigid = 'rigid', + Soft = 'soft', +} + +export enum ANDROID_HAPTICS { + ClockTick = 'clock-tick', + Confirm = 'confirm', + ContextClick = 'context-click', + DragStart = 'drag-start', + GestureEnd = 'gesture-end', + GestureStart = 'gesture-start', + KeyboardPress = 'keyboard-press', + KeyboardRelease = 'keyboard-release', + KeyboardTap = 'keyboard-tap', + LongPress = 'long-press', + Reject = 'reject', + SegmentFrequentTick = 'segment-frequent-tick', + SegmentTick = 'segment-tick', + TextHandleMove = 'text-handle-move', + ToggleOff = 'toggle-off', + ToggleOn = 'toggle-on', + VirtualKey = 'virtual-key', + VirtualKeyRelease = 'virtual-key-release', +} + +const IOS_TO_IMPACT: Record = { + [IOS_HAPTICS.Light]: ImpactFeedbackStyle.Light, + [IOS_HAPTICS.Medium]: ImpactFeedbackStyle.Medium, + [IOS_HAPTICS.Heavy]: ImpactFeedbackStyle.Heavy, + [IOS_HAPTICS.Rigid]: ImpactFeedbackStyle.Rigid, + [IOS_HAPTICS.Soft]: ImpactFeedbackStyle.Soft, +} + +const ANDROID_TO_HAPTICS: Record = { + [ANDROID_HAPTICS.ClockTick]: AndroidHaptics.Clock_Tick, + [ANDROID_HAPTICS.Confirm]: AndroidHaptics.Confirm, + [ANDROID_HAPTICS.ContextClick]: AndroidHaptics.Context_Click, + [ANDROID_HAPTICS.DragStart]: AndroidHaptics.Drag_Start, + [ANDROID_HAPTICS.GestureEnd]: AndroidHaptics.Gesture_End, + [ANDROID_HAPTICS.GestureStart]: AndroidHaptics.Gesture_Start, + [ANDROID_HAPTICS.KeyboardPress]: AndroidHaptics.Keyboard_Press, + [ANDROID_HAPTICS.KeyboardRelease]: AndroidHaptics.Keyboard_Release, + [ANDROID_HAPTICS.KeyboardTap]: AndroidHaptics.Keyboard_Tap, + [ANDROID_HAPTICS.LongPress]: AndroidHaptics.Long_Press, + [ANDROID_HAPTICS.Reject]: AndroidHaptics.Reject, + [ANDROID_HAPTICS.SegmentFrequentTick]: AndroidHaptics.Segment_Frequent_Tick, + [ANDROID_HAPTICS.SegmentTick]: AndroidHaptics.Segment_Tick, + [ANDROID_HAPTICS.TextHandleMove]: AndroidHaptics.Text_Handle_Move, + [ANDROID_HAPTICS.ToggleOff]: AndroidHaptics.Toggle_Off, + [ANDROID_HAPTICS.ToggleOn]: AndroidHaptics.Toggle_On, + [ANDROID_HAPTICS.VirtualKey]: AndroidHaptics.Virtual_Key, + [ANDROID_HAPTICS.VirtualKeyRelease]: AndroidHaptics.Virtual_Key_Release, +} + +async function noop() {} + +export function useHapticFeedback() { + const isHapticsDisabled = useHapticsDisabled() + + return useMemo(() => { + const disabled = IS_WEB || isHapticsDisabled + + if (disabled) { + return { + success: noop, + error: noop, + platform: noop, + } + } + + return { + success: async () => { + if (IS_IOS) { + await notificationAsync(NotificationFeedbackType.Success) + } else { + await performAndroidHapticsAsync(AndroidHaptics.Confirm) + } + }, + error: async () => { + if (IS_IOS) { + await notificationAsync(NotificationFeedbackType.Error) + } else { + await performAndroidHapticsAsync(AndroidHaptics.Reject) + } + }, + platform: async (opts: { + ios?: IOS_HAPTICS + android?: ANDROID_HAPTICS + }) => { + if (IS_IOS && opts.ios != null) { + await impactAsync(IOS_TO_IMPACT[opts.ios]) + } else if (IS_ANDROID && opts.android != null) { + await performAndroidHapticsAsync(ANDROID_TO_HAPTICS[opts.android]) + } + }, + } + }, [isHapticsDisabled]) +} diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts index 475fd6f94e..55856856f5 100644 --- a/src/lib/haptics.ts +++ b/src/lib/haptics.ts @@ -1,14 +1,17 @@ -import React from 'react' +import {useCallback} from 'react' import * as Device from 'expo-device' import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics' import {useHapticsDisabled} from '#/state/preferences/disable-haptics' import {IS_IOS, IS_WEB} from '#/env' +/** + * @deprecated use `useHapticFeedback` instead + */ export function useHaptics() { const isHapticsDisabled = useHapticsDisabled() - return React.useCallback( + return useCallback( (strength: 'Light' | 'Medium' | 'Heavy' = 'Medium') => { if (isHapticsDisabled || IS_WEB) { return @@ -18,7 +21,7 @@ export function useHaptics() { const style = IS_IOS ? ImpactFeedbackStyle[strength] : ImpactFeedbackStyle.Light - impactAsync(style) + void impactAsync(style) // DEV ONLY - show a toast when a haptic is meant to fire on simulator if (__DEV__ && !Device.isDevice) { diff --git a/src/view/screens/Storybook/Haptics.tsx b/src/view/screens/Storybook/Haptics.tsx new file mode 100644 index 0000000000..b0629b9c16 --- /dev/null +++ b/src/view/screens/Storybook/Haptics.tsx @@ -0,0 +1,73 @@ +import {View} from 'react-native' + +import { + ANDROID_HAPTICS, + IOS_HAPTICS, + useHapticFeedback, +} from '#/lib/haptic-feedback' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {Text} from '#/components/Typography' + +export function Haptics() { + const t = useTheme() + const haptics = useHapticFeedback() + + return ( + + Haptics + + Semantic + + + + + + iOS + + UIImpactFeedbackGenerator.FeedbackStyle + + + {Object.entries(IOS_HAPTICS).map(([name, value]) => ( + + ))} + + + Android + + performHapticFeedback + + + {Object.entries(ANDROID_HAPTICS).map(([name, value]) => ( + + ))} + + + ) +} diff --git a/src/view/screens/Storybook/Storybook.tsx b/src/view/screens/Storybook/Storybook.tsx index ee7892fa83..9abc4eac20 100644 --- a/src/view/screens/Storybook/Storybook.tsx +++ b/src/view/screens/Storybook/Storybook.tsx @@ -16,6 +16,7 @@ import {Breakpoints} from './Breakpoints' import {Buttons} from './Buttons' import {Dialogs} from './Dialogs' import {Forms} from './Forms' +import {Haptics} from './Haptics' import {Icons} from './Icons' import {Links} from './Links' import {Menus} from './Menus' @@ -107,6 +108,7 @@ export default function Storybook() { +