🙌

SwiftUI + Firestore:TextFieldの値が保存されない謎のバグを解決した話

に公開

はじめに

SwiftUIとFirestoreを使った学習管理アプリを開発中、ユーザーのニックネームを編集・保存する機能で奇妙なバグに遭遇しました。新しい名前を入力して保存しても、なぜか元の名前に戻ってしまうという現象です。

この記事では、問題の発見から解決までの過程を詳しく共有します。同じような問題に直面している方の参考になれば幸いです。

今回起こった問題

症状

プロフィール編集画面で以下の動作を行うと:

  1. 「プロフィールを編集」ボタンを押す
  2. TextFieldに新しいニックネーム(例:「山田太郎」)を入力
  3. 「保存」ボタンを押す

期待される結果:新しいニックネームがFirestoreに保存され、画面に反映される

実際の結果:元のニックネーム(「挑戦者」)のまま変更されない

初期のコード構造

struct ProfileView: View {
    @State private var nickname: String = ""
    @State private var isEditing: Bool = false
    
    var body: some View {
        // 編集モード時
        if isEditing {
            TextField("ニックネーム", text: $nickname)
            HStack {
                Button("キャンセル") {
                    nickname = viewModel.user?.nickname ?? ""
                    isEditing = false
                }
                Button("保存") {
                    saveProfile()
                }
            }
        }
    }
    
    func saveProfile() {
        // Firestoreに保存する処理
    }
}

原因の特定

デバッグプロセス

問題の原因を特定するため、以下のデバッグを実施しました:

1. Firestore側の確認

// デバッグログを追加
print("📝 Firestoreから取得した生データ:")
print("   - nickname: \(data["nickname"] ?? "nil")")

→ Firestoreには正しく「挑戦者」が保存されていた

2. 保存処理の確認

func saveProfile() {
    print("🔍 保存処理開始")
    print("   - 入力値: '\(nickname)'")
    print("   - トリム後: '\(trimmedNickname)'")
    // ...
}

→ 保存時のnicknameが常に「挑戦者」になっていた

3. UI操作の追跡

Button("キャンセル") {
    print("🔵 キャンセルボタンが押されました")
    // ...
}
Button("保存") {
    print("🔴 保存ボタンが押されました")
    print("   - 現在のnickname: '\(nickname)'")
    // ...
}

真の原因

ログを詳細に分析した結果、以下の事象が判明:

📝 TextField onChange: 'あああ'  // ユーザーが入力
🔵 キャンセルボタンが押されました  // なぜか先に発生
🔴 保存ボタンが押されました       // その後に保存処理
   - 現在のnickname: 'テストユーザー'  // 元の値に戻っている

原因:ユーザーが保存ボタンを押そうとした際に、誤ってキャンセルボタンも押してしまい、nicknameが元の値にリセットされてから保存処理が実行されていた。

解決方法と試したこと

試行錯誤1:Firestore設定の見直し

最初はFirestore側の問題を疑い、以下を確認・修正:

// setupFirestore関数でキャッシュ設定をしていたのを削除
private func setupFirestore() {
    // 設定を削除 - デフォルト設定を使用
}

→ 効果なし

試行錯誤2:User構造体のCodingKeys修正

struct User: Identifiable, Codable {
    @DocumentID var id: String?
    
    enum CodingKeys: String, CodingKey {
        // idを削除(@DocumentIDは自動的に処理される)
        case level
        case experience
        case totalStudyTime
        case nickname
        case unlockedPersonIDs
    }
}

→ 効果なし

試行錯誤3:保存方法の変更

// merge: trueを外して試す
try await db.collection("users").document(uid).setData(from: userToSave)

// updateDataを使用
try await db.collection("users").document(uid).updateData([
    "nickname": trimmedNickname
])

→ 効果なし

最終的な解決方法

編集中の値を別の状態変数で管理することで解決:

struct ProfileView: View {
    @State private var nickname: String = ""
    @State private var editingNickname: String = ""  // 編集中の値を別に保持
    @State private var isEditing: Bool = false
    
    var body: some View {
        // 編集モード時
        if isEditing {
            TextField("新しいニックネームを入力", text: $editingNickname)
                .onChange(of: editingNickname) { newValue in
                    print("📝 TextField onChange: '\(newValue)'")
                }
            
            HStack(spacing: 20) {  // ボタン間の間隔を広げる
                Button("キャンセル") {
                    print("🔵 キャンセルボタンが押されました")
                    isEditing = false
                }
                .buttonStyle(.bordered)
                
                Spacer()
                
                Button("保存") {
                    print("🔴 保存ボタンが押されました")
                    print("   - 保存する値: '\(editingNickname)'")
                    nickname = editingNickname  // 編集中の値を反映
                    saveProfile()
                }
                .buttonStyle(.borderedProminent)
            }
        } else {
            Button("プロフィールを編集") {
                let currentNickname = viewModel.user?.nickname ?? ""
                editingNickname = currentNickname.isEmpty ? "挑戦者" : currentNickname
                nickname = editingNickname
                isEditing = true
            }
        }
    }
}

なぜこれで解決したのか

  1. 状態の分離:表示用のnicknameと編集用のeditingNicknameを分離
  2. UIの改善:ボタン間の間隔を広げ、誤タップを防止
  3. ボタンスタイルの明確化.bordered.borderedProminentで視覚的に区別

学んだこと

1. SwiftUIのUI配置は慎重に

特にモバイルアプリでは、ボタンが近すぎると誤タップが発生しやすい。適切な間隔とタップ領域の確保が重要。

2. デバッグログの重要性

.onChange(of: textFieldValue) { newValue in
    print("TextField changed: '\(newValue)'")
}

このような詳細なログが問題の特定に役立った。

3. 状態管理の設計

編集中の値と確定値を同じ変数で管理すると、予期しない動作の原因になることがある。用途に応じて状態を分離することで、より堅牢なUIを実現できる。

まとめ

一見するとFirestoreやSwiftUIのバグのように見えた問題も、実際はUI設計の問題でした。このような問題は:

  • 詳細なデバッグログで操作の流れを追跡
  • UIの使いやすさを考慮した設計
  • 適切な状態管理

で解決できることが多いです。

特に、ユーザーの操作ミスを防ぐUI設計は、技術的な正しさと同じくらい重要だということを改めて認識しました。

参考:デバッグ時に役立つログの仕込み方

// ボタンアクション
Button("アクション") {
    print("🔴 ボタンが押されました - \(Date())")
    print("   - 現在の状態: \(currentState)")
}

// TextField変更
TextField("入力", text: $value)
    .onChange(of: value) { newValue in
        print("📝 値が変更されました: '\(oldValue)' → '\(newValue)'")
    }

// Firestore操作
do {
    print("🚀 Firestore操作開始")
    try await firestore.operation()
    print("✅ 成功")
} catch {
    print("❌ エラー: \(error)")
}

このような視覚的にわかりやすいログを使うことで、複雑な非同期処理も追跡しやすくなります。

Discussion