瞑想と呼吸法のための小さなアプリを個人開発で出したとき、画面の円はゆっくり膨らんだり縮んだりするのに、指先には何も伝わってこないことがずっと気になっていました。目を閉じて使う場面が多いアプリなのに、視覚だけで呼吸を導いていたのです。
そこで 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つの連続イベントを、強さカーブで包む。これだけで、指先に呼吸の輪郭が現れます。