0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【開発日誌 Day2】送信成功トーストを消したら、メモアプリは"自然"になった

0
Posted at

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?