「鳴らさない場所」を先に決める——植物と話すARアプリのハプティクス設計

騒がしい展示会場で音を使わずに状態を伝えるため、触覚を「機械の操作」と「植物の気配」の2層に分離。エラーの三連振動を禁じ、株の削除に0.9秒の減衰を鳴らすまでの設計。

#iOS#Core Haptics#UX

ARで観葉植物にスマホをかざすと、その植物が吹き出しで話しかけてくる「Plavo(プラボ)」というiOSアプリを作っています。このアプリには「計測値を数字のまま画面に出さない」という原則があります。土の水分量が「28%」だと数字で表示した瞬間に、相手が生き物ではなく「管理対象のセンサー端末」に見えてしまうからです。

数字を使わずに植物の状態を伝えるために、最初は視覚(葉の見た目や画面のトーン)と言葉(吹き出しのセリフ)の2つの経路を使っていました。そこに「3本目の経路」として加えたのが、手元に伝わる振動——**ハプティクス(触覚)**です。

ただ、ハプティクスは「なんとなく気持ちいいから」という理由で足し始めると、あっという間に破綻します。あらゆるボタンを押すたびに同じような振動が返ってくると、手応えは「画面を押した」以上の意味を持たなくなり、肝心な場面での振動が埋もれてしまいます。

そこでPlavoでは、どこで何を鳴らすかと同じくらい、「どこで鳴らさないか」を先に決めることから設計を始めました。

なぜ音ではなく、触覚だったのか

ハプティクスを3本目の経路に選んだのには、展示環境とARならではの3つの理由があります。

  1. 数字を出さずに「渇き」や「潤い」を伝えられること。 視覚と言葉はどちらも「画面の中」にありますが、振動は持っている手に直接届きます。乾いた硬い振動と、水を吸い上げるような柔らかい振動を使い分けることで、パーセンテージを見せずに植物のコンディションを手触りとして伝えられます。
  2. 展示会場では音が使えないこと。 Plavoは展示会での体験を想定していますが、会場は周囲の話し声や環境音で騒がしく、来場者はイヤホンをしていません。スピーカーから効果音を鳴らしてもかき消されてしまいますし、そもそもARのカメラセッションと音声再生が競合するリスクもあります。触覚なら、どれだけ会場が騒がしくても確実に手元へ届きます。
  3. 片手でARを覗いている姿勢では、画面の端を見られないこと。 スマホを片手で掲げて植物を覗き込んでいるとき、人の視線は画面中央の植物と吹き出しに釘付けになります。その状態でシャッターを切ったり、画面下の弧(アーク)をなぞって観察する株を切り替えたりしても、画面の端で起きた変化を目で追う余裕がありません。手に確かな手応えが返れば、植物から目を離さずに「撮れた」「切り替わった」と確信できます。

「操作の触覚」と「植物の触覚」を絶対に混ぜない

設計で一番最初に決めたルールは、ハプティクスを2つの層に厳密に分け、絶対に混ぜないことです。

層何を伝えるか使う技術語彙の例
操作の触覚機械(UI)がこちらの操作を受け取ったこと標準API(UIFeedbackGenerator / .sensoryFeedback)tick(刻み)、tap(軽い応答)、snap(確定)、thud(重い削除)、caution(上限・注意)
植物の触覚その子がそこに居ること、いまどんな状態かCore Haptics のカスタム波形pulse(検出)、meet(命名・出会い)、thirst(渇き)、drink(給水)、unease(水のやりすぎ)、farewell(看取り・別れ)

なぜこの2つを混ぜてはいけないのか。たとえば、ボタンを押した返事に植物用のカスタム波形を使ってしまうと、「UIを操作した手応え」が「生き物の気配」に化けてしまい、何に対するフィードバックなのかが分からなくなります。

逆に、新しい植物をカメラで迎えて名前をつけた瞬間に、iOS標準の成功振動(.success)を鳴らしてしまうと、せっかくの出会いが**「登録フォームの送信作業が完了した」という事務的な合図**に変わってしまいます。Plavoでは植物の登録を「作業」ではなく「出会い」として体験してもらう設計にしているため、ここは機械の完了音ではなく、弱から強へと温かくふくらむ0.35秒のカスタム波形(meet)を返す必要がありました。

また、**「植物の触覚を鳴らすのは、植物が言葉を発する場面に限る」**という制約も設けました。触覚はあくまでセリフの伴奏であり、画面に変化がないのに単独でいきなり手元が震えるようなことは起きないようにしています。

標準の error と success を封印した理由

標準のフィードバック(UINotificationFeedbackGenerator)には、.error(ブブブッという3連の警告振動)と .success(軽快な完了振動)が用意されています。普通のアプリなら真っ先に使うこの2つを、Plavoでは一切使わないと決めました。

失敗していないものを、失敗として鳴らさない(H-2)

カメラをかざしても植物がまだ見つからないとき、画面には「見当たらないなぁ」と出ます。また、アルバムを開いたときにまだ1枚も撮っていなければ「まだ写真がありません」と出ます。

ここで .error の3連振動を鳴らしてしまうと、スマホが来場者を「あなたの操作が間違っています」と叱りつけることになります。植物がカメラに映っていないのも、写真がまだ無いのも、ユーザーのミスではありません。本当に上限に達したとき(「今日の日記はもう10枚あります」など)やセンサーに繋がらなかったときだけ、一歩手前の .warning を使った caution を返し、.error はアプリ全体から追放しました。

削除は「作業の成功」ではなく「別れ」(H-5)

同じように、登録していた株を削除したときに .success を鳴らすのもやめました。植物を単なるデータレコードとして扱うなら「削除処理が正常に完了しました」で正解かもしれませんが、人と同等の相棒として扱うアプリで削除時に軽快な成功音が鳴るのはあまりに冷淡です。

そこで、取り返しのつかないリセット操作には鈍く重い thud(.impact(.heavy))を割り当て、株の削除を実行した瞬間だけには、0.9秒かけて静かに減衰していく1回きりのカスタム波形 farewell を鳴らすようにしました。「完了」ではなく「別れ」の手触りにするためです。

「鳴らさない場所」を仕様書に明文化する

ハプティクスの設計書を書いていて気づいたのは、「鳴らさないと書いていない場所には、あとから自分や他の誰かが気軽に振動を足してしまう」ということでした。そこで、画面ごとの割り当て表には「ここで何を鳴らすか」だけでなく、「ここは鳴らさない」という決定とその理由を同じ熱量で書き込みました。

場面判定鳴らさない理由
カメラで植物を見失った瞬間鳴らさない「振動しないこと(無音)」そのものが、気配が消えたことを表すため。エラーではない
水分状態が comfortable / drying に入ったとき鳴らさない落ち着いている状態に振動をつけると落ち着いて見えなくなる。drying(20〜30%)で鳴らすと、本当に危ない thirsty との差が消える
展示用の水分モックスライダーを動かしている最中鳴らさない指で動かすたびに複数の水分帯域をまたぐため、鳴らすと thirst と drink が交互に暴発する
「水をあげる」ボタンを押した瞬間鳴らさないボタンを押した瞬間に鳴らすと、直後に水分帯域が watered に変わってセリフが出る瞬間の drink と二重に鳴ってしまう
「話しかける」ボタンを押した瞬間操作の tap のみ(meet は鳴らさない)「はじめまして」で meet を鳴らし、数秒後の名前決定でも meet を鳴らすと、どちらが迎え入れの成立なのか分からなくなる
画面下部のタブバーの切り替え鳴らさないOS標準のタブバーの挙動に任せ、過剰な意味づけをしない
ギャラリーの全画面表示で写真を左右に送るとき鳴らさない全画面は1枚の写真をじっくり見る場所であり、送るたびに手に振動が来ると見ることから注意が逸れる

さらに、水分量のパーセンテージを振動の「強さ」に連続値としてマッピングすることもやめました。Plavoの水分状態は6つの帯域に分かれており、段(ステップ)で表現すれば十分です。もし連続値で強さを変えてしまうと、デモ用のモック(毎秒0.6%ずつ乾いていく仕様)を動かしているあいだ、同じ帯域の中でも手応えが微妙に揺れ続けてしまい、「何が変わったのか」が逆に読み取れなくなります。**「変わった瞬間にだけ鳴らし、状態が続いているあいだは鳴らさない(H-1)」**を徹底することで、初めて1回1回の振動に意味が宿ります。

実装で踏んだ罠:回転アークで毎回生成器を new していた

設計を固めていざコード(Haptics.swift)に落とし込む段階で、既存のUI実装に潜んでいたハプティクスのバグを1つ見つけました。

カメラ画面の下部には、登録した植物を切り替えるための扇形のUI(PlantSelectorArc)があります。指で弧をなぞると、植物の名前が頂点を通過するたびに「カチ、カチ」と目盛りの手応え(tick)が返る仕組みです。ところが、直す前のコードは次のようになっていました。

// 直す前:名前が頂点を通るたびに毎回インスタンスを生成していた
UISelectionFeedbackGenerator().selectionChanged()

UIFeedbackGenerator 系のクラスは、内部の Taptic Engine(振動モーター)がスリープ状態から起き上がるまでにわずかな準備時間を必要とします。そのため、使う直前に prepare() を呼んでハードウェアを温めておくのが本来の使い方です。

毎回 UISelectionFeedbackGenerator() を新しく作ってその場で selectionChanged() を呼んでいると、最初の1回目の「カチッ」がワンテンポ遅れるだけでなく、弧を素早く回したときにモーターの準備が追いつかず、手応えが鈍くなったり詰まったりしていました。

そこで、弧を回し始めた瞬間(beginTurning)に単一の Haptics.Ticker インスタンスを作って保持し、1目盛り鳴らすたびに次の目盛りに備えて prepare() を掛け直し、指を離した瞬間(onEnded)にインスタンスを手放すライフサイクルに書き換えました。また、Core Haptics のエンジン(CHHapticEngine)についても、アプリ起動時にいきなり起こすのではなくカメラ画面を開いたときにだけ事前に温めておく(Haptics.prepare())ようにしています。植物の触覚が最初に鳴るのは、カメラを開いて株を見つけた瞬間の pulse(トクンという0.2秒の2連振動)なので、エンジンが寝たままこの瞬間を迎えると、一番効かせたい最初の一発が遅れてしまうからです。

同時に、engine.playsHapticsOnly = true を指定してオーディオセッションとの競合を防ぎ、バックグラウンドから復帰したときや低電力モード・非対応端末では、Core Haptics のカスタム波形から標準の UIFeedbackGenerator の語彙(meet なら snap など)へ自動でフォールバックし、それでも鳴らなければ無言でスキップして体験を止めない安全網を張りました。

振動は、足すことよりも「引いて意味を残すこと」のほうがずっと難しい表現手段です。どこで鳴らさないかを先に決めたことで、植物を見つけた瞬間の小さな「トクン」が、騒がしい会場でもちゃんと生き物の気配として手に残るようになりました。

このノートに出てくるプロダクト

ほかのノート

ノートの一覧に戻る