WIP: better haptics API

This commit is contained in:
Samuel Newman
2026-02-24 21:52:01 +02:00
parent bc61314c41
commit 488556d7ae
4 changed files with 196 additions and 3 deletions
+115
View File
@@ -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, ImpactFeedbackStyle> = {
[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, AndroidHaptics> = {
[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])
}
+6 -3
View File
@@ -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) {
+73
View File
@@ -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 (
<View style={[a.gap_md]}>
<Text style={[a.font_bold, a.text_5xl]}>Haptics</Text>
<Text style={[a.font_bold, a.text_2xl]}>Semantic</Text>
<View style={[a.flex_row, a.gap_sm, a.flex_wrap]}>
<Button
label="Success haptic"
size="small"
color="primary"
onPress={() => haptics.success()}>
<ButtonText>success</ButtonText>
</Button>
<Button
label="Error haptic"
size="small"
color="negative"
onPress={() => haptics.error()}>
<ButtonText>error</ButtonText>
</Button>
</View>
<Text style={[a.font_bold, a.text_2xl]}>iOS</Text>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
UIImpactFeedbackGenerator.FeedbackStyle
</Text>
<View style={[a.flex_row, a.gap_sm, a.flex_wrap]}>
{Object.entries(IOS_HAPTICS).map(([name, value]) => (
<Button
key={name}
label={`${name} haptic`}
size="small"
color="secondary"
onPress={() => haptics.platform({ios: value})}>
<ButtonText>{name}</ButtonText>
</Button>
))}
</View>
<Text style={[a.font_bold, a.text_2xl]}>Android</Text>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
performHapticFeedback
</Text>
<View style={[a.flex_row, a.gap_sm, a.flex_wrap]}>
{Object.entries(ANDROID_HAPTICS).map(([name, value]) => (
<Button
key={name}
label={`${name} haptic`}
size="small"
color="secondary"
onPress={() => haptics.platform({android: value})}>
<ButtonText>{name}</ButtonText>
</Button>
))}
</View>
</View>
)
}
+2
View File
@@ -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() {
<Theming />
</ThemeProvider>
<Haptics />
<Toasts />
<Buttons />
<Forms />