1. はじめに
個人開発している習慣化アプリHabitSparkをアップデートしました!
今回の v1.1.0 では、身近な友人に実際にアプリを使ってもらい、不具合や使いづらかった点をフィードバックしてもらいました。
その中で、開発者としては「問題ない」と思っていた部分にも、ユーザー視点では分かりにくさや違和感があることに気づくことができました。
この記事では、そうしたフィードバックをもとに、
- どんな課題が見つかったのか
- それをどうロジックやUIで解決したのか
を、実装とあわせてまとめています。
個人開発の中で行った、小さな改善の積み重ねとして読んでもらえたら嬉しいです。
2. 「手元」を見て気づいた3つのUXの壁
実際に操作してもらう中で、言葉にされる前の「一瞬の迷い」や違和感が見えてきました。
① 「ウィジェットと順番違くない?」
「あれ?アプリと並び違くない?」
【現場で起きたこと】
ホーム画面(アプリ内)ではA→B→Cの順で表示されているのに、ウィジェットではC→A→Bのように順番がバラバラに見える瞬間があった。
特に、タスクにチェックを付けたあとに外した直後に発生し、しばらくすると元に戻ることもある状態でした。
【原因】
アプリ側で状態が更新された直後、
- ウィジェットの再描画タイミング
- データ取得タイミング
この2つにズレが生じ、一時的に順序が不安定になることが原因でした。
さらに、並び順のルールを明示していなかったため、
その瞬間の配列順がそのまま表示されてしまっていました。
【解決】
アプリ・ウィジェットの両方で同一のソートロジックを適用し、どのタイミングでも順序が崩れないように修正。
// アプリ側とウィジェット側で共通のソートロジックを適用
let displayTasks = entry.tasks.sorted {
// 1. まずは「未完了」を上に、「完了済み」を下に並べる
if $0.isCompleted != $1.isCompleted {
return !$0.isCompleted && $1.isCompleted
}
// 2. 状態が同じ場合は、UUIDで順序を完全に固定する
// これにより、再描画のタイミングで順序が入れ替わるのを防ぐ
return $0.id.uuidString < $1.id.uuidString
}
👉 一瞬でも表示が崩れると、ユーザーには「バグ」に見える
👉 UIは「最終的に正しい」ではなく「常に安定している」ことが重要
② 「昨日、目標入れ忘れてたんだよね…」
「昨日やったのに、目標入れ忘れてた」
「これって後から入れたら、達成率どうなるの?」
【現場で起きたこと】
「実際には行動できていたのに、目標や記録を後から追加しようとした」場面で、手が止まっていた。
【問題の本質】
ユーザーは「記録を整えたい」だけなのに、
途中から追加すると過去の達成率が変わってしまうと、不安やストレスにつながる。
【解決】
目標ごとに「開始日」を持たせることで、進捗のカウント方法を見直しました。
struct Goal: Codable, Identifiable {
let id: UUID
var title: String
var isCompleted: Bool
var startDate: Date // 🌟実際に「+」ボタンを押した日(統計の分母計算に使用)
var targetDate: Date // 🌟カレンダー上のどの日付として扱うか(UI表示に使用)
// 既存ユーザーのデータを壊さないための後方互換性(Migration)
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
isCompleted = try container.decodeIfPresent(Bool.self, forKey: .isCompleted) ?? false
// 過去バージョンからアップデートした場合、startDateがないので「遠い過去」を補完
let decodedStartDate = try container.decodeIfPresent(Date.self, forKey: .startDate) ?? Date.distantPast
startDate = decodedStartDate
// targetDateがない場合はstartDateと同じにする
targetDate = try container.decodeIfPresent(Date.self, forKey: .targetDate) ?? decodedStartDate
}
}
- 過去の日付から目標をスタート可能に
- 途中から追加しても、これまでの達成率はそのまま
- 目標ごとの開始日をもとに、進捗を正しくカウント
👉 「記録の自由度」と「統計の正しさ」を両立
👉 ユーザーの行動を否定しない設計に
③ 「またこれ入力しなきゃダメ?」
「さっき入れたのに、またやるの?」
【問題】
チュートリアル再表示時に、毎回入力が必要だった
【解決】
初回のみ必須、それ以降はスキップ可能に変更
// 既に一度チュートリアルを終えているかを判定
@AppStorage("hasCompletedMainTutorial") private var hasCompletedTutorial = false
// ... UI部分 ...
if step == 0 {
TextField("目標を入力...", text: $firstGoalTitle)
// 2回目以降(設定画面などから開いた場合)はスキップを許可
if hasCompletedTutorial {
Text("※以前入力した目標がある場合はスキップできます")
.font(.caption2)
.foregroundColor(.secondary)
}
}
// ボタンの活性・非活性制御
Button("次へ") {
// 次のステップへ
}
.disabled(firstGoalTitle.isEmpty && step == 0 && !hasCompletedTutorial)
👉 「強制」ではなく「選択」に変えるだけで体験は大きく変わる
3. アジャイル開発の恩恵:ヒントカードの導入
「これって消しちゃダメなやつ?」
この一言から気づいたのは、
「分かりやすさ」より「安心感」が重要な場面があるということ。
そこで、画面内にヒントを配置するようにしました。
InlineHintCard(
title: "目標を立てて今日の一歩に",
message: "💡 月の途中や過去の日付から目標を追加することもできます。\n後から追加した目標は、これまでの達成率には影響しません。",,
isShowing: $showCalendarHint
)
👉 ユーザーの不安を先回りして解消
👉 説明を減らすのではなく、迷いを減らす
4. まとめ:対面テストは最強の開発手法
実際に操作を見守ることで見えてきたのは、
- 開発者は「仕様」で考える
- ユーザーは「感情」で使う
という明確な違いでした。
ログや数値では見えない違和感も、
リアルな操作を見ることで一瞬で発見できます。
今回のアップデートでは、
- データの正しさ
- ユーザーの気持ち
この2つを両立させることができました。
💡 おわりに
「横で見るだけ」で、ここまで改善点が出るのかと正直驚きました。
個人開発をしているなら、一度でいいので
誰かが使っている様子を観察してみるのはかなりおすすめです。
ログ100行より、1回のリアルな操作の方がヒントになります。
次回は、AdMob / ATT対応(広告まわりの実装)についてまとめる予定です。