Captio式シンプルメモ開発日誌
2026年5月8日
「送信が分かりやすい」と「送信が邪魔しない」は、別の概念だ。
ずっと同じだと思っていた。違った。
前回(Day1)では、Captio が消えたあとの3年と、Outboxアーキテクチャと、UIKitを選んだ理由について書いた。Day1の最後に「次回は送信体験をもっと自然にする」と予告した。今回はその予告通り、送信中と送信後の見せ方を3回書き直した話を、Swiftコードを交えて書く。
結論から書く。送信成功トーストを消した。 0.25秒で完結するページめくりアニメーションだけを残し、「送信できました」というモーダルやスナックバーは出さないことにした。理由は、送信できたことを"言葉で確認"する瞬間こそが、メモを書く流れを切っていたからだ。
派手な成功フィードバックは、ユーザーへの"親切"の顔をした邪魔になりうる。それを実装で証明していく回だ。
「送信成功」を意識させない、という命題
メモアプリで一番頻度が高い操作は何か。書くことではない。送ることだ。1日に20回書く人なら、20回送る。週に140回送る。年に7,300回送る。
7,300回毎回「送信できました」と画面の下から黒い帯が出てきて、2秒後に消えていったらどうなるか。最初の100回は安心するかもしれない。500回目には鬱陶しくなる。1,000回目にはトーストの面積分だけ視野を奪われていることに気づく。
実は、UI設計の文脈で「成功フィードバックは目立たせるべき」という常識は、頻度が低いアクションに対してのみ正しい。注文確定。決済完了。退会処理。これらは年に数回しか押さないボタンだから、はっきり成功を示す価値がある。
しかし、メモを送るは違う。メモ送信は「呼吸」に近い。呼吸ごとに「成功しました」と告知されたら、呼吸そのものが意識化されてしまう。そして意識化された瞬間、それは"自然"ではなくなる。
ここに気づくのに3週間かかった。Day1で書いた送信成功トースト(黒帯2秒)を実装してから、自分でアプリを使い始めて2週間目、ふと「あのトースト、要らないんじゃないか」という違和感が生まれた。
要るか要らないか、ではない。「無いほうが、送ったことを"自然に感じられる"」 という仮説だった。
ページめくりアニメーション0.25秒を3回書き直した話
トーストを消す代わりに、送信成功の合図をどこに乗せるか。答えは「ページがめくれて消える瞬間」だった。書いたテキストが、紙が風に吹かれて飛んでいくように、画面から消える。それが0.25秒で完結する。
最初の実装はこうだった。
// v1: 単純な fade out
UIView.animate(withDuration: 0.25, animations: {
self.composeView.alpha = 0
}, completion: { _ in
self.composeView.removeFromSuperview()
self.resetEditor()
})
これは「消えた」だけだった。送ったという感覚がしない。Time-to-Reset(送信ボタン押下からエディタが空になるまでの時間)は150msに収まったが、ユーザーテストで「本当に送れたのか不安」というコメントが3件出た。
第2版は3D変換を入れた。
// v2: 3D rotation + fade
let layer = composeView.layer
layer.shouldRasterize = true
layer.rasterizationScale = UIScreen.main.scale
var transform = CATransform3DIdentity
transform.m34 = -1.0 / 800 // 透視投影
transform = CATransform3DRotate(transform, .pi / 4, 1, 0, 0)
UIView.animate(
withDuration: 0.25,
delay: 0,
options: [.curveEaseIn],
animations: {
layer.transform = transform
self.composeView.alpha = 0
},
completion: { _ in
self.composeView.removeFromSuperview()
self.resetEditor()
}
)
m34 = -1.0 / 800 で透視を効かせ、X軸まわりに45度回転させた。紙が手前から奥に倒れていくような動きになる。これは「送った」という感覚は出た。しかし、もうひとつ問題があった。
回転が"重い"。45度はやりすぎた。45度というのはドアが半分開いた角度で、紙としては完全に倒れていない中途半端な状態だ。視覚的にひっかかる。
第3版で30度に変えた。同時に、curveEaseIn から curveEaseOut に変えた。
// v3: 30度 + ease out + わずかなY軸オフセット
var transform = CATransform3DIdentity
transform.m34 = -1.0 / 1000 // 透視を弱める
transform = CATransform3DTranslate(transform, 0, -8, 0)
transform = CATransform3DRotate(transform, .pi / 6, 1, 0, 0) // 30度
UIView.animate(
withDuration: 0.25,
delay: 0,
options: [.curveEaseOut],
animations: {
layer.transform = transform
self.composeView.alpha = 0
}
)
curveEaseOut にしたのは、最後の1/3でゆっくり消えていくほうが「投げた」感覚が残るからだ。ease in は加速して消えるので「飲み込まれた」感が出てしまう。送るとは"差し出す"行為であって、"飲み込まれる"行為ではない。
m34 = -1.0 / 1000 に弱めたのは、透視を強くしすぎると紙が極端に変形して見え、「自然」ではなくなるからだ。1000は経験値だが、800〜1200の範囲が"自然なペーパー感"の境界だった。
そして Y軸に -8pt 動かす のを足した。紙が真上からめくれるのではなく、上に少し浮いてからめくれて消える。8ピクセルというのはRetina換算で16物理ピクセル、視覚的には「軽く持ち上がった」と認識できる最小値。
完成版の0.25秒は、結果として0.05秒の前段(持ち上がり)と0.20秒の後段(めくれて消える)に分かれている。1つの動作のように見えて、内部は2フェーズ。これが「自然」の正体だった。
サブセクション:rasterizationの落とし穴
shouldRasterize = true を入れたら、低スペックデバイス(iPhone SE 第3世代)で文字がぼやけた。
layer.rasterizationScale = UIScreen.main.scale // ← これがないとボケる
UIScreen.main.scale を rasterizationScale に渡し忘れると、デフォルト1.0でラスタライズされてRetinaが活きない。これはアニメーション系の常識だが、毎回忘れる。shouldRasterize を有効にした次の行に必ず rasterizationScale を書くという個人ルールにした。
NotificationCenterのpostタイミング、150msの溝
送信アニメーションを直したら、もうひとつ問題が見えた。送信完了直後にHistoryタブを開くと、いま送ったメモが一瞬遅れて出る。具体的には150msほど。1度では気づかないが、5回連続で送ると気づく。
原因はNotificationCenter のpostタイミングだった。Day1で書いた Outboxアーキテクチャでは、送信のフローはこうなっていた。
// Day1版: 送信成功 → DB更新 → Notification post
func didFinishSending(_ entry: Entry) async {
do {
try await coreDataStack.update(entry, status: .sent, sentAt: .now)
await MainActor.run {
NotificationCenter.default.post(
name: .entrySent,
object: entry.id
)
}
} catch { /* ... */ }
}
これだと、coreDataStack.update が CoreDataのバックグラウンドコンテキストでrunし、メインコンテキストへのマージが終わるまでに50〜100ms かかる。NotificationCenterのpostは即座に届くが、Historyタブのフェッチは"マージ済みのデータ"を読みにいくので、結果として古い状態のテーブルがレンダリングされてしまう。
修正版はこうした。
// v2: マージ完了を待ってから post
func didFinishSending(_ entry: Entry) async {
do {
try await coreDataStack.update(entry, status: .sent, sentAt: .now)
// メインコンテキストへのマージを明示的に待つ
try await coreDataStack.viewContext.perform {
try coreDataStack.viewContext.save()
}
await MainActor.run {
NotificationCenter.default.post(
name: .entrySent,
object: entry.id
)
}
} catch { /* ... */ }
}
viewContext.save() を明示的に呼ぶと、その時点でメインコンテキストにマージが反映される。マージが終わってからpostすれば、Historyタブを開いた瞬間にUIが正しい状態になる。150msの溝はこれで消えた。
ただし、これにはトレードオフがある。viewContext.save() はメインスレッドをブロックするので、保存対象が多いと体感がもたつく。今回は1件の更新だから問題ないが、バルク送信を実装する日が来たら別のアプローチ(部分通知+差分マージ)が必要になる。Day10前後で書く予定。
サブセクション:actor isolationでハマった件
CoreData + Swift Concurrencyの組み合わせは、actor-isolation の宣言を一箇所間違えると "Sending value of non-Sendable type" 系のコンパイルエラーが大量に出る。
// NG: NSManagedObject を actor境界を越えて持ち回ろうとする
let entry = try await coreDataStack.fetch(id: id) // bg context
return entry // ← actor境界を越えるとエラー
// OK: ID(Sendable)だけ持ち回り、各actor内で再fetch
let entryID = try await coreDataStack.fetchID(matching: predicate)
let result = await MainActor.run {
coreDataStack.viewContext.fetchByID(entryID)
}
NSManagedObject は Sendable でないので、actor境界を越えて持ち回らない。IDだけ持ち回り、各actor内でfetchする。これはCoreData + Concurrencyの基本作法だが、最初は何度も間違えた。
トーストを消した日 — 伝えないことが伝える
3D変換のチューニングとNotificationCenter postタイミングの修正が終わった日、最後の判断としてトーストを消した。
// v1で書いていたコード(削除した)
// SuccessToast.show("送信しました", duration: 2.0)
// v2: 何もしない
// (ページめくりアニメーションが終わった時点で、ユーザーは送信完了を理解している)
これは引き算の設計だ。「足したらUXが良くなる」と「引いたらUXが良くなる」は別の方向の改善で、引くほうが圧倒的に難しい。なぜなら引くと、引いたぶん"何もしていない"ように見えるからだ。
「メモアプリ おすすめ」で検索してくる人は、機能の多さで判断する。トーストがあるアプリは「ちゃんと作られている」と判定される。トーストがないアプリは「シンプルすぎて何もない」と判定される。この判定は短期的には正しい。しかし長期的には、トーストがあるアプリのほうが先に削除される。なぜなら、毎日邪魔だからだ。
Captio はトーストを出さなかった。送るとアプリが閉じて、ホーム画面に戻る。それだけだった。「送信できました」という言葉はどこにもなかったが、ホーム画面に戻った瞬間、ユーザーはみんな送信成功を理解していた。画面遷移そのものがフィードバックだった。
Captio式シンプルメモは、Captioと違って起動を保ったまま続けて書ける設計にした。だから画面遷移はない。代わりに、ページめくりが画面遷移の代わりを果たす。0.25秒で書いたものが消えて、空のエディタが現れる。それが「送れた」のサインだ。
言葉で「送信しました」と告知することは、その0.25秒のアニメーションへの不信任投票でもある。「アニメーションだけでは伝わらないだろうから、念のため言葉でも言っておきます」と。
トーストを消した瞬間、アニメーションが急に"確信を持った"動作に見え始めた。
なぜ"派手なフィードバック"を世界はやめられないのか
ここまで書いて、自分の考えに反論しておく必要がある。派手な成功フィードバックを使い続けるアプリのほうが圧倒的多数だからだ。Slackの「送信完了」音、メールアプリの送信音、X(Twitter)の "Your post was sent" バナー。世界はこれらを捨てない。なぜか。
3つ理由がある。
ひとつ。初心者ユーザーへの保険として機能している。アプリを初めて触る人は「本当に送れたのか」を不安に思う。フィードバックを出しておけば、不安は減る。「上級者にとっては邪魔だが、初心者にとっては必要」という言い分は、企業向けプロダクトでは特に強い。
ふたつ。アクセシビリティの観点。VoiceOverユーザーや視覚障害のあるユーザーにとって、画面アニメーションは認識しづらい。「送信しました」という音声フィードバックや明示的なテキストは、彼らにとって不可欠だ。これは正しい指摘で、無視できない。
みっつ。プロダクトマネージャーの説明責任。会議で「このボタンを押すと何が起きるか」と聞かれたとき、「画面がフェードアウトします」よりも「成功トーストが出ます」のほうが説明しやすい。仕様書に書きやすい。エンジニアもテストしやすい。派手なフィードバックは"組織が使いやすい"。
これらを認めた上で、それでもCaptio式シンプルメモではトーストを消した。理由は、対象ユーザーがCaptio経験者を中心とした「メモを高頻度で送る人」だからだ。彼らは初心者ではない。VoiceOver対応は別途、明示的に音声カスタマイズで実装する(Day7予定)。組織の説明責任は、自分一人で開発しているので存在しない。
つまり派手なフィードバックを捨てられるかは、ユーザーセグメントと組織体制に依存する。「正しい」答えはない。Captio式シンプルメモというプロダクトの文脈では、捨てるが正解だった。
今日から試せる「邪魔しないUI」5つのチェック
明日から自分のアプリで使えるチェックリストを残す。
- 1日に何回押されるアクションか数える。10回以上ならフィードバックは控えめに。100回以上なら言葉のフィードバックを消すことを検討する。
- 成功時のフィードバックを消したとき、ユーザーは何で成功を知るかを書き出す。書き出せないなら、まだ消す段階ではない。
- 0.25秒〜0.5秒のアニメーションは、
curveEaseOutをデフォルトに。送る・閉じる・消すは ease out。出す・現れるは ease in out。 - 3D変換は m34 = -1.0/1000 を起点に、800〜1200の範囲で調整。これより強いと"漫画っぽい"、弱いと"効いていない"。
- 通知系(NotificationCenter, Combine, AsyncSequence)のpostタイミングは、必ずデータ層のマージ完了後に呼ぶ。50〜150msのズレは、5回連続で操作したときに必ず気づかれる。
今日の学び:「分かりやすい」と「邪魔しない」は反対方向
派手なフィードバックは"分かりやすい"。トーストもダイアログもバイブレーションも、全部"分かりやすい"側だ。
しかし"分かりやすい"を最大化しても、"邪魔しない"には届かない。むしろ反対方向に行く。"分かりやすい"を捨てた瞬間に、はじめて"邪魔しない"が現れる。
メモアプリで重要なのは、書いた人がメモのことを忘れられること。送ったあと、自分が何を送ったかをすぐに忘れて、次のことに移れること。トーストはその"忘れ"を妨害する装置だった。
派手なものを足すのは簡単で、引くのは難しい。引いたあとに残るものに自信がないと、引けない。今日のSimple Memoは、ページめくり0.25秒だけが残った。この0.25秒に、自分は確信を持っている。
次回(Day3)予告:Outboxアーキテクチャをコードで全部見せる
Day1で概念だけ書いたOutboxアーキテクチャを、Day3ではコードで全部出す。
- CoreDataのスキーマ定義(Entry, OutboxItem, RelayLog)
- 指数バックオフ再送(最大10回・jitter付き)の Swift Concurrency 実装
- ネットワーク復旧検知(NWPathMonitor)と Outboxドレインの結合
「ネットワーク不安定環境でも取りこぼしゼロ」を実現する仕組みを、コピペで動くコードで書く予定。AES-GCM暗号化やKeychain連携はDay5前後で別記事にする。
コメントで教えてください
メモアプリで「あのトースト、消してくれないかな」と思ったことがある人、どのアプリの何のトーストでしたか?
逆に「これは出ていてほしい」というフィードバックがあれば、それも知りたい。
邪魔か、必要か、の境界線をデータ化したい。
Captio式シンプルメモ
外部ライブラリ依存ゼロ。Swift + Apple純正フレームワークだけで作った、起動0.3秒のメモアプリ。
App Store: https://apps.apple.com/jp/app/captio%E5%BC%8F%E3%82%B7%E3%83%B3%E3%83%97%E3%83%AB%E3%83%A1%E3%83%A2/id6749649498