1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Swiftでクイズアプリ作成 ~勉強編~

Last updated at Posted at 2025-09-17

目次

1. 背景
2. 今回開発するアプリ
3. アプリ紹介
3-1. 画面レイアウト
3-2. コード
4. 実装ポイント解説
4-1. データ管理
4-2. MVVMモデル
4-3. 表示項目のコンポーネント化
4-4. その他
5. 終わりに

背景

会社のアプリ開発の勉強会で、クイズアプリを作ってみようとなり、私はSwiftを使用してクイズアプリを作りました。
この記事では勉強用として実装したアプリを紹介 / 解説します。
別途、仕事用(設計 / 実装として)で提供できそうなアプリとなるように改修して改めて紹介できればと考えています。

今回開発するアプリ

事前に登録した問題 / 4択選択肢 / 正解 / 不正解時の解説 を元にしたクイズアプリ

アプリ紹介

画面レイアウト

スタート画面

Quiz出題回答画面

  • 選択肢をクリックすることで次の問題に遷移する
  • 回答し終えるとこれまでの問題とその回答の一覧が表示される
  • 回答一覧画面ではやり直すか、結果確認を行うか選択する
  • 結果確認では各問題 / 回答 / 正誤結果 / 不正解時の解説を表示

Quizデータ管理画面

  • 登録されている問題セット(問題 / 選択肢 / 正解 / 解説)を1つずつ表示
  • 「<<」「>>」で別の問題セットを表示
  • 編集ボタンで表示された問題セットを編集可能な状態に変更
  • 保存ボタンで表示されている問題セットで上書き保存
  • 削除ボタンで表示された問題セットを削除
  • 新規QA登録ボタンで新たに問題セットを入力し保存

コード

フォルダ構成

QuestionAndAnswer/
├── App/
│   └── QuestionAndAnswerApp.swift          # アプリのエントリーポイント
├── Models/
│   ├── QuestionAnswerDatabase.swift        # SwiftDataモデル
│   └── QandA.swift                         # 画面表示用データモデル
├── ViewModels/
│   ├── QuizViewModel.swift                 # クイズ画面用ViewModel
│   ├── ManagementDataViewModel.swift       # データ管理画面用ViewModel
│   └── RegistDataViewModel.swift           # データ登録画面用ViewModel
└──  Views/
    ├── StartView.swift                     # スタート画面
    ├── QuizView.swift                      # クイズ画面
    ├── QuestionView.swift                  # 問題表示コンポーネント
    ├── ConfirmView.swift                   # 回答確認画面
    ├── ResultView.swift                    # 結果表示画面
    ├── ManagementDataView.swift            # データ管理画面
    ├── RegistDataView.swift                # データ登録画面
    └── ErrorView.swift                     # エラー表示コンポーネント

コード

QuestionAndAnswerApp.swift
import SwiftUI

@main
struct QuestionAndAnswerApp: App {
    var body: some Scene {
        WindowGroup {
            StartView()
        }
        // データを永続化(アプリを止めても消えない)するコンテナを作成
        .modelContainer(for: QuestionAnswerDatabase.self)
    }
}
QuestionAnswerDatabase.swift
import Foundation
import SwiftData

/* DB */
// @Model:SwiftDataDBを使うためのアノテーション
// final:継承禁止
@Model
final class QuestionAnswerDatabase: Identifiable {
    
    // ID
    var id: String
    
    // 問題文
    var question: String
    
    // 正解
    var answerText: String
    
    // 選択肢
    var choices: [String]
    
    // 解説
    var commentary: String
    
    // 登録日時
    var createdAt: Date
    
    init(question: String, answerText: String, choices: [String], commentary: String) {
        self.id = UUID().uuidString
        self.question = question
        self.answerText = answerText
        self.choices = choices
        self.commentary = commentary
        // 現在の日時
        self.createdAt = Date()
    }
}

QandA.swift
/* 画面表示問題セット管理 */
struct QandA {
    
    // 問題セット
    var qaData: QuestionAnswerDatabase
    
    // 選択肢一覧
    var choices: [String]
    
    // ユーザの選択肢番号
    var selectChoice = 0
    
    // 正解番号
    var answerIndex: Int
    
    // コンストラクタ
    init (qaData: QuestionAnswerDatabase) {
        self.qaData = qaData
        self.choices = self.qaData.choices
        self.answerIndex = self.choices.firstIndex(of: qaData.answerText) ?? 0
    }
}
QuizViewModel.swift
import Foundation

// QuizView向けModelクラス
// MainActor : このクラス/メソッドはメインスレッドで動かすと指定
// ObservableObject : このクラスでの変更をSwiftUiに監視させる
@MainActor
class QuizViewModel: ObservableObject {
    // 画面表示用の問題リスト
    @Published var qa: [QandA] = []
    
    // 現在の問題インデックス
    @Published var currentQuestionIndex: Int = 0
    
    // 画面表示用エラーメッセージ
    @Published var errorMessage: String?
    
    // ConfirmView表示フラグ
    @Published var isShowingConfirmView: Bool = false
    
    // 問題回答データベース
    var qaData: [QuestionAnswerDatabase]
    
    // 問題回答データベースの設定
    init(qaData: [QuestionAnswerDatabase]) {
        self.qaData = qaData
        if !qaData.isEmpty {
            initializeQuestions()
        }
    }
    
    // 現在の問題セットを取得
    var currentQuestion: QandA? {
        // 現在の問題番号チェック
        guard currentQuestionIndex < qa.count else { return nil }
        return qa[currentQuestionIndex]
    }
    
    // 戻るボタン表示チェック
    var canGoBack: Bool {
        currentQuestionIndex > 0
    }
    
    // 一つ前の問題に戻る
    func back() {
        currentQuestionIndex -= 1
    }
    
    // 最新の問題回答データベースの設定
    func updateQaData(_ newData: [QuestionAnswerDatabase]) {
        self.qaData = newData
        initializeQuestions()
    }
    
    // 画面表示用問題セットの初期化
    func initializeQuestions() {
        guard !qaData.isEmpty else {
            errorMessage = "問題データ読み込みに失敗しました。"
            return
        }
        
        // 問題セットの設定
        // 問題回答データベースのリストから問題リストの各要素にデータを設定
        // 問題回答データベースから1つづつdata変数として取り出し、QandAクラスのインスタンス生成時に使用
        qa = qaData.map { data in
            QandA(qaData: data)
        }
        
        currentQuestionIndex = 0
        isShowingConfirmView = false
        errorMessage = nil
    }
    
    // やり直しボタンクリック時実行関数
    func restartQuizView() {
        // 現在の問題番号をリセット
        currentQuestionIndex = 0
        
        // QuizViewに戻る
        // ConfirmView表示フラグをOFFにすることで一つ前のQuizViewを表示
        isShowingConfirmView = false
    }
    
    // 選択肢ボタンクリック時実行関数
    func selectAnswer(_ answerIndex: Int){
        // 安全性チェック
        guard currentQuestionIndex < qa.count else { return }
        
        // 画面上で選択した選択肢番号を現在の問題セットに設定
        qa[currentQuestionIndex].selectChoice = answerIndex

        // 現在の問題番号が最終問題番号でない場合
        if currentQuestionIndex != qa.count - 1 {
            // 問題番号をインクリメント
            currentQuestionIndex += 1
        } else {
            // ConfirmView表示フラグをON
            isShowingConfirmView = true
        }
    }
}

ManagementDataViewModel.swift
import Foundation
import SwiftData

@MainActor
class ManagementDataViewModel: ObservableObject {
    
    // 現在の問題番号
    @Published var currentIndex: Int = 0
    // 現在の問題セット
    private var currentEditingQuestion: QuestionAnswerDatabase?
    
    // 画面上のデータ登録項目
    @Published var editingQuestion: String = ""
    @Published var editingChoices: [String] = ["", "", "", ""]
    @Published var editingCommentary: String = ""
    @Published var editingCorrectAnswerIndex: Int = 0
    
    // 編集フラグ
    @Published var isEditing: Bool = false
    
    // メッセージ
    @Published var message: String = ""
    
    // データベース操作オブジェクト
    private var context: ModelContext?
    
    // データベースにある問題セット一覧
    private var questions: [QuestionAnswerDatabase] = []
    
    // 初期化実施フラグ
    private var hasInitialized: Bool = false
    
    // データ初期化
    func setData(context: ModelContext , questions: [QuestionAnswerDatabase]) {
        
        if hasInitialized {
            return
        }
        
        self.context = context
        context.autosaveEnabled = false
        self.questions = questions
        hasInitialized = true
        
        if !questions.isEmpty {
            loadCurrentQuestion()
        }
    }
    
    // 問題セットの読み込み
    func loadCurrentQuestion() {
        // 安全性チェック
        guard currentIndex >= 0 && currentIndex < questions.count else { return }
        
        // 現在の問題セットの設定
        let question = questions[currentIndex]
        currentEditingQuestion = question
        
        // 画面表示する各問題セット項目の設定
        editingQuestion = question.question
        editingChoices = question.choices
        editingCommentary = question.commentary
        editingCorrectAnswerIndex = question.choices.firstIndex(of: question.answerText) ?? 0
        
        message = ""
    }
    
    // 正解インデックス値の設定
    func selectCorrectAnswer(index: Int) {
        editingCorrectAnswerIndex = index
    }
    
    // バリデーションチェック
    private func validate() -> Bool {
        if editingQuestion.isEmpty {
            message = "バリデーションエラー:質問を入力してください。"
            return false
        }
        
        for (index, choice) in editingChoices.enumerated() {
            if choice.isEmpty {
                message = "バリデーションエラー:\(index + 1)番目の選択肢を入力してください。"
                return false
            }
        }
        
        if editingCorrectAnswerIndex < 0 || editingCorrectAnswerIndex >= editingChoices.count {
            message = "バリデーションエラー:正解の選択肢が選択されていません。"
            return false
        }
        
        if editingCommentary.isEmpty {
            message = "バリデーションエラー:解説を入力してください。"
            return false
        }
        
        return true
    }
    
    // 問題セットの保存
    func saveQuestion() {
        guard let context = context,
            let currentQuestion = currentEditingQuestion else {
            message = "保存エラー:データが見つかりませんでした。"
            return
        }
        
        if !validate() {
            return
        }
        
        // 問題セットの上書き保存
        // 既存のデータベースの値を参照しているcurrentQuestionに対して画面上の項目を設定することで、
        // 変更されたデータを自動で検知し、context.saveにて変更されたデータのみ上書き保存される
        do {
            currentQuestion.question = editingQuestion
            currentQuestion.choices = editingChoices
            currentQuestion.commentary = editingCommentary
            currentQuestion.answerText = editingChoices[editingCorrectAnswerIndex]
            
            try context.save()
            
            // 編集モードをOFF
            isEditing = false
            message = "保存成功"
            
            // 現在の問題セットに、変更したデータを再設定
            if currentIndex < questions.count - 1 {
                questions[currentIndex] = currentQuestion
            }
        } catch {
            message = "保存エラー:\(error.localizedDescription)"
            
        }
            
    }
    
    // 問題セットの削除
    func deleteQuestion() {
        guard let context = context,
            let currentQuestion = currentEditingQuestion else {
            message = "削除エラー:データが見つかりませんでした。"
            return
        }
        
        do {
            // DB上から現在の問題セットの削除+保存
            context.delete(currentQuestion)
            try context.save()
            
            // 読み込み済みの問題セット一覧から現在の問題セットの要素を削除
            questions.remove(at: currentIndex)
            
            // 現在の問題番号の更新
            if currentIndex >= questions.count {
                currentIndex = questions.count - 1
            }
            
            // 問題セットの読み込み
            loadCurrentQuestion()
            
            message = "削除成功"
        } catch {
            message = "削除エラー:\(error.localizedDescription)"
            
        }
    }
    
    // 次の問題セットの読み込み
    func moveToNext() {
        if currentIndex < questions.count - 1 {
            if isEditing {
                cancelEdit()
            }
            currentIndex += 1
            loadCurrentQuestion()
        }
    }
    
    // 1つ前の問題セットの読み込み
    func moveToPrev() {
        if currentIndex > 0 {
            if isEditing {
                cancelEdit()
            }
            currentIndex -= 1
            loadCurrentQuestion()
        }
    }
    
    // 編集モードON
    func startEdit() {
        isEditing = true
    }
    
    // 編集モードOFF
    func cancelEdit() {
        isEditing = false
    }
    
    // 次の問題セット読み込み可否チェック
    var canGoBack: Bool {
        currentIndex > 0
    }
    
    // 1つ前の問題セット読み込み可否チェック
    var canGoForward: Bool {
        currentIndex < questions.count - 1
    }
}

RegistDataViewModel.swift
import Foundation
import SwiftData

@MainActor
class RegistDataViewModel: ObservableObject {
    
    @Published var inputQuestion: String = ""
    @Published var inputChoices: [String] = ["", "", "", ""]
    @Published var inputCommentary: String = ""
    @Published var correctAnswerIndex: Int = 0
    
    @Published var resultMessage: String = ""
    
    private var context: ModelContext?
    
    func setContext(_ context: ModelContext) {
        self.context = context
        context.autosaveEnabled = false
    }
    
    private func validate() -> Bool {
        
        if inputQuestion.isEmpty {
            resultMessage = "バリデーションエラー:質問を入力してください。"
            return false
        }
        
        for (index, choice) in inputChoices.enumerated() {
            if choice.isEmpty {
                resultMessage = "バリデーションエラー:\(index + 1)番目の選択肢を入力してください。"
                return false
            }
        }
        
        if inputCommentary.isEmpty {
            resultMessage = "バリデーションエラー:解説を入力してください。"
            return false
        }
        
        if correctAnswerIndex < 0 || correctAnswerIndex >= inputChoices.count {
            resultMessage = "バリデーションエラー:正解の選択肢が選択されていません。"
            return false
        }
        return true
    }
    
    /* データ登録 */
    func registQuestion () {
        guard let context = context else {
            resultMessage = "データベース接続エラー"
            return
        }
        
        if !validate() {
            return
        }
        
        let answerText = inputChoices[correctAnswerIndex]
        let data = QuestionAnswerDatabase(question: inputQuestion, answerText: answerText, choices: inputChoices, commentary: inputCommentary)
        context.insert(data)
        resultMessage = "登録完了"
        
    }
}

StartView.swift
import SwiftUI

struct StartView: View {

    @State var isShowingQuizView: Bool = false
    @State var isShowingManagementDataView: Bool = false
    var body: some View {
        NavigationStack {
            VStack {
                Button("QUIZスタート") {
                    isShowingQuizView = true
                }
                // QUIZスタートボタンが押下されたらQuizViewへ遷移
                .navigationDestination(isPresented: $isShowingQuizView) {
                    QuizView(isShowingQuizView: $isShowingQuizView)
                }
                
                Button("QUIZデータ管理スタート") {
                    isShowingManagementDataView = true
                }
                // QUIZデータ管理スタートボタンが押下されたらManagementDataViewへ遷移
                .navigationDestination(isPresented: $isShowingManagementDataView) {
                    ManagementDataView(isShowingManagementDataView: $isShowingManagementDataView)
                }
            }
        }
    }
}

QuizView.swift
import SwiftUI
import SwiftData

struct QuizView: View {
    
    /* StartViewからのパラメータ */
    // QuizView表示フラグ
    // 親子間で状態を共有及び変更するためにBindingをつける
    @Binding var isShowingQuizView: Bool
    
    // ConfirmView表示フラグ
    @State var isShowingConfirmView: Bool = false
    
    // DBからデータ取得
    // 登録日時の古い順にソート
    @Query(sort: \QuestionAnswerDatabase.createdAt, order: .forward)
    var db: [QuestionAnswerDatabase]
    
    // 問題回答管理
    @State var questions: [QandA] = []
    
    // QuizView向けのデータ処理及び状態管理
    @StateObject private var viewModel = QuizViewModel(qaData: [])
    
    var body: some View {
        Group {
            // エラーメッセージがある場合
            if let errorMessage = viewModel.errorMessage {
                ErrorView(message: errorMessage)
            
            // 問題データが存在する場合
            } else if let currentQA = viewModel.currentQuestion {
                // 問題セットを表示
                // 現在の問題データとselectAnswer関数を引数
                QuestionView(
                    currentQuestion: currentQA,
                    selectedAnswer: viewModel.selectAnswer
                )
                
                // 一つ前の問題に戻れる場合
                if viewModel.canGoBack {
                    Button("戻る") {
                        viewModel.back()
                    }
                }
            }
        }
        // QuizView表示時
        .onAppear {
            viewModel.updateQaData(db)
        }
        // 最終問題を選択したとき、ConfirmViewへ遷移
        .navigationDestination(isPresented: $viewModel.isShowingConfirmView) {
            // 全問題セットとrestartQuizView関数を引数
            ConfirmView(
                questions: viewModel.qa,
                restartAction: {
                    viewModel.restartQuizView()
                }
            )
        }
    }
    
    
}

QuestionView.swift
import SwiftUI

struct QuestionView: View {
    /* QuizViewからのパラメータ */
    // 現在の問題セット
    let currentQuestion: QandA
    
    // 選択肢ボタンクリック時実行関数
    let selectedAnswer: (Int) -> Void

    var body : some View {
        VStack {
            // 問題文表示
            Text(currentQuestion.qaData.question)
                .padding()
            
            // 選択肢表示
            // 選択肢はボタンとなっており、クリックすると次の問題が表示される
            // id: \.selfは、反復処理要素を一意に識別するもの
            // 反復処理要素がIdentifiableに準拠した独自クラスで、そこにプロパティとしてidが存在したらid: \.selfは不要
            ForEach(currentQuestion.choices.indices, id: \.self) { i in
                Button(currentQuestion.choices[i]) {
                    selectedAnswer(i)
                }
                .padding()
            }
        }
    }
}

ConfirmView.swift
import SwiftUI

struct ConfirmView: View {

    /* QuizViewからのパラメータ */
    // 現在の問題回答状況
    let questions: [QandA]
    // やり直しボタンクリック用関数
    let restartAction: () -> Void

    // ResultView表示フラグ
    @State var isShowingResultView: Bool = false

    var body: some View {
        VStack {
            Text("回答一覧")
                .padding()
            
            // 問題文と回答内容の一覧を表示
            ForEach(questions.indices, id: \.self) { i in
                let currentQuestion = questions[i].qaData
                Text("質問\(i+1) : \(currentQuestion.question)")
                Text("回答 : \(currentQuestion.choices[questions[i].selectChoice])")
            }
            
            // 空行
            Spacer().frame(height: 16)
            
            Button("やり直す") {
                restartAction()
            }
            
            Button("結果を確認") {
                isShowingResultView = true
            }
            // 結果を確認ボタンをクリックした場合
            .navigationDestination(isPresented: $isShowingResultView) {
                // ResultViewへ遷移
                // 問題回答管理とやり直しボタンクリック用関数を引数
                ResultView(questions: questions, restartAction: restartAction)
            }
        }
        // ConfirmViewでは画面右上にあるBackボタンは非表示
        .navigationBarBackButtonHidden(true)
    }
}

ResultView.swift
import SwiftUI

struct ResultView: View {
    /* ConfirmViewからのパラメータ */
    // 現在の問題回答状況
    let questions: [QandA]
    // やり直しボタンクリック用関数
    var restartAction: () -> Void
    
    var body : some View {
        VStack {
            
            Text("回答結果")
                .padding()
            
            // 問題毎の問題文、回答内容、正解(不正解時には解説)の表示
            ForEach(questions.indices, id: \.self) { i in
                let currentQuestion = questions[i].qaData
                
                // 正解判定
                let isCorrect = questions[i].selectChoice == questions[i].answerIndex
                
                // 正解判定による〇×設定
                let resultText = isCorrect ? "〇" : "×"
                
                if i != 0 {
                    Spacer().frame(height: 16)
                }
                Text("質問\(i+1) : \(currentQuestion.question)")
                Text("回答 : \(currentQuestion.choices[questions[i].selectChoice])")
                Text("正解 : \(resultText)")
                if !isCorrect {
                    Text("解説:\(currentQuestion.commentary)")
                }
            }
            
            // 空行
            Spacer().frame(height: 16)
            
            Button("やり直す") {
                restartAction()
            }
        }
    }
}

ManagementDataView.swift
import SwiftUI
import SwiftData

struct ManagementDataView: View {
    
    /* StartViewからのパラメータ */
    // ManagementDataView表示フラグ
    @Binding var isShowingManagementDataView: Bool
    
    @State var isShowingRegistDataView: Bool = false
    
    // データの取得
    // @QueryはView上でのみ使用可能
    // View以外でデータ取得するにはModelContext.fetch()を使う
    @Query var questions: [QuestionAnswerDatabase]
    @Environment(\.modelContext) var context
    
    // ManagementDataViewModelオブジェクト
    @StateObject private var viewModel = ManagementDataViewModel()
    
    var body: some View {
        
        VStack {
            
            // メッセージ表示セクション
            messageSection
            
            // 問題セット表示セクション
            if !questions.isEmpty {
                questionEditSection
                navigationEditSection
                actionButtonSection
            }

            // 新規データ登録ボタン表示セクション
            registButtonSection
            
        }
        // ManagementDataView表示時
        .onAppear {
            // データ初期化
            viewModel.setData(context: context, questions: questions)
        }
        // RegistDataViewへの画面遷移
        .navigationDestination(isPresented: $isShowingRegistDataView) {
            RegistDataView(isShowingRegistDataView: $isShowingRegistDataView)
        }
    }
    
    // メッセージ表示セクション
    private var messageSection: some View {
        VStack {
            if !viewModel.message.isEmpty {
                Text(viewModel.message)
                    .foregroundColor(.red)
                    .padding()
            }
        }
    }
    
    // 問題セット表示セクション
    private var questionEditSection: some View {
        VStack {
            // 問題文表示セクション
            EditableQuestion(text: $viewModel.editingQuestion, isEditing: viewModel.isEditing)
            // 選択肢表示セクション
            EditableChoices(choices: $viewModel.editingChoices,
                            correctAnswerIndex: $viewModel.editingCorrectAnswerIndex,
                            onCorrectAnswerSelect: viewModel.selectCorrectAnswer,
                            isEditing: viewModel.isEditing)
            // 解説表示セクション
            EditableCommentary(text: $viewModel.editingCommentary, isEditing: viewModel.isEditing)
        }
    }
    
    // 別問題セット移動ボタン表示セクション
    private var navigationEditSection: some View {
        HStack {
            Button("<<") {
                viewModel.moveToPrev()
            }
            .disabled(!viewModel.canGoBack)
            
            Button(">>") {
                viewModel.moveToNext()
            }
            .disabled(!viewModel.canGoForward)
        }
    }
    
    // 問題セットアクションボタン表示セクション
    private var actionButtonSection: some View {
        VStack {
            if viewModel.isEditing {
                HStack {
                    Button("保存") {
                        viewModel.saveQuestion()
                    }
                    
                    Button("キャンセル") {
                        viewModel.cancelEdit()
                    }
                }
            } else {
                HStack {
                    Button("編集") {
                        viewModel.startEdit()
                    }
                    
                    Button("削除") {
                        viewModel.deleteQuestion()
                    }
                }
            }
        }
    }
    
    // 新規登録ボタン表示セクション
    private var registButtonSection: some View {
        VStack {
            Button("新規QA登録") {
                isShowingRegistDataView = true
            }
        }
    }
}

/* 問題セット内の各部品表示セクション
 * 編集モードがONの時はテキストボックス内に登録されたデータを表示し、編集可能な状態
 * 編集モードがOFFの時はラベル内にデータを表示し、編集不可能な状態
 */
// 問題文表示セクション
struct EditableQuestion: View {
    @Binding var text: String
    let isEditing: Bool
    
    var body : some View {
        VStack {
            if isEditing {
                Text("問題文")
                TextField("問題文", text: $text)
                    .border(Color.gray)
                    .padding(.horizontal, 20)
            } else {
                Text("問題文:\(text)")
            }
        }
    }
}

// 選択肢表示セクション
struct EditableChoices: View {
    @Binding var choices: [String]
    @Binding var correctAnswerIndex: Int
    let onCorrectAnswerSelect: (Int) -> Void
    let isEditing: Bool
    
    var body : some View {
        VStack {
            Text("選択肢一覧")
            
            ForEach(choices.indices, id: \.self) { index in
                HStack {
                    if isEditing {
                        Button(action: {
                            onCorrectAnswerSelect(index)
                        }) {
                            Image(systemName: correctAnswerIndex == index ? "checkmark.square.fill" : "square")
                                .foregroundColor(correctAnswerIndex == index ? .green : .gray)
                        }
                        
                        TextField("選択肢\(index + 1)", text: $choices[index])
                            .border(Color.gray)
                            .padding(.horizontal, 20)
                    } else {
                        Text("\(index + 1).")
                        
                        Text(choices[index])
                    }
                }
            }
            
            if !isEditing {
                Text("正解:\(choices[correctAnswerIndex])")
            }
        }
    }
}

// 解説表示セクション
struct EditableCommentary: View {
    @Binding var text: String
    let isEditing: Bool
    
    var body : some View {
        VStack {
            if isEditing {
                Text("解説")
                TextField("解説", text: $text)
                    .border(Color.gray)
                    .padding(.horizontal, 20)
            } else {
                Text("解説:\(text)")
            }
        }
    }
}

RegistDataView.swift
import SwiftUI
import SwiftData

struct RegistDataView: View {
    /* StartViewからのパラメータ */
    // RegistDataView表示フラグ
    @Binding var isShowingRegistDataView: Bool
    
    // データの登録・更新・削除の実行
    @Environment(\.modelContext) var context
    
    @StateObject private var viewModel = RegistDataViewModel()

    var body: some View {
        GeometryReader { geometry in
            VStack {
                Text("新規QA登録")
                    .font(.title)
                    .padding()
                
                if !viewModel.resultMessage.isEmpty {
                    Text(viewModel.resultMessage)
                        .foregroundColor(.red)
                        .padding()
                }
                
                QuestionInputSection(question: $viewModel.inputQuestion)
                
                ChoiceInputSection(choices: $viewModel.inputChoices,
                                   correctAnswerIndex: $viewModel.correctAnswerIndex)

                CommentaryInputSection(commentary: $viewModel.inputCommentary)
                
                Button(action: {
                    viewModel.registQuestion()
                }) {
                    Text("登録")
                }
            }
            .frame(width: geometry.size.width, height: geometry.size.height)
            .position(x: geometry.size.width / 2, y: geometry.size.height / 2)
            .onAppear {
                viewModel.setContext(context)
            }
        }
    }
    
    struct QuestionInputSection: View {
        @Binding var question : String
        
        var body : some View {
            Text("問題文")
            TextField("問題文", text: $question)
                .border(Color.gray)
                .padding(.horizontal, 20)
        }
    }
    
    struct ChoiceInputSection: View {
        
        @Binding var choices : [String]
        @Binding var correctAnswerIndex : Int

        var body: some View {
            Text("選択肢一覧")
            ForEach(choices.indices, id: \.self) { index in
                HStack {
                    Button(action: {
                        correctAnswerIndex  = index
                    }) {
                        Image(systemName: correctAnswerIndex == index ? "checkmark.square.fill" : "square")
                            .foregroundColor(correctAnswerIndex == index ? .green : .gray)
                    }
                    
                    TextField("選択肢\(index + 1)", text: $choices[index])
                        .border(Color.gray)
                        .padding(.horizontal, 20)
                    
                }
            }
        }
    }
    
    struct CommentaryInputSection: View {
        
        @Binding var commentary : String
        
        var body: some View {
            Text("解説")
            TextField("解説", text: $commentary)
                .border(Color.gray)
                .padding(.horizontal, 20)
        }
    }
}

ErrorView.swift
import SwiftUI

struct ErrorView: View {
    let message: String

    var body: some View {
        Text("Error")
            .font(.title)
        
        Text(message)
            .font(.body)
    }
}

実装ポイント解説

データ管理

Swiftでデータを保存するにあたり、ローカル(スマホ内)にデータを保存する際、
以下ローカルDBが候補として挙げられた。
今回、勉強用としてSwift専用のSwiftDataを採用

使用要件

以下はSwiftDataを使用できる環境要件

  • Xcode : 15以上
  • iOS : 17.0以上
  • macOS : 14.0以上

参考資料:
https://developer.apple.com/documentation/SwiftData

コードに以下のようにimportすることでSwiftDataを使用できる

import.swift
import SwiftData
import SwiftUI

SwiftData使用の流れ

SwiftDataを使ってデータの操作(CRUD)をする方法は以下の流れ

  1. データ構造の定義
  2. アプリ全体でデータベースを永続的に使用できるようコンテナの作成
  3. データ操作オブジェクトの取得 + α
  4. データ操作

1. データ構造の定義

QuestionAnswerDatabase.swift
import Foundation
import SwiftData

// @Model:SwiftDataDBを使うためのアノテーション
// final:継承禁止
// Identifiable:データを一意に識別させる
@Model
final class QuestionAnswerDatabase: Identifiable {
    
    // ID
    var id: String
    
    // 問題文
    var question: String
    
    // 正解
    var answerText: String
    
    // 選択肢
    var choices: [String]
    
    // 解説
    var commentary: String
    
    // 登録日時
    var createdAt: Date

    // データ登録時に使用するコンストラクタ
    init(question: String, answerText: String, choices: [String], commentary: String) {
        // 一意となるIDを採番
        self.id = UUID().uuidString
        self.question = question
        self.answerText = answerText
        self.choices = choices
        self.commentary = commentary
        // 現在の日時
        self.createdAt = Date()
    }
}

2. アプリ全体でデータベースを永続的に使用できるようコンテナの作成

QuestionAndAnswerApp.swift
import SwiftUI

@main
struct QuestionAndAnswerApp: App {
    var body: some Scene {
        WindowGroup {
            StartView()
        }
        // QuestionAnswerDatabaseをアプリ全体で使用可能にするコンテナを作成
        .modelContainer(for: QuestionAnswerDatabase.self)
    }
}

コンテナとは、、

  • データベースファイルの作成(SQLite)
  • データの永続化
  • QuestionAnswerDatabaseクラス構造をテーブル設計に変換
    - クラス:テーブル
    - プロパティ:カラム
  • データ操作を行うモデルコンテキストを作成

3. データ操作オブジェクトの取得 + α

ManagementDataView.swift
@Environment(\.modelContext) var context

このやりかたでは、コンテナによりアプリ全体で利用可能なモデルコンテキストを取得している。
だが、制限としてView階層でのみ使用することができる取得方法という点。

他にも、App階層でのみ使用することができるModelContainerから直接取得する方法

getModelContextInApp.swift
do {
    container = try ModelContainer(for: QuestionAnswerDatabase.self)
} catch {
    fatalError("Failed to create container: \(error)")
}

や、サービスクラス等でコンテナを作成しそこから取得する方法が存在

getModelContextInService.swift
private let container: ModelContainer
private let context: ModelContext

init() throws {
    // 自分でcontainerを作成
    container = try ModelContainer(for: QuestionAnswerDatabase.self)
    context = container.mainContext
}

今回は以下理由から、View階層からModelContextを取得しViewModel階層で使用する設計を採用

  • 役割分担
    - View階層 : 画面描画 + Viewで使用する値の管理
    - ViewModel階層 : ビジネスロジック + データ処理
  • View階層でデータ取得したものと、ViewModelで取得したデータが異なる可能性
  • 複数のコンテナ作成によるパフォーマンスの低下
  • SwiftDataの設計思想として1つのコンテナを共有すること
ManagementDataView-ViewModel.swift
struct ManagementDataView: View {
    @Environment(\.modelContext) var context

    ...

    var body: some View {
        
        VStack {
            
            ...
            
        }
        // ManagementDataView表示時
        .onAppear {
            // データ初期化
            viewModel.setData(context: context, ...)
        }

        ...
        
    }
}

class ManagementDataViewModel: ObservableObject {
    private var context: ModelContext?

    // データ初期化
    func setData(context: ModelContext , ...) {
        
        ...
        
        self.context = context
        
        ...
    }
}

更に、今回手動でデータ保存を行いたいので、デフォルトで設定されているmodelContext.autosaveEnabledによる自動保存の設定をOFF

ManagementDataView.swift
func setContext(_ context: ModelContext) {
    self.context = context
    // 自動保存を無効にして手動制御
    context.autosaveEnabled = false
}

4. データ操作

データ取得(検索)

基本的なデータ取得は、View階層で Query を使用し取得する方法

simpleMethod.swift
// データ登録順に全件取得
@Query var questions: [QuestionAnswerDatabase]

// 登録日時項目の値が新しい順にソートして全件取得
@Query(sort: \QuestionAnswerDatabase.createdAt, order: .forward)
var db: [QuestionAnswerDatabase]

複雑な取得をしたい場合、View階層以外でビジネルロジックを組みながらmodelContext.fetch() を使用し取得する方法

fukuzatsuMethod.swift
@MainActor
class SearchService: ObservableObject {
   private var context: ModelContext?
   @Published var searchResults: [QuestionAnswerDatabase] = []
   
   func search(keyword: String, category: String?, createdAfter: Date?) {
       guard let context = context else { return }
       
       // 動的な条件でクエリを構築
       var predicate: Predicate<QuestionAnswerDatabase>?
       
       if let category = category, let createdAfter = createdAfter {
           predicate = #Predicate { question in
               question.question.localizedStandardContains(keyword) &&
               question.commentary.contains(category) &&
               question.createdAt >= createdAfter
           }
       } else if let category = category {
           predicate = #Predicate { question in
               question.question.localizedStandardContains(keyword) &&
               question.commentary.contains(category)
           }
       } else {
           predicate = #Predicate { question in
               question.question.localizedStandardContains(keyword)
           }
       }
       
       do {
           let descriptor = FetchDescriptor(predicate: predicate)
           searchResults = try context.fetch(descriptor)
       } catch {
           print("検索エラー: \(error)")
       }
   }
}

今回、複雑な取得は不要だったのでQueryを使用して取得を実施

データ登録

modelContext.insert() によりデータを登録

RegistDataViewModel.swift
// データ登録
func registQuestion() {
   ...
   
   // QuestionAnswerDatabaseデータベースクラスのインスタンス化
   // 画面上の各登録値を引数にインスタンスを作成
   let data = QuestionAnswerDatabase(
       question: inputQuestion, 
       answerText: answerText, 
       choices: inputChoices, 
       commentary: inputCommentary
   )
   
   // データベースに登録
   context.insert(data)
}
データ更新

データベースの値を参照しているcurrentQuestionに対して画面上の項目を設定することで、変更されたデータを自動で検知し、modelContext.save() により変更されたデータのみ上書き保存される

ManagementDataViewModel.swift
func saveQuestion() {
   guard let context = context,
       let currentQuestion = currentEditingQuestion else {
       message = "保存エラー:データが見つかりませんでした。"
       return
   }
   
   ...
   
   // 問題セットの上書き保存
   // 
   do {
       currentQuestion.question = editingQuestion
       currentQuestion.choices = editingChoices
       currentQuestion.commentary = editingCommentary
       currentQuestion.answerText = editingChoices[editingCorrectAnswerIndex]
       
       try context.save()
       
       ...
       
   } catch {
       message = "保存エラー:\(error.localizedDescription)"
   }  
}
データ削除

modelContext.delete()により、メモリ上から指定したデータを削除し、modelContext.save() によりデータベースに反映する

ManagementDataViewModel.swift
func deleteQuestion() {
   guard let context = context,
       let currentQuestion = currentEditingQuestion else {
       message = "削除エラー:データが見つかりませんでした。"
       return
   }
   
   do {
       // DB上から現在の問題セットの削除+保存
       context.delete(currentQuestion)
       try context.save()
       
       ...
       
   } catch {
       message = "削除エラー:\(error.localizedDescription)"
       
   }
}

MVVMモデル

ソフトウェアの設計パターンは主に4パターン存在する。
以下ではSwift目線からそれぞれの設計についてまとめる。

  • MVC (Model - View - Controller)
  • MVP (Model - View - Presenter)
  • VIPER (View - Interactor - Presenter - Entity-Router)
  • MVVM (Model - View - ViewModel)

MVC

 Model ↔ Controller ↔ View
  • Model : データ管理
  • View : 画面表示
  • Controller : ModelとViewとの仲介者 かつ Viewで表示する内容を直接操作

ControllerによってModelとViewを制御

  • メリット : 1番シンプルな設計
  • デメリット :
    - UIKitで使用可能で、SwiftUIでは使用不可
    - Controllerが肥大化し、管理が大変

MVP

Model ↔ Presenter ↔ View
  • Model : データ管理
  • View : 画面表示
  • Presenter : ModelとViewとの仲介者 かつ Viewで表示する内容を間接操作

PresenterによるViewとModelの完全な仲介役

  • メリット : MVCと異なりViewとModelを完全に分離することで保守性の向上
  • デメリット : 間接操作を実施するためのProtocol(Viewで用意する画面操作関数)が膨大

MVCとMVPとの違いイメージ

diffrentControllerPresenter.swift
// Controller: 直接操作
class Controller: UIViewController {
    @IBOutlet weak var label: UILabel!
    
    func updateData() {
        label.text = "Updated" // 直接操作
    }
}

// Presenter: Protocol経由
class Presenter {
    weak var view: ViewProtocol?
    
    func updateData() {
        view?.showData("Updated") // 間接操作
    }
}

VIPER

View ↔ Presenter ↔ Interactor ↔ Entity
         ↕
      Router
  • View : 画面表示
  • Interactor : ビジネスロジック
  • Presenter : ViewとInteractorとの仲介
  • Entity : データモデル、ドメインルール
  • Router : 画面遷移

責任を完全に分けた設計

  • メリット :
    - テストが容易
    - 再利用性が高い
    - 大規模開発に適切
  • デメリット :
    - 複雑
    - 小規模開発には過剰
    - 学習コストが高い

MVVM

Model ↔ ViewModel ↔ View
  • Model : データモデル
  • ViewModel : ビジネスロジック かつ 状態管理
  • View : 画面表示

MVCではできない自動同期

  • メリット :
    - SwiftUIで使用可能
    - ViewModelとView間でデータの自動同期が可能(データバインディング)
    - 小 / 中規模開発では適切
  • デメリット :
    - データバインディングに依存
    - ViewModelが少し肥大化する可能性あり
    - 学習コストが少し高い

今回、アプリの規模感や、複雑性のバランスからMVVMを採用

※ 改めてSwiftアプリ開発におけるMVVMについて調べてみたら、MVVMは場合によっては良くないと記事を発見する。。
https://qiita.com/karamage/items/8a9c76caff187d3eb838

表示項目のコンポーネント化

ManagementDataViewにて何も考えずにView表示項目を実装していたところ、以下エラーが発生しました。

The compiler is unable to type-check this expression in reasonable time;
try breaking up the expression into distinct sub-expressions

こちら調べると、コンパイラが時間内に式の型チェックができないというエラーとのこと。
複雑すぎる式でSwiftの型推論システムが処理しきれない時に発生してしまうそうです。

ここでいう複雑というのは以下が挙げられます。

  • 長すぎるメソッドチェーン
let result = users
    .filter { $0.age > 18 }
    .map { $0.name }
    .filter { $0.count > 5 }
    .sorted()
    .prefix(10)
    .map { $0.uppercased() }
    .joined(separator: ", ")
  • 算術式が複雑
let calculation = (a * b + c) / (d - e) + (f * g) - (h / i) + (j + k) * l
  • 複雑な条件式
if user.age > 18 && user.isActive && !user.isBanned && 
   user.subscriptionType == .premium && user.lastLoginDate > Date().addingTimeInterval(-86400) &&
   user.posts.count > 10 && user.followers.count > 100 {
    // 処理
}
  • 複雑なView ※今回はこの問題が該当
var body: some View {
    VStack {
        HStack {
            Image("icon").resizable().frame(width: 50, height: 50)
            Text("タイトル").font(.title).foregroundColor(.blue)
            Spacer()
            Button("ボタン") { action() }.background(Color.red)
        }
        // さらに多くのView要素が続く...
    }
}
  • 複雑なジェネリクス
let mapped = dictionary
    .compactMapValues { $0 as? SomeType }
    .mapValues { $0.property }
    .filter { $0.value > threshold }
    .sorted { $0.key < $1.key }

今回、複雑なViewというのが原因として考えられ、以下のようにコンポーネント化して利用したところ、エラーは解消されました。

ManagementDataView.swift
var body: some View {
        
    VStack {
        
        // メッセージ表示セクション
        // 部品を呼び出している
        messageSection
        
        // 問題セット表示セクション
        if !questions.isEmpty {
            questionEditSection
            navigationEditSection
            actionButtonSection
        }

        // 新規データ登録ボタン表示セクション
        registButtonSection
        
    }

    ...
    
}

// メッセージ表示セクション
// 表示項目の部品
private var messageSection: some View {
    VStack {
        if !viewModel.message.isEmpty {
            Text(viewModel.message)
                .foregroundColor(.red)
                .padding()
        }
    }
}

...

その他

以下ポイントも気にかけながら実装したのですが、私が以前まとめた記事でも紹介したのでここでは割愛

  • ナビゲーション設計 (画面遷移)
  • 状態管理 (Published, StateObject, ObservableObject, Binding)

終わりに

今回、会社のアプリ開発の勉強会として初めてのアプリ開発のお題がクイズアプリでしたが、アプリ開発の勉強としては入門〜基礎的な要素をかなり網羅したアプリなのかなと感じました。

  • 画面表示/UI設計
  • 画面遷移
  • データ管理(CRUD操作)
  • 状態管理
  • アーキテクチャパターン
  • バリデーション
  • エラーハンドリング
  • コンポーネント設計

また、アプリユーザとしては

  • データ登録 / 編集
  • 表示された問題に回答
  • 結果確認

のような特別難しそうな動きはしていなそうなものであっても、実装としては上記ポイントを考慮した少し複雑なものになってしまうんだなと感じました。

1
2
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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?