Skip to content

Commit bcf33b3

Browse files
committed
feat: adding android haptic module
1 parent b026544 commit bcf33b3

6 files changed

Lines changed: 313 additions & 30 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+
<uses-permission android:name="android.permission.VIBRATE"/>
23
</manifest>
Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,106 @@
11
package com.haptics
22

3+
import android.content.Context
4+
import android.os.Build
5+
import android.os.Vibrator
6+
import android.os.VibrationEffect
7+
import com.haptics.HapticsVibrationType
8+
import android.view.HapticFeedbackConstants
9+
import com.facebook.react.bridge.Promise
310
import com.facebook.react.bridge.ReactApplicationContext
411
import com.facebook.react.module.annotations.ReactModule
512

613
@ReactModule(name = HapticsModule.NAME)
714
class HapticsModule(reactContext: ReactApplicationContext) :
815
NativeHapticsSpec(reactContext) {
916

17+
private val vibrator: Vibrator? by lazy {
18+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
19+
val vibratorManager = reactContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as android.os.VibratorManager
20+
vibratorManager.defaultVibrator
21+
} else {
22+
@Suppress("DEPRECATION")
23+
reactContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
24+
}
25+
}
26+
1027
override fun getName(): String {
1128
return NAME
1229
}
1330

31+
override fun impact(style: String, promise: Promise) {
32+
try {
33+
val vibrationType = HapticsUtils.getImpactType(style)
34+
vibrate(vibrationType)
35+
promise.resolve(null)
36+
} catch (e: IllegalArgumentException) {
37+
promise.reject("E_INVALID_STYLE", e.message)
38+
} catch (e: Exception) {
39+
promise.reject("E_UNEXPECTED", "An unexpected error occurred: ${e.message}", e)
40+
}
41+
}
42+
43+
override fun notification(type: String, promise: Promise) {
44+
try {
45+
val vibrationType = HapticsUtils.getNotificationType(type)
46+
vibrate(vibrationType)
47+
promise.resolve(null)
48+
} catch (e: IllegalArgumentException) {
49+
promise.reject("E_INVALID_TYPE", e.message)
50+
} catch (e: Exception) {
51+
promise.reject("E_UNEXPECTED", "An unexpected error occurred: ${e.message}", e)
52+
}
53+
}
54+
55+
override fun selection(promise: Promise) {
56+
try {
57+
vibrate(HapticsUtils.getSelectionType())
58+
promise.resolve(null)
59+
} catch (e: Exception) {
60+
promise.reject("E_UNEXPECTED", "An unexpected error occurred: ${e.message}", e)
61+
}
62+
}
63+
64+
override fun androidHaptics(type: String, promise: Promise) {
65+
try {
66+
val view = currentActivity?.window?.decorView
67+
68+
if (view == null) {
69+
promise.reject("E_NO_VIEW", "Could not get the current view.")
70+
return
71+
}
72+
val feedbackConstant = HapticsUtils.getAndroidHapticsType(type)
73+
74+
reactApplicationContext.runOnUiQueueThread {
75+
view.performHapticFeedback(feedbackConstant)
76+
}
77+
promise.resolve(null)
78+
} catch (e: IllegalArgumentException) {
79+
promise.reject("E_INVALID_TYPE", e.message)
80+
} catch (e: Exception) {
81+
promise.reject("E_HAPTIC_FEEDBACK", "An unexpected error occurred: ${e.message}", e)
82+
}
83+
}
84+
85+
private fun vibrate(type: HapticsVibrationType) {
86+
if (vibrator?.hasVibrator() == false) {
87+
return
88+
}
89+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
90+
if (vibrator?.hasAmplitudeControl() == true) {
91+
val effect = VibrationEffect.createWaveform(type.timings, type.amplitudes, -1)
92+
vibrator?.vibrate(effect)
93+
} else {
94+
val effect = VibrationEffect.createWaveform(type.timings, -1)
95+
vibrator?.vibrate(effect)
96+
}
97+
} else {
98+
@Suppress("DEPRECATION")
99+
vibrator?.vibrate(type.oldFallback, -1)
100+
}
101+
}
102+
14103
companion object {
15104
const val NAME = "Haptics"
16105
}
17-
}
106+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package com.haptics
2+
3+
import android.os.Build
4+
import com.haptics.HapticsVibrationType
5+
import android.view.HapticFeedbackConstants
6+
7+
object HapticsUtils {
8+
private data class HapticInfo(val constant: Int, val requiredApi: Int)
9+
10+
private val notificationTypes = mapOf(
11+
"success" to HapticsVibrationType(
12+
timings = longArrayOf(0, 25, 80, 35),
13+
amplitudes = intArrayOf(0, 120, 0, 180),
14+
oldFallback = longArrayOf(0, 25, 80, 35)
15+
),
16+
"warning" to HapticsVibrationType(
17+
timings = longArrayOf(0, 20, 100, 50),
18+
amplitudes = intArrayOf(0, 200, 0, 255),
19+
oldFallback = longArrayOf(0, 20, 100, 50)
20+
),
21+
"error" to HapticsVibrationType(
22+
timings = longArrayOf(0, 20, 50, 20, 50, 20),
23+
amplitudes = intArrayOf(0, 150, 0, 200, 0, 255),
24+
oldFallback = longArrayOf(0, 20, 50, 20, 50, 20)
25+
)
26+
)
27+
28+
private val impactTypes = mapOf(
29+
"light" to HapticsVibrationType(
30+
timings = longArrayOf(0, 20),
31+
amplitudes = intArrayOf(0, 110),
32+
oldFallback = longArrayOf(0, 20)
33+
),
34+
"soft" to HapticsVibrationType(
35+
timings = longArrayOf(0, 50),
36+
amplitudes = intArrayOf(0, 100),
37+
oldFallback = longArrayOf(0, 50)
38+
),
39+
"medium" to HapticsVibrationType(
40+
timings = longArrayOf(0, 40),
41+
amplitudes = intArrayOf(0, 180),
42+
oldFallback = longArrayOf(0, 40)
43+
),
44+
"rigid" to HapticsVibrationType(
45+
timings = longArrayOf(0, 30),
46+
amplitudes = intArrayOf(0, 220),
47+
oldFallback = longArrayOf(0, 30)
48+
),
49+
"heavy" to HapticsVibrationType(
50+
timings = longArrayOf(0, 60),
51+
amplitudes = intArrayOf(0, 255),
52+
oldFallback = longArrayOf(0, 60)
53+
)
54+
)
55+
56+
private val ALL_HAPTIC_TYPES = mapOf(
57+
"long-press" to HapticInfo(HapticFeedbackConstants.LONG_PRESS, 1),
58+
"clock-tick" to HapticInfo(HapticFeedbackConstants.CLOCK_TICK, 1),
59+
"virtual-key" to HapticInfo(HapticFeedbackConstants.VIRTUAL_KEY, 1),
60+
"keyboard-tap" to HapticInfo(HapticFeedbackConstants.KEYBOARD_TAP, 1),
61+
"reject" to HapticInfo(HapticFeedbackConstants.REJECT, Build.VERSION_CODES.R),
62+
"confirm" to HapticInfo(HapticFeedbackConstants.CONFIRM, Build.VERSION_CODES.R),
63+
"gesture-end" to HapticInfo(HapticFeedbackConstants.GESTURE_END, Build.VERSION_CODES.R),
64+
"gesture-start" to HapticInfo(HapticFeedbackConstants.GESTURE_START, Build.VERSION_CODES.R),
65+
"context-click" to HapticInfo(HapticFeedbackConstants.CONTEXT_CLICK, Build.VERSION_CODES.M),
66+
"keyboard-press" to HapticInfo(HapticFeedbackConstants.KEYBOARD_PRESS, Build.VERSION_CODES.O_MR1),
67+
"toggle-on" to HapticInfo(HapticFeedbackConstants.TOGGLE_ON, Build.VERSION_CODES.UPSIDE_DOWN_CAKE),
68+
"toggle-off" to HapticInfo(HapticFeedbackConstants.TOGGLE_OFF, Build.VERSION_CODES.UPSIDE_DOWN_CAKE),
69+
"drag-start" to HapticInfo(HapticFeedbackConstants.DRAG_START, Build.VERSION_CODES.UPSIDE_DOWN_CAKE),
70+
"keyboard-release" to HapticInfo(HapticFeedbackConstants.KEYBOARD_RELEASE, Build.VERSION_CODES.O_MR1),
71+
"text-handle-move" to HapticInfo(HapticFeedbackConstants.TEXT_HANDLE_MOVE, Build.VERSION_CODES.O_MR1),
72+
"no-haptics" to HapticInfo(HapticFeedbackConstants.NO_HAPTICS, Build.VERSION_CODES.UPSIDE_DOWN_CAKE),
73+
"segment-tick" to HapticInfo(HapticFeedbackConstants.SEGMENT_TICK, Build.VERSION_CODES.UPSIDE_DOWN_CAKE),
74+
"virtual-key-release" to HapticInfo(HapticFeedbackConstants.VIRTUAL_KEY_RELEASE, Build.VERSION_CODES.O_MR1),
75+
"segment-frequent-tick" to HapticInfo(HapticFeedbackConstants.SEGMENT_FREQUENT_TICK, Build.VERSION_CODES.UPSIDE_DOWN_CAKE),
76+
"gesture-threshold-activate" to HapticInfo(HapticFeedbackConstants.GESTURE_THRESHOLD_ACTIVATE, Build.VERSION_CODES.UPSIDE_DOWN_CAKE),
77+
"gesture-threshold-deactivate" to HapticInfo(HapticFeedbackConstants.GESTURE_THRESHOLD_DEACTIVATE, Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
78+
)
79+
80+
fun getNotificationType(type: String): HapticsVibrationType =
81+
notificationTypes[type] ?: throw IllegalArgumentException("'type' must be one of ${notificationTypes.keys}. Obtained '$type'.")
82+
83+
fun getImpactType(style: String): HapticsVibrationType =
84+
impactTypes[style] ?: throw IllegalArgumentException("'style' must be one of ${impactTypes.keys}. Obtained '$style'.")
85+
86+
fun getSelectionType(): HapticsVibrationType =
87+
HapticsVibrationType(
88+
timings = longArrayOf(0, 10),
89+
amplitudes = intArrayOf(0, 90),
90+
oldFallback = longArrayOf(0, 10)
91+
)
92+
93+
fun getAndroidHapticsType(type: String): Int {
94+
val hapticInfo = ALL_HAPTIC_TYPES[type]
95+
?: throw IllegalArgumentException(
96+
"'type' must be one of ${ALL_HAPTIC_TYPES.keys.joinToString()}. Obtained '$type'."
97+
)
98+
return if (Build.VERSION.SDK_INT >= hapticInfo.requiredApi) {
99+
hapticInfo.constant
100+
} else {
101+
HapticFeedbackConstants.VIRTUAL_KEY
102+
}
103+
}
104+
}
105+
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.haptics
2+
3+
data class HapticsVibrationType(
4+
val timings: LongArray,
5+
val amplitudes: IntArray,
6+
val oldFallback: LongArray
7+
) {
8+
override fun equals(other: Any?): Boolean {
9+
if (this === other) return true
10+
if (javaClass != other?.javaClass) return false
11+
other as HapticsVibrationType
12+
if (!timings.contentEquals(other.timings)) return false
13+
if (!amplitudes.contentEquals(other.amplitudes)) return false
14+
if (!oldFallback.contentEquals(other.oldFallback)) return false
15+
return true
16+
}
17+
18+
override fun hashCode(): Int {
19+
var result = timings.contentHashCode()
20+
result = 31 * result + amplitudes.contentHashCode()
21+
result = 31 * result + oldFallback.contentHashCode()
22+
return result
23+
}
24+
}

src/NativeHaptics.ts

Lines changed: 82 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,111 @@
11
import type {TurboModule} from 'react-native';
22
import {TurboModuleRegistry} from 'react-native';
33

4-
export type NotificationFeedback = 'error' | 'success' | 'warning';
5-
export type ImpactFeedback = 'soft' | 'light' | 'rigid' | 'heavy' | 'medium';
4+
/**
5+
* Represents the feedback type for notifications, corresponding to Apple's `UINotificationFeedbackGenerator.FeedbackType`.
6+
* On Android, the behavior is simulated to match the iOS equivalent as closely as possible.
7+
* @see https://developer.apple.com/documentation/uikit/uinotificationfeedbackgenerator/feedbacktype
8+
*
9+
* - `success`: Indicates that a task or action has completed successfully.
10+
* - `warning`: Indicates that a task or action has produced a warning.
11+
* - `error`: Indicates that a task or action has failed.
12+
*/
13+
export type NotificationFeedback = 'success' | 'warning' | 'error';
14+
15+
/**
16+
* Specifies the intensity of a haptic impact, corresponding to Apple's `UIImpactFeedbackGenerator.FeedbackStyle`.
17+
* On Android, the behavior is simulated to match the iOS equivalent.
18+
* @see https://developer.apple.com/documentation/uikit/uiimpactfeedbackgenerator/feedbackstyle
19+
*
20+
* - `light`: A collision between small, light user interface elements.
21+
* - `medium`: A collision between moderately sized user interface elements.
22+
* - `heavy`: A collision between large, heavy user interface elements.
23+
* - `soft`: A collision between user interface elements that are soft, with a large amount of compression or elasticity. (iOS 13.0+)
24+
* - `rigid`: A collision between user interface elements that are rigid, with a small amount of compression or elasticity. (iOS 13.0+)
25+
*/
26+
export type ImpactFeedback = 'light' | 'medium' | 'heavy' | 'soft' | 'rigid';
27+
28+
/**
29+
* A set of predefined haptic types corresponding to Android's native `HapticFeedbackConstants`.
30+
*
31+
* **Note:** Availability varies by Android SDK version. If a requested type is
32+
* unsupported on the device, it safely falls back to `virtual-key`.
33+
* @platform android
34+
* @see https://developer.android.com/reference/android/view/HapticFeedbackConstants
35+
*
36+
* - `clock-tick`: Feedback for a clock tick, e.g., while setting the time.
37+
* - `confirm`: Confirms a user's action. (Requires API 30+)
38+
* - `context-click`: Feedback for a context-click or right-click. (Requires API 23+)
39+
* - `drag-start`: Indicates the start of a drag action. (Requires API 34+)
40+
* - `gesture-end`: Indicates the end of a gesture. (Requires API 30+)
41+
* - `gesture-start`: Indicates the start of a gesture. (Requires API 30+)
42+
* - `gesture-threshold-activate`: Indicates the activation of a gesture threshold. (Requires API 34+)
43+
* - `gesture-threshold-deactivate`: Indicates the deactivation of a gesture threshold. (Requires API 34+)
44+
* - `keyboard-press`: Feedback for pressing a key on a soft keyboard. (Requires API 27+)
45+
* - `keyboard-release`: Feedback for releasing a key on a soft keyboard. (Requires API 27+)
46+
* - `keyboard-tap`: Feedback for a tap on a soft keyboard key.
47+
* - `long-press`: Feedback for a long press on an object.
48+
* - `no-haptics`: Explicitly specifies that no haptic feedback should be provided. (Requires API 34+)
49+
* - `reject`: Rejects a user's action. (Requires API 30+)
50+
* - `segment-frequent-tick`: A frequent tick in a segmented control. (Requires API 34+)
51+
* - `segment-tick`: A tick in a segmented control. (Requires API 34+)
52+
* - `text-handle-move`: Feedback for moving a text selection handle. (Requires API 27+)
53+
* - `toggle-off`: Indicates a toggle has been turned off. (Requires API 34+)
54+
* - `toggle-on`: Indicates a toggle has been turned on. (Requires API 34+)
55+
* - `virtual-key`: Feedback for a virtual key press.
56+
* - `virtual-key-release`: Feedback for a virtual key release. (Requires API 27+)
57+
*/
658
export type AndroidHapticsFeedback =
7-
| 'reject'
59+
| 'clock-tick'
860
| 'confirm'
9-
| 'toggle-on'
10-
| 'no-haptics'
11-
| 'long-press'
61+
| 'context-click'
1262
| 'drag-start'
13-
| 'clock-tick'
14-
| 'toggle-off'
15-
| 'virtual-key'
1663
| 'gesture-end'
17-
| 'keyboard-tap'
18-
| 'segment-tick'
1964
| 'gesture-start'
20-
| 'context-click'
65+
| 'gesture-threshold-activate'
66+
| 'gesture-threshold-deactivate'
2167
| 'keyboard-press'
22-
| 'text-handle-move'
2368
| 'keyboard-release'
24-
| 'virtual-key-release'
25-
| 'segment-frequent-tick';
69+
| 'keyboard-tap'
70+
| 'long-press'
71+
| 'no-haptics'
72+
| 'reject'
73+
| 'segment-frequent-tick'
74+
| 'segment-tick'
75+
| 'text-handle-move'
76+
| 'toggle-off'
77+
| 'toggle-on'
78+
| 'virtual-key'
79+
| 'virtual-key-release';
2680

2781
export interface Spec extends TurboModule {
2882
/**
29-
* Triggers haptic feedback based on the provided selection feedback type.
83+
* Triggers a haptic feedback to indicate a selection change.
84+
* Corresponds to `UISelectionFeedbackGenerator` on iOS.
3085
* @returns A promise that resolves when the haptic feedback is completed.
3186
*/
3287
selection(): Promise<void>;
3388
/**
34-
* Triggers haptic feedback based on the provided impact style.
35-
* @param style The type of impact feedback to trigger.
89+
* Triggers an impact haptic feedback.
90+
* Corresponds to `UIImpactFeedbackGenerator` on iOS.
91+
* @param style The intensity of the impact feedback.
3692
* @returns A promise that resolves when the haptic feedback is completed.
3793
*/
3894
impact(style: ImpactFeedback): Promise<void>;
3995
/**
40-
* Triggers haptic feedback based on the provided selection feedback type.
41-
* @platform android
96+
* Triggers a notification haptic feedback to communicate success, warning, or error.
97+
* Corresponds to `UINotificationFeedbackGenerator` on iOS.
98+
* @param type The type of notification feedback to trigger.
4299
* @returns A promise that resolves when the haptic feedback is completed.
43100
*/
44-
androidHaptics(type: AndroidHapticsFeedback): Promise<void>;
101+
notification(type: NotificationFeedback): Promise<void>;
45102
/**
46-
* Triggers haptic feedback based on the provided notification type.
47-
* @param type The type of notification feedback to trigger.
103+
* Triggers a platform-specific haptic feedback on Android.
104+
* @platform android
105+
* @param type The Android-specific haptic feedback constant to use.
48106
* @returns A promise that resolves when the haptic feedback is completed.
49107
*/
50-
notification(type: NotificationFeedback): Promise<void>;
108+
androidHaptics(type: AndroidHapticsFeedback): Promise<void>;
51109
}
52110

53111
export default TurboModuleRegistry.getEnforcing<Spec>('Haptics');

0 commit comments

Comments
 (0)