瞑想と呼吸法のための小さなアプリを個人開発で出したとき、画面の円はゆっくり膨らんだり縮んだりするのに、指先には何も伝わってこないことがずっと気になっていました。目を閉じて使う場面が多いアプリなのに、視覚だけで呼吸を導いていたのです。
そこで expo-haptics を入れて、息を吸う合図と吐く合図に振動を足してみました。けれど、Haptics.impactAsync() で出せるのは一瞬の「トン」という打撃感だけです。私が欲しかったのは、4秒かけてゆっくり強くなり、止まり、また8秒かけて消えていく、息そのものに寄り添う波でした。単発の振動をいくら並べても、その連続した呼吸の感触にはなりません。
iPhone の Taptic Engine は、本当はもっと繊細な表現ができます。それを引き出す仕組みが CoreHaptics です。ここでは、CoreHaptics の連続イベントとパラメータカーブを使って呼吸に同期した触覚を作り、それを Expo ネイティブモジュールとして React Native(Rork で生成したアプリ)から呼べる形にするところまでを、実装コードとともに見ていきます。
なぜ expo-haptics だけでは呼吸の波にならないのか
expo-haptics が公開しているのは、大きく二系統です。impactAsync(style) の打撃感(light / medium / heavy など)と、notificationAsync(type) の成功・警告・失敗フィードバック。どちらも「あらかじめ決められた、ごく短いパターンを一回鳴らす」ためのものです。
呼吸の波に必要なのは、これとは性質が違います。整理すると、足りないのは次の3点です。
持続 : 数秒間、途切れず鳴り続ける連続的な振動
強さの変化 : その数秒の中で、強さがなめらかに上下すること
位相の制御 : 吸う・止める・吐くという局面を、秒単位で指定できること
expo-haptics はこのいずれも提供していません。プリセットを setTimeout で並べても、間に無音の隙間ができ、「波」ではなく「点線」になってしまいます。指先に呼吸を感じてもらうには、振動そのものを連続イベントとして設計し、その強さを時間に沿って曲線で動かす必要があります。それができるのが CoreHaptics です。
やりたいこと expo-haptics CoreHaptics
一瞬のタップ感 得意 可能(transient イベント)
数秒続く連続振動 不可 continuous イベント
強さを時間でなめらかに変える 不可 パラメータカーブ
導入の手軽さ 数行で完了 ネイティブモジュールが必要
つまり選択は二者択一ではありません。タップ感は expo-haptics に任せ、呼吸の波だけを CoreHaptics で足す、という併用が現実的です。私自身もこの形に落ち着きました。
CoreHaptics の連続イベントとパラメータカーブ
CoreHaptics で呼吸を表現する鍵は、CHHapticEvent の continuous(連続) タイプと、CHHapticParameterCurve(パラメータカーブ)の組み合わせです。
連続イベントは「ここから何秒間、振動を持続させる」という指定ができます。そしてパラメータカーブは「その持続の間に、強さ(intensity)を時刻ごとにどう変えるか」を制御点で描きます。吸う息なら 0 からゆっくり立ち上げ、吐く息なら最大からゆっくり 0 へ落とす。この曲線こそが、呼吸の波の正体です。
intensity(強さ)と sharpness(鋭さ)はいずれも 0.0〜1.0 の値を取ります。sharpness を低くすると角の取れた、丸く柔らかい振動になります。瞑想アプリでは sharpness を 0.3 前後に抑えると、刺すような感触が消えて呼吸になじみます。
吸う4秒・止める7秒・吐く8秒、いわゆる 4-7-8 呼吸の1サイクルを、Swift で組み立てると次のようになります。
import CoreHaptics
func makeBreathPattern ( inhale : Double , hold : Double , exhale : Double ) throws -> CHHapticPattern {
let sharp = CHHapticEventParameter ( parameterID : .hapticSharpness, value : 0.3 )
// 吸う: 強さ 0 → 0.8 へ立ち上げる連続イベント
let inhaleEvent = CHHapticEvent (
eventType : .hapticContinuous,
parameters : [
CHHapticEventParameter ( parameterID : .hapticIntensity, value : 0.8 ),
sharp
],
relativeTime : 0 ,
duration : inhale)
let inhaleCurve = CHHapticParameterCurve (
parameterID : .hapticIntensityControl,
controlPoints : [
. init ( relativeTime : 0 , value : 0.0 ),
. init ( relativeTime : inhale, value : 1.0 )
],
relativeTime : 0 )
// 吐く: 強さ 0.8 → 0 へ落とす連続イベント(holdの分だけ後ろにずらす)
let exhaleStart = inhale + hold
let exhaleEvent = CHHapticEvent (
eventType : .hapticContinuous,
parameters : [
CHHapticEventParameter ( parameterID : .hapticIntensity, value : 0.8 ),
sharp
],
relativeTime : exhaleStart,
duration : exhale)
let exhaleCurve = CHHapticParameterCurve (
parameterID : .hapticIntensityControl,
controlPoints : [
. init ( relativeTime : exhaleStart, value : 1.0 ),
. init ( relativeTime : exhaleStart + exhale, value : 0.0 )
],
relativeTime : 0 )
// hold(止める7秒)はイベントを置かない=無音。これが「息を止める」局面になる
return try CHHapticPattern (
events : [inhaleEvent, exhaleEvent],
parameterCurves : [inhaleCurve, exhaleCurve])
}
ポイントは、止める局面に あえてイベントを置かない ことです。無音そのものが「息を止めている」という体験になります。吸う・吐くの2つの連続イベントを、強さカーブで包む。これだけで、指先に呼吸の輪郭が現れます。
Expo ネイティブモジュールとして公開する
CoreHaptics は Swift の世界の API なので、React Native から直接は呼べません。Expo Modules API を使って薄いネイティブモジュールで包み、startBreathing という関数として JavaScript 側に渡します。
import ExpoModulesCore
import CoreHaptics
public class BreathHapticModule : Module {
private var engine: CHHapticEngine ?
public func definition () -> ModuleDefinition {
Name ( "BreathHaptic" )
Function ( "isSupported" ) { () -> Bool in
CHHapticEngine. capabilitiesForHardware ().supportsHaptics
}
AsyncFunction ( "startBreathing" ) { ( inhale : Double , hold : Double , exhale : Double ) in
try self . ensureEngine ()
let pattern = try makeBreathPattern ( inhale : inhale, hold : hold, exhale : exhale)
let player = try self .engine ? . makePlayer ( with : pattern)
try player ? . start ( atTime : CHHapticTimeImmediate)
}
Function ( "stop" ) {
try? self .engine ? . stop ()
}
}
private func ensureEngine () throws {
guard CHHapticEngine. capabilitiesForHardware ().supportsHaptics else { return }
if engine == nil {
engine = try CHHapticEngine ()
}
try engine ? . start ()
}
}
これで TypeScript からは、秒数を渡すだけの素直なインターフェースになります。
import { requireNativeModule } from "expo-modules-core" ;
const BreathHaptic = requireNativeModule ( "BreathHaptic" );
export function startBreathing ( inhale = 4 , hold = 7 , exhale = 8 ) {
if ( ! BreathHaptic. isSupported ()) return false ;
BreathHaptic. startBreathing (inhale, hold, exhale);
return true ;
}
export function stopBreathing () {
BreathHaptic. stop ();
}
ネイティブの複雑さを startBreathing(4, 7, 8) の一行に閉じ込められたところが、この設計のいちばんの利点です。呼吸法のプリセットを増やしたくなっても、JavaScript 側は秒数を変えるだけで済みます。
本番で必ず踏む4つの落とし穴
ここまでは「うまくいく道」でした。実機に出すと、ここから先で必ずつまずきます。私が App Store に出したあと、実際に対処が必要だった点を順に挙げます。
1. エンジンは勝手に止まる
CHHapticEngine は、メモリ逼迫やメディアサービスのリセットで OS から止められることがあります。止まったまま startBreathing を呼ぶと、無言で何も鳴りません。stoppedHandler と resetHandler を登録し、止まったら作り直す・リセットされたら再起動する、という回復処理を必ず入れます。
engine ? .stoppedHandler = { reason in
print ( "Haptic engine stopped: \( reason ) " )
}
engine ? .resetHandler = { [ weak self ] in
try? self ? .engine ? . start () // メディアサービス復帰時に鳴り直せるように
}
2. バックグラウンド復帰で無反応になる
アプリがバックグラウンドに入るとエンジンは停止します。フォアグラウンドに戻った最初の一回が無反応になりがちなので、startBreathing の冒頭で毎回 engine.start() を呼び直す設計にしておきます。上の ensureEngine() が毎回 start() するのは、このためです。二重起動しても害はありません。
3. 端末が触覚に対応していない
Taptic Engine を持たない端末や iPad では、supportsHaptics が false を返します。ここで黙って何もしないと「壊れている」と受け取られます。私は、非対応端末では expo-haptics の軽い単発タップを吸う・吐くの境目だけに鳴らすフォールバックに切り替えました。波は出せなくても、区切りは伝わります。
4. 低電力モードで触覚が抑制される
低電力モードでは、システムが触覚出力を弱めたり止めたりします。これは仕様なので、振動が弱い・出ないという問い合わせを減らすため、設定画面に「触覚フィードバック」のオン・オフと、低電力モード時は弱まる旨の一文を置いておくのが実務的です。触覚を体験の中心に据えるなら、視覚の呼吸ガイドは常に残し、触覚はあくまで補助として設計しておくことを推奨します。
React の呼吸アニメーションと位相を合わせる
最後に、画面の円のアニメーションと触覚の位相を一致させます。ずれていると、かえって呼吸を乱します。触覚と視覚で同じ秒数の定数を共有し、1サイクルを単位に回すのが確実です。
import { useEffect, useRef } from "react" ;
import { Animated } from "react-native" ;
import { startBreathing, stopBreathing } from "./breathHaptic" ;
const INHALE = 4 , HOLD = 7 , EXHALE = 8 ;
export function useBreathCycle ( active : boolean ) {
const scale = useRef ( new Animated. Value ( 0.6 )).current;
useEffect (() => {
if ( ! active) { stopBreathing (); return ; }
const runCycle = () => {
startBreathing ( INHALE , HOLD , EXHALE ); // 触覚(吸う・吐く)
Animated. sequence ([
Animated. timing (scale, { toValue: 1 , duration: INHALE * 1000 , useNativeDriver: true }),
Animated. delay ( HOLD * 1000 ),
Animated. timing (scale, { toValue: 0.6 , duration: EXHALE * 1000 , useNativeDriver: true }),
]). start ();
};
runCycle ();
const id = setInterval (runCycle, ( INHALE + HOLD + EXHALE ) * 1000 );
return () => { clearInterval (id); stopBreathing (); };
}, [active]);
return scale;
}
秒数の定数を1か所にまとめ、触覚と視覚の両方がそれを参照する。この小さな約束を守るだけで、画面の膨らみと指先の波がぴたりと重なります。この一致が生まれた瞬間に、アプリは「呼吸を導く道具」に変わります。瞑想や睡眠導入のアプリでは、この体験の質がそのまま継続率(リテンション)に効いてくる、というのが私自身の実感です。
次の一歩
まずは isSupported() を確かめる小さな画面を1つ作り、startBreathing(4, 7, 8) を実機で一度鳴らしてみてください。指先に波が来る感覚を確認できたら、そこから sharpness を 0.1 ずつ動かして、自分のアプリにいちばん馴染む柔らかさを探す——その手触りの調整こそが、触覚を扱う仕事のいちばん面白いところだと感じています。