Rork で生成したアプリの「お気に入りに追加」ボタンを押したとき、Expo Go では指先に小さな振動が返ってくるのに、TestFlight に上げた瞬間まったく無反応になる――先週もまさにこのパターンで半日詰まりました。
私は2014年から壁紙系・癒し系のアプリを個人開発で運営していて、AdMob の eCPM 計測ついでにユーザー行動を眺めていると、ハプティクスが効いているアプリと効いていないアプリでは平均セッション時間が体感で1〜2割違います。たった数十ミリ秒の触覚フィードバックなのですが、無いと「タップした感」が消えてしまうので、本番で無反応のまま気付かずリリースしてしまうと、レビュースコアにじわじわ効いてくる地味に怖い不具合です。
expo-haptics 自体のコード量はごく少ないのですが、それゆえに「動かない理由が全部外側にある」のがやっかいなところ。原因を5つに分けて、上から順に潰していくと多くのケースで直ります。
まず切り分けるべき3つの観点
調査に入る前に、以下を確認します。これだけで原因の半分は絞れます。
- 「実機の iPhone で物理的に振動しない」のか「振動はしているが弱くて気付かない」のか
- iOS だけ無反応か、Android だけ無反応か、両方無反応か
- Expo Go では動くのに EAS Build や TestFlight でだけ無反応か
シミュレータでは仕様上ハプティクスは絶対に発火しません。「シミュレータで動かない=バグ」ではないので、必ず実機で確認してください。私自身、新しい M シリーズの Mac に乗り換えた直後、シミュレータでデバッグばかりしていてここで時間を溶かしました。
原因1: iPhone 側のシステム設定でハプティクスが無効
最初に疑うべきは、ユーザー(または開発者自身)の iPhone 設定です。設定 → サウンドと触覚 → システムの触覚 がオフになっていると、expo-haptics の呼び出しは内部的にエラーを返さず黙ってスキップされます。
これは仕様で、Apple の HIG 上「触覚は OS 設定を尊重する」と書かれているため、アプリ側で強制発火させる正規 API はありません。本番でユーザーから「振動しない」報告が来たときは、まずこの設定を疑うようガイドする必要があります。
App Store のレビューで「振動しません」と書かれた星3を見て焦りましたが、原因はユーザーが触覚オフにしていただけ、というケースが体感で半数以上を占めます。アプリ内のヘルプに「触覚は iOS 設定の影響を受けます」と一文添えるだけでだいぶ減ります。
原因2: Low Power Mode で触覚エンジンが落ちる
iOS の Low Power Mode(低電力モード)に入っていると、Taptic Engine が省電力のために制限されます。Haptics.impactAsync を呼んでも音だけ鳴って振動しない、あるいはまったく無反応というケースが普通にあります。
これは Apple の公式仕様で、ドキュメントには明記が薄いものの、UIDevice.current.isLowPowerModeEnabled で取得できる状態と連動しています。React Native 側からは expo-battery で同等の判定ができます。
import * as Haptics from 'expo-haptics';
import * as Battery from 'expo-battery';
async function softTap() {
const isLowPower = await Battery.isLowPowerModeEnabledAsync();
if (isLowPower) {
// 期待する出力: 触覚は省略、視覚フィードバックだけで代替する
return;
}
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}UI 側で軽いカラーアニメーションを併走させておくと、低電力時でも「タップした感」が失われません。私が運営している壁紙アプリでは、画像保存成功時に必ず色変化+触覚のセットで返すようにしていて、低電力モードでも違和感ゼロにできています。
原因3: Android で expo-haptics を直接呼んでいる(VIBRATE 権限の罠)
Android では expo-haptics 内部で VIBRATE 権限を使います。Expo SDK 50 以降のデフォルト設定では権限が自動付与されますが、app.json で android.permissions を手動で絞っていると VIBRATE が落ちることがあります。
app.json を以下のように明示します。
{
"expo": {
"android": {
"permissions": ["VIBRATE"]
}
}
}明示的に他の権限を列挙しているのに VIBRATE を書き忘れているケースが多いので、adb shell dumpsys package <package-name> | grep VIBRATE で実機上の権限を確認します。プレビュービルドで動いていても、本番ビルドで権限プロファイルが差し変わると無反応になるパターンがありますので注意が必要です。
原因4: イベントハンドラ内で await し損ねている
Rork が生成してくる定型コードでよく見るのが、onPress 内で await せずに Haptics.impactAsync を呼び出して、その直後に画面遷移してしまうパターンです。
// ❌ 触覚が間に合わずに画面遷移してしまう
<Pressable onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
router.push('/detail');
}}>impactAsync は Promise を返すので、画面遷移が先に走ると遷移中の Activity がフォアグラウンドから外れて触覚自体がキャンセルされる挙動になります。
// ✅ 触覚を発火してから遷移する
<Pressable onPress={async () => {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
router.push('/detail');
}}>体感はわずか数十ミリ秒の差ですが、await の有無で「タップした感」が劇的に変わります。実装では「触覚→遷移」の順を守ることを徹底すると、本番で無反応に見える事象がほぼ消えます。
原因5: 連打されて触覚エンジンが詰まる
ハプティクスは内部キューを持っていて、短時間に連続発火させるとキューが詰まって以降の呼び出しが無視されます。スワイプで連続インタラクションを起こすカード型 UI などで、ユーザーが速くスワイプしたあと数秒間ハプティクスが止まる、という症状が出ます。
簡易的なスロットルで回避できます。
import * as Haptics from 'expo-haptics';
let lastHapticAt = 0;
const HAPTIC_MIN_INTERVAL = 80; // ミリ秒
export async function throttledLightImpact() {
const now = Date.now();
if (now - lastHapticAt < HAPTIC_MIN_INTERVAL) return;
lastHapticAt = now;
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}私の壁紙アプリでは、サムネをスワイプで切り替えるたびに発火するハプティクスがこれを入れる前は1秒間に8回くらい鳴ろうとして詰まっていました。80ms スロットルにすると、体感はむしろ「キレが良くなった」と感じられるくらいに改善しました。
本番リリース前に確認したいチェックリスト
ここまでの内容を本番チェックリストに落とすと次のようになります。
- 実機(iOS/Android 両方)で物理的に振動するか
- iOS のシステム触覚設定オン/オフ両方で意図通りの挙動か
- Low Power Mode 時にフォールバック動作するか
- 連打時に詰まらないか(80〜100ms スロットル推奨)
awaitで発火を待ってから遷移しているか
私の場合、TestFlight に上げた直後の Internal Testing で必ずこの5点を確認するワークフローにしてから、ハプティクス起因のレビュー指摘がほぼゼロになりました。地味な部分ですが、ここを丁寧にやるかどうかでアプリ全体の「触り心地」がはっきり変わります。
最後までお読みいただきありがとうございました。皆さんのアプリの触り心地が、少しでも気持ちよくなるきっかけになれば嬉しいです。