【開発日誌 Day2】送信ボタンを押した"あと"の体験を、全部設計し直した
【開発日誌 Day2】送信ボタンを押した"あと"の体験を、全部設計し直した
Captio式シンプルメモ開発日誌
2026年2月14日
Day1で書いた予告は、こうだった。
次は送信体験をもっと静かに、もっと自然に整えます。
今日一日やってみて分かったのは、「送信体験」という言葉が指すものは、自分が思っていたよりずっと広かったということです。
送信ボタンを押す。画面がクリアされる。メモがメールで届く。——文字にすると3行で終わる体験の中に、実は数十の設計判断が折り重なっている。アニメーションの速さ、音の有無、触覚の強さ、画面の戻り方、履歴への反映タイミング、エラーが起きたときの伝え方、バックグラウンドでの再送ロジック、レート制限のかけ方。
Captioが「なんか気持ちいい」と感じられた理由は、これらの判断が全部揃っていたから。一つでも間違えると、体験はすぐに壊れる。
今日はその「送信ボタンを押した後」に起きるすべてを、コードレベルで見直しました。
App Store:軽快シンプルメモ
送信アニメーションの彫刻——「紙が消える」を0.25秒で表現する
Day1で触れたページめくりアニメーション。今日はそのパラメーターを徹底的に調整しました。
まず、大前提として。このアニメーションは0.25秒で完結しなければならない。メモアプリにおいて、送信後にユーザーを0.5秒以上待たせるアニメーションは「邪魔」です。Captioの良さは「押したら消える」こと。アニメーションはその消え方に「気持ちよさ」を足すためのものであって、ユーザーの注意を奪うためのものではない。
この0.25秒の中に、4つの要素が詰まっています。
①ページめくりの3D変換。 テキストが書かれた「紙」が、右下を支点に上方向にめくれていくアニメーションです。CATransform3Dで奥行きを表現しています:
private func performSendAnimation(completion: @escaping () -> Void) {
// ハプティクスを実行
impactGenerator.impactOccurred()
// テキストビューのスナップショットを作成
guard let snapshot = textView.snapshotView(afterScreenUpdates: false) else {
completion()
return
}
snapshot.frame = textView.frame
view.addSubview(snapshot)
textView.alpha = 0
// 影を追加(紙感を出す)
snapshot.layer.shadowColor = UIColor.black.cgColor
snapshot.layer.shadowOpacity = 0.3
snapshot.layer.shadowOffset = CGSize(width: -5, height: 5)
snapshot.layer.shadowRadius = 10
// アンカーポイントを右下に設定(ページめくりの支点)
let originalCenter = snapshot.center
snapshot.layer.anchorPoint = CGPoint(x: 1.0, y: 1.0)
snapshot.layer.position = CGPoint(
x: originalCenter.x + snapshot.bounds.width / 2,
y: originalCenter.y + snapshot.bounds.height / 2
)
// 3D変換を設定(奥行き感)
var transform = CATransform3DIdentity
transform.m34 = -1.0 / 500.0
ここで注目してほしいのがtransform.m34 = -1.0 / 500.0という1行。これはiOS開発において3Dパースペクティブを制御する「魔法の数値」です。この値が奥行き感の強さを決める。-1.0 / 300.0だと奥行きが強すぎて大げさになり、-1.0 / 1000.0だと平面的で紙が飛んでいく感じが出ない。500.0という値は、画面サイズに対してちょうど「自然なめくれ」に見えるスイートスポットです。
②めくり方向の調整。 日めくりカレンダーのように上方向にめくれる動きを、X軸回転とZ軸回転の組み合わせで実現しています:
UIView.animate(
withDuration: 0.25,
delay: 0,
options: [.curveEaseIn],
animations: {
var finalTransform = transform
// X軸回転(上方向にめくれる)
finalTransform = CATransform3DRotate(finalTransform, -.pi / 2.5, 1, 0, 0)
// 軽いZ軸回転(右下からのめくれ感)
finalTransform = CATransform3DRotate(finalTransform, -.pi / 12, 0, 0, 1)
// 上方向に移動
finalTransform = CATransform3DTranslate(finalTransform, 0, -self.view.bounds.height * 0.3, 0)
// 縮小
finalTransform = CATransform3DScale(finalTransform, 0.4, 0.4, 1)
snapshot.layer.transform = finalTransform
snapshot.alpha = 0
},
-.pi / 2.5のX軸回転は、紙が上に72度めくれる角度。90度(.pi / 2)だと完全に裏返って不自然。72度だと「めくれて消えていく途中」で画面外に出るので、最後まで見届ける必要がない。
Z軸の-.pi / 12(15度)は、めくれる紙にほんの少し「ひねり」を加えるためのもの。これがないと機械的な動きに見えてしまう。風に吹かれた紙は、真っ直ぐにはめくれないから。
③サウンドとハプティクス。 視覚だけでなく、聴覚と触覚でも「送った」を伝えます:
private func playSendSound() {
guard SettingsManager.shared.isSendSoundEnabled else {
return
}
// システムサウンド 1001 (Mail Sent - Swoosh)
// サイレントモード時は自動で鳴らない
AudioServicesPlaySystemSound(1001)
// ハプティクスも強めに(mediumに変更)
let generator = UIImpactFeedbackGenerator(style: .medium)
generator.prepare()
generator.impactOccurred()
}
サウンドにはiOS標準のメール送信音(System Sound 1001、通称Swoosh)を使っています。カスタム音声ファイルを同梱する選択肢もあったけど、やめました。理由は2つ。①「聞き慣れた音」のほうが安心感がある。②カスタム音声はサイレントモードの挙動を自分でハンドリングする必要があるが、AudioServicesPlaySystemSoundはサイレントモード時に自動で無音になる。システムの挙動に逆らわないこと。これもCaptioの設計哲学です。
④スナップショットのライフサイクル管理。 アニメーション完了後、スナップショットを削除してテキストビューを再表示する:
completion: { _ in
snapshot.removeFromSuperview()
self.textView.alpha = 1
completion()
}
)
}
地味だけど、snapshot.removeFromSuperview()を忘れるとメモリリークになる。送信のたびにビューが1つずつ積み上がっていく。100回送信したら100枚の見えないビューが重なっている——こういうバグは、パフォーマンス計測をしないと絶対に気づけない。
Historyのリアルタイム反映——NotificationCenterで「状態を同期する」設計
Day1で予告した「Historyを開いたときにステータスが最新になっていない」問題。今日、根本的に解決しました。
核心は、HistoryManagerがデータを変更するたびにNotificationを発行するという設計パターンです。
final class HistoryManager {
/// 履歴データが変更された時の通知名
static let historyDidChangeNotification =
Notification.Name("HistoryManagerHistoryDidChange")
/// 変更通知をメインスレッドで発行
private func postChangeNotification() {
if Thread.isMainThread {
NotificationCenter.default.post(
name: HistoryManager.historyDidChangeNotification,
object: nil
)
} else {
DispatchQueue.main.async {
NotificationCenter.default.post(
name: HistoryManager.historyDidChangeNotification,
object: nil
)
}
}
}
ここで重要なのは、Notificationをメインスレッドかどうかでディスパッチ分岐していること。
なぜか。SendManagerの送信処理はDispatchQueue.global(qos: .userInitiated)で非同期実行されます。送信成功時にHistoryのステータスを更新すると、その更新はバックグラウンドスレッドから呼ばれる。しかしUIの更新はメインスレッドでしか行えない。だから、NotificationCenterへのpostは常にメインスレッドで行う必要があります。
このif Thread.isMainThreadの分岐がなかったら何が起きるか。バックグラウンドスレッドからpostされたNotificationを受け取った側がtableView.reloadData()を呼ぶと、UIKitがクラッシュします。しかもこれは確率的に起きるバグで、テスト中に10回中1回くらいしか再現しない。本番環境で突然クラッシュレポートが上がってきて初めて気づく——iOSアプリ開発でよくある、最悪のバグパターンです。
そして受信側。HistoryViewControllerではviewDidLoadでObserverを登録します:
override func viewDidLoad() {
super.viewDidLoad()
// 履歴データ変更通知を監視(送信完了時の自動リフレッシュ)
NotificationCenter.default.addObserver(
self,
selector: #selector(historyDidChange),
name: HistoryManager.historyDidChangeNotification,
object: nil
)
}
@objc private func historyDidChange() {
loadHistory()
}
private func loadHistory() {
let allHistory = HistoryManager.shared.getHistory()
history = Array(allHistory.prefix(maxDisplayCount))
tableView.reloadData()
}
これにより、History画面を開いている状態でバックグラウンド送信が完了すると、ユーザーが何も操作しなくてもステータスが「未送信」→「送信済み」にリアルタイムで切り替わるようになりました。
ちなみに、HistoryManagerは読み込みにメモリキャッシュを使っています:
func getHistory() -> [HistoryEntry] {
// キャッシュがあればそれを返す
if let cached = cachedHistory {
return cached
}
// ファイルから読み込み
guard FileManager.default.fileExists(atPath: historyFileURL.path) else {
return []
}
do {
let encryptedData = try Data(contentsOf: historyFileURL)
let decryptedData = try decryptData(encryptedData)
let history = try JSONDecoder().decode([HistoryEntry].self, from: decryptedData)
cachedHistory = history
return history
} catch {
return []
}
}
毎回AES-GCM復号化+JSONデコードを走らせると、履歴が50件を超えたあたりから体感できる遅延が出ます。キャッシュ層を挟むことで、書き込み時以外のアクセスはメモリ上の配列をそのまま返す。暗号化によるオーバーヘッドをユーザーに感じさせない工夫です。
「長押し」の設計——送信済みと未送信で、挙動を分ける
History画面のインタラクション設計で今日一番悩んだのは、「メモをタップしたときに何が起きるべきか」でした。
多くのアプリは、リストアイテムをタップすると詳細画面に遷移します。でもSimple Memoのメモは、件名と本文の区別がない短いテキスト。詳細画面を作っても表示するものがない。遷移のアニメーションで0.3秒待たされるだけ。
結論として、タップでは何もしないことにしました。代わりに長押しで操作を分岐させます。
override func tableView(_ tableView: UITableView,
contextMenuConfigurationForRowAt indexPath: IndexPath,
point: CGPoint) -> UIContextMenuConfiguration? {
let entry = history[indexPath.row]
// 長押し開始時にセルをハイライト
highlightCell(at: indexPath)
// 送信済み: 即シェアシート(メニューを挟まない)
if entry.isSent {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.shareEntry(entry, at: indexPath)
}
return nil // コンテキストメニューは出さない
}
// 未送信: 再送メニューを表示
return UIContextMenuConfiguration(
identifier: indexPath as NSCopying,
previewProvider: nil
) { [weak self] _ in
let resendAction = UIAction(
title: L("history.resend"),
image: UIImage(systemName: "paperplane")
) { _ in
self?.resendEntry(entry, at: indexPath)
}
return UIMenu(title: "", children: [resendAction])
}
}
この設計のポイントは3つあります。
①送信済みメモは、長押しで即シェアシートを開く。 コンテキストメニューを挟まない。「長押し→メニュー表示→共有を選択→シェアシート」は3ステップ。「長押し→シェアシート」なら1ステップ。送信済みメモに対してユーザーがやりたいことは、ほぼ100%「テキストをコピーするか共有する」のどちらか。だから最短経路だけを提供する。
②未送信メモは、コンテキストメニューで「再送信」を表示する。 こちらは確認ステップがあるほうがいい。再送信は明示的なアクションだから。
③ハイライトのアニメーション。 長押し中のセルには青いオーバーレイが0.15秒のアニメーションで表示されます:
override func setHighlighted(_ highlighted: Bool, animated: Bool) {
super.setHighlighted(highlighted, animated: animated)
let targetAlpha: CGFloat = highlighted ? 1.0 : 0.0
if animated {
UIView.animate(withDuration: 0.15, delay: 0, options: [.curveEaseInOut]) {
self.highlightOverlay.alpha = targetAlpha
}
} else {
highlightOverlay.alpha = targetAlpha
}
}
ハイライトの色はUIColor.systemBlue.withAlphaComponent(0.12)。透明度12%。これは「押していることが分かる」と「目立ちすぎない」のギリギリのバランスです。10%だと暗い背景で見えない。15%だと主張が強い。12%がちょうどいい。
こういう数値、Figmaのモックアップでは分からないんです。実機で指で押して、離して、押して、離してを繰り返して、ようやく「これだ」と思える。UIの設計は、結局は身体性の問題です。
バックグラウンド再送——「直列化」と「ジッター」で安全に再送する
Day1で紹介したOutboxの自動再送。今日はその裏側にある再送ロジックを完成させました。
まず、再送のトリガーは3つあります。
①ネットワーク回線の復帰。 NWPathMonitorがオフライン→オンラインの遷移を検知したとき。
②アプリの起動時。 Outboxに未送信メッセージがあれば、起動直後に再送を試みます。
③バックグラウンドタスク。 BGTaskSchedulerにより、アプリがバックグラウンドにいても再送を試行します。
起動時の再送トリガーはAppDelegateに書いています:
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// バックグラウンドタスクを登録
BackgroundTaskManager.shared.registerBackgroundTask()
// ネットワーク監視を開始
NetworkMonitor.shared.startMonitoring()
// 起動時に未送信メッセージがあれば再送を試行
if OutboxManager.shared.pendingCount() > 0 {
BackgroundTaskManager.shared.retryPendingMessages { _ in }
}
return true
}
3行。でもこの3行が「アプリを閉じて再起動しても、メモは絶対に失われない」という保証を生んでいます。
次に、再送処理の設計で最も重要な部分、直列化とジッターについて説明します。
未送信メッセージが5通溜まっているとき、5通を同時にAPIに投げると何が起きるか。サーバーサイドのレート制限に引っかかります。1分あたりデバイス30通という制限は余裕がありますが、IP単位で1時間120通という制限もある。複数のユーザーが同じネットワーク(会社のWi-Fiなど)から同時にリトライすると、IP制限にぶつかる可能性がある。
だから、再送は直列実行+ランダムジッター付きで設計しています:
/// メッセージを直列で処理(再帰的)
private func processMessagesSequentially(
messages: [OutboxMessage],
index: Int,
allSuccess: Bool,
completion: @escaping (Bool) -> Void
) {
// 全て処理完了
guard index < messages.count else {
DispatchQueue.main.async { completion(allSuccess) }
// pendingが残っていれば再スケジュール
if OutboxManager.shared.pendingCount() > 0 {
scheduleRetryTask()
}
return
}
let message = messages[index]
SendManager.shared.sendOutboxMessage(message) { [weak self] result in
var currentSuccess = allSuccess
switch result {
case .success:
break // Outbox削除はSendManager内で実行済み
case .failure:
currentSuccess = false
case .queued:
currentSuccess = false
}
// 次のメッセージへ(0.8〜1.5秒のジッター付き)
let jitter = Double.random(in: 0.8...1.5)
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + jitter) {
self?.processMessagesSequentially(
messages: messages,
index: index + 1,
allSuccess: currentSuccess,
completion: completion
)
}
}
}
Double.random(in: 0.8...1.5) のジッター。なぜランダムにするのか。
固定間隔(たとえば1秒ごと)で再送すると、複数のデバイスが同時にリトライした場合、サーバーへのリクエストが同期してしまう。1秒ごとにスパイクが立つ。これを「Thundering Herd問題」と呼びます。ランダムなジッターを入れることで、各デバイスの送信タイミングが自然にバラけ、サーバーへの負荷が平滑化されます。
さらに、レート制限に引っかかった場合の指数バックオフも実装しています:
static func exponentialBackoffDelay(retryCount: Int) -> TimeInterval {
let baseDelay: TimeInterval = 1.0 // 基本: 1秒
let maxDelay: TimeInterval = 300.0 // 上限: 5分
let jitter = TimeInterval.random(in: 0...1) // ジッター
// 2^retryCount * baseDelay + jitter
let delay = min(pow(2.0, Double(retryCount)) * baseDelay + jitter, maxDelay)
return delay
}
リトライ0回目は1秒後。1回目は2秒後。2回目は4秒後。3回目は8秒後。4回目は16秒後。最大で5分。これにジッターを足すことで、リトライのタイミングが毎回微妙にずれる。
地味だけど、この「他のユーザーのことまで考えた再送設計」が、サービス全体の安定性を守ります。
レート制限の設計——「使い始めは自由に、使い続けるなら支えてほしい」
今日もう一つ大きく手を入れたのが、レート制限とビジネスモデルの整合性です。
Simple Memoのプランは3段階。7日間の無料トライアル、トライアル後の無料プラン(1日3通)、プレミアム(無制限)。
このフロー設計には明確な意図があります。
/// 無料期間中かどうか(Day1〜Day7)
var isInTrialPeriod: Bool {
return currentDay <= trialPeriodDays
}
/// 日次上限を超えているかどうか
func isOverDailyLimit() -> Bool {
if isPremiumOrTrial { return false }
return todaySendCount >= freeDailyLimit // 3通
}
「最初の7日間は制限なし」 にした理由。メモアプリは「習慣」に組み込まれるまでに数日かかる。初日に3回使って「便利だな」と思っても、翌日には忘れている。1週間毎日使って初めて「これがないと困る」になる。だから7日間は好きなだけ使ってもらう。制限を感じる前に体験を知ってもらう。
「トライアル後は1日3通」 にした理由。ゼロにしない。広告も出さない。「昨日まで使えていたアプリが突然使えなくなる」体験は、信頼を壊す。1日3通あれば、「ちょっとしたメモを送る」という最低限の用途は維持できる。「もっと使いたい」と感じた人だけがプレミアムに進む。
現在の初回起動からの経過日数は、初回起動日をUserDefaultsに記録して計算します:
var daysSinceFirstLaunch: Int {
guard let firstLaunch = firstLaunchDate else { return 0 }
let calendar = Calendar.current
let components = calendar.dateComponents([.day], from: firstLaunch, to: Date())
return max(0, components.day ?? 0)
}
var currentDay: Int {
return daysSinceFirstLaunch + 1 // Day1から開始
}
そしてクールダウン。連打防止の1.5秒は、送信ボタンだけでなく、History画面の再送信にも適用されます:
private func resendEntry(_ entry: HistoryEntry, at indexPath: IndexPath) {
// 日次上限チェック
if RateLimitManager.shared.isOverDailyLimit() {
showToast(L("toast.dailyLimit"))
return
}
// クールダウンチェック
if !RateLimitManager.shared.canSend() {
showToast(L("toast.cooldown"))
return
}
// 再送実行
SendManager.shared.resend(entry: entry) { [weak self] result in
switch result {
case .success:
RateLimitManager.shared.recordSend()
HistoryManager.shared.updateStatus(id: entry.id, status: .sent)
self?.showToast(L("toast.sent"))
self?.loadHistory()
case .failure(let error):
HistoryManager.shared.updateStatus(id: entry.id, status: .failed)
let errorMessage = self?.getErrorMessage(for: error) ?? error.localizedDescription
self?.showToast(errorMessage)
self?.loadHistory()
case .queued:
self?.showToast(L("alert.send.offline"))
}
}
}
ここでの設計判断。「再送成功」「再送失敗」「オフラインでキュー」——3つの結果に対して、すべてトーストで伝えています。ダイアログ(アラート)ではなく、トースト。
なぜか。ダイアログは「OKを押す」というアクションを要求する。そのアクションには何の意味もない。「送信できました」→「OK」←この「OK」を押す0.3秒に、何の価値があるのか。トーストなら2秒で自動的に消える。ユーザーの操作を中断しない。「伝わったけど、邪魔しなかった」。これが目指す体験です。
History画面のセル設計——10行プレビューとステータスバッジ
History画面のセル一つ一つにも、今日いくつかの調整を入れました。
メモのプレビューは最大10行まで表示します。11行以上のメモは末尾に「…」を表示して切り詰める:
func previewText(maxLines: Int = 10) -> String {
let lines = body.components(separatedBy: .newlines)
if lines.count <= maxLines {
return body
}
let truncatedLines = Array(lines.prefix(maxLines))
return truncatedLines.joined(separator: "\n") + "\n…"
}
10行という数字にも意味があります。メモアプリに書かれるテキストの大半は1〜5行。10行あれば、ほぼすべてのメモの全文を表示できる。一方で、稀に長いメモ(議事録の走り書きなど)が入ったとき、セルの高さが画面を埋め尽くすのを防ぐ。10行は「ほぼ全文が見える」と「画面を壊さない」の境界線です。
ステータスバッジは、未送信と失敗で色を分けています:
if entry.isPending {
statusBadge.isHidden = false
if entry.status == .failed {
statusBadge.text = " " + L("history.status.failed") + " "
statusBadge.backgroundColor = .systemRed
} else {
statusBadge.text = " " + L("history.status.pending") + " "
statusBadge.backgroundColor = .systemOrange
}
bodyLabel.textColor = .secondaryLabel
} else {
statusBadge.isHidden = true
bodyLabel.textColor = .label
}
送信済みのメモにはバッジを表示しない。これも意識的な設計です。多くのアプリは「送信済み ✓」のようなバッジを付けますが、送信済みが正常状態であるなら、正常状態にラベルは不要です。異常な状態(未送信・失敗)だけにバッジを付ける。正常は沈黙で表現する。
今日の学び:「沈黙」がフィードバックになる
Day1では「安心感の設計」について書きました。今日の学びは、その延長線にあります。
良いUIは「沈黙」で多くのことを伝える。
送信ボタンを押す。紙がめくれて消える。テキスト入力欄が空白に戻る。——この「空白に戻った」こと自体が、送信成功のフィードバック。
History画面でメモのバッジが消えている。——この「バッジがない」こと自体が、送信済みのフィードバック。
トーストが出ない。ダイアログが出ない。何も起きない。——この「何も起きない」こと自体が、「すべて正常」のフィードバック。
過剰な通知、過剰な確認ダイアログ、過剰なアニメーション。これらは一見親切に見えるけど、本質的にはアプリがユーザーの注意力を奪っている。Captioはそれをしなかった。だからSimple Memoもしない。
何かが起きたときだけ伝える。何も起きないときは、黙っている。
この「沈黙のデザイン」が、使い続けるうちに「信頼」に変わる。
次回(Day3)予告:多層セキュリティの全体像を公開する
Day1でプライバシーオーバーレイとephemeral URLSessionについて触れました。Day3では、Simple Memoのセキュリティ設計の全体像を公開します。
Keychain、AES-GCM、Data Protection、HMAC署名。 これらがどう組み合わさって「メモが覗かれない」を実現しているのか。Relay APIのサーバーサイドセキュリティも含めて、アーキテクチャ全体を図解します。
メールアドレス検証フロー。 6桁コードの生成、SHA-256ハッシュによるメール保存、認証試行回数の制限。悪用を防ぐための多層防御を、TypeScriptのコードとともに解説します。
外部依存ゼロの意味。 なぜサードパーティライブラリを1つも使わないのか。「依存が増える」とは、セキュリティの文脈で具体的に何が起きるのか。
コメントで教えてください
今日の記事で触れた「沈黙のデザイン」——あなたのアプリでも意識していますか?
逆に「ここは沈黙じゃなくてちゃんと教えてほしい」と思う場面があれば、ぜひ教えてください。メモアプリに限らず、日常使うアプリの「静かすぎて不安になる」体験、あるいは「うるさすぎてやめた」体験。
そういうリアルな声が、Simple Memoの設計を研ぎ澄ませてくれます。
Simple Memo - Captio式シンプルメモ
外部ライブラリ依存ゼロ。Swift + Apple純正フレームワークだけで作った、起動0.3秒のメモアプリ。
App Store:ダウンロードはこちら
Discussion