💨

【開発日誌 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