WIP: better haptics API
This commit is contained in:
@@ -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
@@ -1,14 +1,17 @@
|
|||||||
import React from 'react'
|
import {useCallback} from 'react'
|
||||||
import * as Device from 'expo-device'
|
import * as Device from 'expo-device'
|
||||||
import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics'
|
import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics'
|
||||||
|
|
||||||
import {useHapticsDisabled} from '#/state/preferences/disable-haptics'
|
import {useHapticsDisabled} from '#/state/preferences/disable-haptics'
|
||||||
import {IS_IOS, IS_WEB} from '#/env'
|
import {IS_IOS, IS_WEB} from '#/env'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use `useHapticFeedback` instead
|
||||||
|
*/
|
||||||
export function useHaptics() {
|
export function useHaptics() {
|
||||||
const isHapticsDisabled = useHapticsDisabled()
|
const isHapticsDisabled = useHapticsDisabled()
|
||||||
|
|
||||||
return React.useCallback(
|
return useCallback(
|
||||||
(strength: 'Light' | 'Medium' | 'Heavy' = 'Medium') => {
|
(strength: 'Light' | 'Medium' | 'Heavy' = 'Medium') => {
|
||||||
if (isHapticsDisabled || IS_WEB) {
|
if (isHapticsDisabled || IS_WEB) {
|
||||||
return
|
return
|
||||||
@@ -18,7 +21,7 @@ export function useHaptics() {
|
|||||||
const style = IS_IOS
|
const style = IS_IOS
|
||||||
? ImpactFeedbackStyle[strength]
|
? ImpactFeedbackStyle[strength]
|
||||||
: ImpactFeedbackStyle.Light
|
: ImpactFeedbackStyle.Light
|
||||||
impactAsync(style)
|
void impactAsync(style)
|
||||||
|
|
||||||
// DEV ONLY - show a toast when a haptic is meant to fire on simulator
|
// DEV ONLY - show a toast when a haptic is meant to fire on simulator
|
||||||
if (__DEV__ && !Device.isDevice) {
|
if (__DEV__ && !Device.isDevice) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {Breakpoints} from './Breakpoints'
|
|||||||
import {Buttons} from './Buttons'
|
import {Buttons} from './Buttons'
|
||||||
import {Dialogs} from './Dialogs'
|
import {Dialogs} from './Dialogs'
|
||||||
import {Forms} from './Forms'
|
import {Forms} from './Forms'
|
||||||
|
import {Haptics} from './Haptics'
|
||||||
import {Icons} from './Icons'
|
import {Icons} from './Icons'
|
||||||
import {Links} from './Links'
|
import {Links} from './Links'
|
||||||
import {Menus} from './Menus'
|
import {Menus} from './Menus'
|
||||||
@@ -107,6 +108,7 @@ export default function Storybook() {
|
|||||||
<Theming />
|
<Theming />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|
||||||
|
<Haptics />
|
||||||
<Toasts />
|
<Toasts />
|
||||||
<Buttons />
|
<Buttons />
|
||||||
<Forms />
|
<Forms />
|
||||||
|
|||||||
Reference in New Issue
Block a user