76
73

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

時間制限付きクイズアプリをつくるサンプル紹介と実装ポイントのまとめ(怒涛のフルカスタマイズバージョン)

Last updated at Posted at 2016-03-28

はじめに

春らしい陽気の季節になってきました。4月からの新生活に向けての準備をしたり等忙しい時期がしばらく続くかと思いますが皆様いかがお過ごしでしょうか?

自分がiPhoneアプリ開発をスタートした際にサンプルで作成していたのがクイズアプリでした。最初はただ4つの選択肢から正しいものを一つ選ぶだけのとってもシンプルなアプリから、いろいろ手を加えてアレンジしてみた結果、昨年に下記のようなアプリをリリースしました。

リリースしたアプリ名:10秒虫食い算

tensecmushikui_top.jpg

こちらのゲームの概要としては「10秒×10問の虫食い算があり(?)に入る数字を1〜9の中から選択して正解とスピードを競う」ミニゲームアプリとなっています。ただの四則演算だと思ってやってみると時間制限もタイトなので結構難しいと思います。最初はそのスピードに面食らいますが、暗算や脳トレ系のクイズが好きな方は意外とハマるかもしれません。

※不具合やおかしい所などがあればお申し付けくださいませm(_ _)m
※こちらはObjective-Cで作成しています。

10秒虫食い算のダウンロードはこちらから

今回は「10秒虫食い算」の設計を練り直した上で、Objective-Cで当時書いたアプリをSwiftに置き換えて時間制限付きクイズアプリのサンプルを作成しました。

今回のサンプル:時間制限付き4択クイズアプリ(食べ合わせクイズ)

ただのクイズだとありきたりだけども、時間制限という一工夫を加えることでゲームとしてもより面白くなりますし、基本的なアプリをアイデア次第で拡張することができるので勉強をし始めた方からある程度アプリを作り慣れている方まで幅広くかつ楽しく実装できるのではないかと思い、今回はこのようにまとめてみました。

実装やゲームの設計に関する考察等に加えて今回はライブラリも使っていますので、ポイントとなりそうな部分等をピックアップして解説していきたいと思います。

1. 今回のサンプルの見た目&おおまかな仕様

★使用環境

OS:El Capitan 10.11.4 ※やっとバージョンアップしました。
XCode:XCode 7.3
Swift:Swift2.2
※ XCodeのバージョンが7.3未満の方は、お使いのXcodeのバージョンアップを行って下さい。

★大まかな仕様や流れに関して

今回のサンプルの特徴は古くからよく言い伝えられている「食べ合わせ」に関する4択クイズを5問1セットで回答してその結果を履歴orグラフで結果表示をするサンプルアプリになります。ただ問題を解くだけだとゲームとしての面白みがないかなと思ったので、さらに10秒の時間制限をつけるようにしてみました。また10秒の制限時間に到達をすると「不正解」とみなし、強制的に次の問題を表示するような仕組みにしました。(5問目が終了した段階で結果表示の画面へ遷移する)

sample.jpg

解き終わったあとは、結果はRealmを使用して「かかった秒数」を「正解数」を登録しておくようにしておき、

  • UITableViewでこれまでのゲーム履歴を記録
  • 直近5回の成績推移を折れ線グラフで表示

ができるようにしています。このサンプルでは問題数はテストなので8問しかデフォルトで表示していませんが、問題を自作して加えたり、グラフの表示を変えたり等をしてアレンジしてみるのも面白いかと思います。

★今回のサンプルで使用したライブラリについて

今回はデータ永続化部分ではこれまでもサンプルの紹介で何度も登場している「Realm」を使用しました(バージョンは0.98.0になります)。あとは折れ線グラフを作成するにあたりましては、以前にHTML5 + Canvas + Javascriptを使用したグラフを活用したことがありましたが、今回はネイティブアプリでグラフ作成ができるライブラリである「ios-charts」を採用しました。

導入を行う際には、下記のようにでPodfileを作成した後に、2行(Realmとios-chartsの宣言)を追加した後にライブラリをインストールすれば準備は完了です。

$ vim Podfile

--- (Podfileに追記する内容:ここから) ---
use_frameworks!
pod 'RealmSwift'
pod 'Charts'
--- (Podfileに追記する内容:ここまで) ---

$ pod install

グラフの描画に関しては、ライブラリやサンプルに関してはGoogle等を活用してお調べれば情報は出てきますが、色変えやカスタマイズを加えるとなった場合には、まだまだ情報が少ないかなと感じる時もあります。ネイティブのグラフの実装やRealmの書き方に関しては下記の資料を参考に作成しております。

■ Realmのドキュメントやリポジトリ:

今回に関しては新規追加とデータの読み出ししか処理がないので、プロジェクト内のRealmに関連する部分を見て頂ければと思います。

■ ios-chartsのドキュメントやリポジトリ:

ネイティブアプリでグラフを表示させたい場合には、CorePlotを使用したりする方法などもありますが、今回はリポジトリのREADMEや実装の解説資料を見つけたということもあったのでこちらを使用してみました。

※ グラフ描画系のライブラリでオススメのものがありましたら是非とも教えていただけますと嬉しいですm(_ _)m!

2. タイマー処理についてのおさらいとこのサンプルでの利用方法

タイマーの生成に関してですが、NSTimerクラスのメソッドを使うので、まずは基本事項を簡単に整理しその後具体的にどのように使うかをまとめていきたいと思います。

★NSTimerクラスと主要なメソッドに関して

タイマー処理を行う際には、まずはNSTimerクラスのインスタンスを作成してあげる必要があります。インスタンスの作成する際には、scheduledTimerWithTimeIntervalメソッドを使用しますが、今回設定するコード例と各引数については下記のようになります。

QuizController.swift
//タイマーをセットするメソッド
func setTimer() {
        
    //毎秒ごとにperSecTimerDoneメソッドを実行するタイマーを作成する
    self.perSecTimer = NSTimer.scheduledTimerWithTimeInterval(1, target: self, selector: #selector(QuizController.perSecTimerDone), userInfo: nil, repeats: true)
        
    //指定秒数後にtimerDoneメソッドを実行するタイマーを作成する(問題の時間制限に到達した場合の実行)
    self.doneTimer = NSTimer.scheduledTimerWithTimeInterval(QuizStruct.timerDuration, target: self, selector: #selector(QuizController.timerDone), userInfo: nil, repeats: true)
}

//タイマー処理を全てリセットするメソッド
func resetTimer() {
    self.perSecTimer!.invalidate()
    self.doneTimer!.invalidate()
}

■ NSTimerクラスのscheduledTimerWithTimeIntervalの引数について:

※Swift2.2からはselector部分の表記が変わっていますのでご注意ください。

引数 データ型 説明
第1引数 NSTimeInterval タイマーの実行間隔
第2引数(target) AnyObject タイマー発生時に呼び出すメソッドがあるターゲット
第3引数(selector) Selector タイマー発生時に呼び出すメソッド名
第4引数(userInfo) AnyObject? selectorで呼び出すメソッドに渡す情報
第5引数(repeats) Bool 繰り返しの有無

■ NSTimerクラスのその他の主なメソッドについて:

メソッド名 戻り値 説明
fire() void タイマーの開始をする
invalidate() void タイマーの停止をする
isValid() Bool タイマー動いているかの判定をする

今回のNSTimerクラスの使い方としては、時間制限を司るタイマーの生成・停止を行うために使用します。

★QuizController.swiftファイル内(問題文を表示するロジック)での使い方について

今回のゲームでは2つそれぞれ違うタイマーが仕込まれています。

具体的には、

  • 問題の制限時間を司るタイマー(今の設定では10秒毎)
  • 「あと◯秒」の残り時間を表示をするタイマー(今の設定では1秒毎)

になります。またこのタイマーについては10秒が経過した段階 or 解答ボタンを押した段階で一旦停止をして、タイマーを再設定をするような形でタイマーを設定します。5問解答が終わった際には次の画面に遷移するので、このタイミングになったらタイマーを停止する処理を忘れないようにして下さい。

■ NSTimerクラスに関する参考資料:

上記はObjective-Cの参考資料も含んでいますが、タイマー処理を実装する場合には確認しておくと良いかと思います。

3. このゲームの仕組みと仕様に関する説明とメソッドの関連性

まずは時間制限付きのクイズアプリを作成するにあたりましての仕組みと動きを実現するための部分に関してかいつまんでにはなりますが解説していきたいと思います。
この部分に関しては、Github内のQuizController.swiftの内容を中心に解説をしていきます。
※メソッドや変数が多くて申し訳ないのですが、下にフローチャート等も掲載していますので、行っている流れを最初に追った上でコードを見てみると若干理解しやすくなるかもしれません。

★このアプリのゲーム部分のフローチャートについて

ミニゲームだけでなくアプリの開発をする場合には、大まかなもので構いませんので手書きないしフリーツールなどを用いた設計書ないしはフローチャート的なものがあると頭の中で整理できて良いかと思います。

今回のゲーム部分の挙動に関しては、下記のような流れで進みます。

ランダムで問題を取得して(重複して問題を出さないようにソートした問題を配列に一旦格納しています)その問題の解答を選択肢 1. 〜 4. の中から選択するという仕様になっています。

選択肢 1. 〜 4. のボタンが押されたら、正解か不正解の判定をして、5問未満の場合は「計算結果」と「かかった時間」を加算して次の問題を表示させます。

もし10秒が経過してしまった場合には、強制的に「かかった時間に10秒を加算」して不正解扱いとして次の問題を表示させます。

そして合計5問の解答が終了した際にはタイマーを全て停止して、「計算結果」と「かかった時間」をRealmに保存した上で次の画面に遷移して計算結果とこれまでのゲーム履歴を表示させるようにしています。
※途中で画面左上にある戻るボタンを押して前の画面へ戻った場合には、タイマーを全て停止するので再度ゲームを始めた場合には、もう一度最初から始まるようにしています。

flowchart.jpg

一連のゲームの流れとしてはまとめると上記のようになっています。

★このゲームでのキモになるメソッドの解説

■ 解答ボタンを押した際の正解・不正解の判定:

各選択肢のボタンを押した際には下記のjudgeCurrentAnswerメソッドが呼び出されます。
こちらはそれぞれの選択肢が正解か不正解かを判定するものです。

どの選択肢が正解かのデータについては問題データのCSVファイルに定義してあり、その部分の番号と突き合わせて判断をする形にしています。

またこの部分では、解答するまでに経過した時間を記録する処理も一緒に行っています。

QuizController.swift
//選択された答えが正しいか誤りかを判定するメソッド
func judgeCurrentAnswer(answer: Int) {
    
    //ボタンを全て非活性にする
    self.allAnswerBtnDisabled()
    
    //カウントを元に戻す
    self.pastCounter = QuizStruct.defaultCounter
    
    //[問題の回答時間] = [n問目の回答した際の時間] - [(n-1)問目の回答した際の時間]として算出する
    switch self.counter {
        case 0:
            self.timeProblemSolvedOne = NSDate()
            self.tmpTimerCount = self.timeProblemSolvedOne.timeIntervalSinceDate(self.timeProblemSolvedZero)
        case 1:
            self.timeProblemSolvedTwo = NSDate()
            self.tmpTimerCount = self.timeProblemSolvedTwo.timeIntervalSinceDate(self.timeProblemSolvedOne)
        case 2:
            self.timeProblemSolvedThree = NSDate()
            self.tmpTimerCount = self.timeProblemSolvedThree.timeIntervalSinceDate(self.timeProblemSolvedTwo)
        case 3:
            self.timeProblemSolvedFour = NSDate()
            self.tmpTimerCount = self.timeProblemSolvedFour.timeIntervalSinceDate(self.timeProblemSolvedThree)
        case 4:
            self.timeProblemSolvedFive = NSDate()
            self.tmpTimerCount = self.timeProblemSolvedFive.timeIntervalSinceDate(self.timeProblemSolvedFour)
        default:
            self.tmpTimerCount = 0.000
    }
    
    //合計時間に問題の回答時間を加算する
    self.totalSeconds = self.totalSeconds + self.tmpTimerCount
    
    //該当の問題の回答番号を取得する
    let targetProblem: NSArray = self.problemArray[self.counter] as! NSArray
    let targetAnswer: Int = Int(targetProblem[1] as! String)!
    
    //カウンターの値に+1をする
    self.counter += 1
    
    //もし回答の数字とメソッドの引数が同じならば正解数の値に+1する
    if answer == targetAnswer {
        self.correctProblemNumber += 1
    }
    
    //タイマーを再設定する
    self.reloadTimer()
}

■ 解答をしたら次の問題を表示する、または計算結果を保存して次の画面へ遷移する:

問題を解答した際の処理の流れとしては、

  • 解答ボタン押下時にjudgeCurrentAnswer()メソッドが実行される
  • judgeCurrentAnswer()メソッド内でreloadTimer()が実行される
  • reloadTimer()メソッド内でcompareNextProblemOrResultView()が実行される
  • compareNextProblemOrResultView()メソッド内でcreateNextProblem()が実行される

というような流れになっています。

正解・不正解を判断した後にタイマーを再設定するメソッドを実行する際に、compareNextProblemOrResultView()メソッドも一緒に呼ばれます。

このメソッドの振る舞いとしては、

  • 規定解答数の5問未満の場合は、次の問題を表示する
  • 規定解答数の5問に達していた場合は、Realmに「正解数」と「かかった時間」を保存して、次の画面に遷移する

という形になります。

次の解答を表示する部分に関しては、createNextProblem()メソッドを用いてCSVで取得したデータから表示する問題と選択肢を表示します。このメソッドに関しては、

  • この画面に遷移した最初の時(第1問を表示する)
  • 問題の解答をした際(第2問目以降〜第5問目まで)

のタイミングで呼び出されます。

QuizController.swift
//タイマーを破棄して再起動を行うメソッド
func reloadTimer() {
    
    //タイマーを破棄する
    self.resetTimer()
    
    //結果表示ページへ遷移するか次の問題を表示する
    self.compareNextProblemOrResultView()
}

//次の問題を表示を行うメソッド
func createNextProblem() {
    
    //取得した問題を取得する
    let targetProblem: NSArray = self.problemArray[self.counter] as! NSArray
    
    //ラベルに表示されている値を変更する
    //配列 → 0番目:問題文, 1番目:正解の番号, 2番目:1番目の選択肢, 3番目:2番目の選択肢, 4番目:3番目の選択肢, 5番目:4番目の選択肢
    self.problemCountLabel.text = "第" + String(self.counter + 1) + "問"
    self.problemTextView.text = targetProblem[0] as! String
    
    //ボタンに選択肢を表示する
    self.answerButtonOne.setTitle("1." + String(targetProblem[2]), forState: .Normal)
    self.answerButtonTwo.setTitle("2." + String(targetProblem[3]), forState: .Normal)
    self.answerButtonThree.setTitle("3." + String(targetProblem[4]), forState: .Normal)
    self.answerButtonFour.setTitle("4." + String(targetProblem[5]), forState: .Normal)
}

//結果表示ページへ遷移するか次の問題を表示するかを決めるメソッド
func compareNextProblemOrResultView() {
    
    if self.counter == QuizStruct.dataMaxCount {
        
        /**
         *(処理)規定回数まで到達した場合は次の画面へ遷移する
         */
        
        //タイマーを破棄する
        self.resetTimer()
        
        //Realmに計算結果データを保存する
        let gameScoreObject = GameScore.create()
        gameScoreObject.correctAmount = self.correctProblemNumber
        gameScoreObject.timeCount = NSString(format:"%.3f", self.totalSeconds) as String
        gameScoreObject.createDate = NSDate()
        gameScoreObject.save()
        
        //次のコントローラーへ遷移する
        self.performSegueWithIdentifier("goScore", sender: nil)
        
    } else {
        
        /**
         *(処理)規定回数に達していない場合はカウントをリセットして次の問題を表示する
         */
        
        //ボタンを全て活性にする
        self.allAnswerBtnEnabled()
        
        //次の問題をセットする
        self.createNextProblem()
        
        //ラベルの値を再セットする
        self.timerDisplayLabel.text = "あと" + String(self.pastCounter) + "秒"
        
        //タイマーをセットする
        self.setTimer()
    }
}

メソッドが多く一見すると、どのメソッドがどのタイミングで使われているのが追いにくいかもしれませんが、そんな時にはフローチャートがあると迷いにくくなります。
※もっとスマートに書く方法とか見つけた方がいましたらご指摘いただければと思いますm(_ _)m

■ 10秒が経過してしまった場合の処理に関して:

10秒が経過した際は不正解扱いとして、次の問題を表示するないしは結果表示画面に遷移する処理を行います。こちらのについてはタイマーのインスタンスdoneTimerのSelectorに設定したメソッドを実行させる形にします。

10秒経過時の処理の流れとしては、

  • 10秒経過時にtimerDone()メソッドが実行される
  • timerDone()メソッド内でreloadTimer()が実行される
  • reloadTimer()メソッド内でcompareNextProblemOrResultView()が実行される
  • compareNextProblemOrResultView()メソッド内でcreateNextProblem()が実行される

というような流れになっています。

QuizController.swift
//指定秒数後にtimerDoneメソッドを実行するタイマーを作成する(問題の時間制限に到達した場合の実行)
self.doneTimer = NSTimer.scheduledTimerWithTimeInterval(QuizStruct.timerDuration, target: self, selector: #selector(QuizController.timerDone), userInfo: nil, repeats: true)

//問題の時間制限に到達した場合に実行されるメソッド
func timerDone() {
    
    //10秒経過時は不正解として次の問題を読み込む
    self.totalSeconds = self.totalSeconds + QuizStruct.limitTimer
    self.pastCounter = QuizStruct.defaultCounter
    
    switch self.counter {
    case 0:
        self.timeProblemSolvedOne = NSDate()
    case 1:
        self.timeProblemSolvedTwo = NSDate()
    case 2:
        self.timeProblemSolvedThree = NSDate()
    case 3:
        self.timeProblemSolvedFour = NSDate()
    case 4:
        self.timeProblemSolvedFive = NSDate()
    default:
        self.tmpTimerCount = 0.000
    }
    
    //カウンターの値に+1をする
    self.counter += 1
    
    //タイマーを再設定する
    self.reloadTimer()
}

時間制限付きのクイズアプリを作成する際は正解・不正解のロジックや画面の動きもさることながら「タイマーを正しくハンドリングするか」という部分も実装の際には重要になってきます。特にこのサンプルではタイマーの再会・停止のタイミングを間違えてしまうと、正しい振る舞いをしないケースが出てくるのでその点に関してはデバッグをする際に注意しましょう。

★iOSのライフサイクルに関して

今回のようなゲーム系のアプリを作成する際だけではなく、ViwControllerで画面表示時に呼び出されるメソッドのライフサイクルに関しても理解を深めておくと良いかと思います。
今回のサンプルに関しても、このライフサイクルを利用した部分(非表示になる直前にタイマーを破棄する処理など)がありますので、参考資料を下記に掲載しておきます。

4. ゲームデータの一覧表示やグラフ出力部分の実装ポイント

アプリユーザーのゲーム結果の履歴はRealmに格納してあり、Realmの設定に関しては下記の過去記事等で設定を参考にして頂ければと思います。

★ゲーム結果履歴一覧をUITableViewで表示する

このサンプルでは特に画像データもなく登録する項目も少ないので、これまでのゲーム結果履歴一覧を表示する際は GameScore.swiftScoreController.swift のUITableView部分で行っている処理を参考にして頂ければと思います。

★直近のゲーム結果の推移をios-chartsを利用して表示する

今回はゲーム結果の履歴を表示するだけでなく、せっかく準備の段階で導入したios-chartsを導入してグラフ表示をできるようにします。まずは import Charts でライブラリをインポートする処理をして下記のように実装を行います。※もしimport Charts でエラーが出てしまった場合には一度ビルドをしてみて下さい。

ScoreController.swift
...(省略)...

//Charsクラスのインポート
import Charts

//グラフに描画する要素数に関するenum
enum GraphXLabelList : Int {
    
    case One = 1
    case Two = 2
    case Three = 3
    case Four = 4
    case Five = 5
    
    static func getXLabelList(count: Int) -> [String] {
        
        var xLabels: [String] = []
        
        //※この画面に遷移したらデータが登録されるので0は考えなくて良い
        if count == self.One.rawValue {
            xLabels = ["最新"]
        } else if count == self.Two.rawValue {
            xLabels = ["最新", "2つ前"]
        } else if count == self.Three.rawValue {
            xLabels = ["最新", "2つ前", "3つ前"]
        } else if count == self.Four.rawValue {
            xLabels = ["最新", "2つ前", "3つ前", "4つ前"]
        } else {
            xLabels = ["最新", "2つ前", "3つ前", "4つ前", "5つ前"]
        }
        return xLabels
    }
    
}

class ScoreController: UIViewController ,UITableViewDelegate, UITableViewDataSource, UINavigationControllerDelegate {

    ...(省略)...

    //折れ線グラフ用のメンバ変数
    var lineChartView: LineChartView = LineChartView()
    
    ...(省略)...
    
    override func viewDidLoad() {

        ...(省略)...
    
        //データを成型して表示する(変数xLabelsとunitSoldに入る配列の要素数は合わせないと落ちる)
        let unitsSold: [Double] = GameScore.fetchGraphGameScore()
        let xLabels = GraphXLabelList.getXLabelList(unitsSold.count)
        
        var dataEntries: [ChartDataEntry] = []
        
        for i in 0..<xLabels.count {
            let dataEntry = ChartDataEntry(value: unitsSold[i], xIndex: i)
            dataEntries.append(dataEntry)
        }
        
        //グラフに描画するデータを表示する
        let lineChartDataSet = LineChartDataSet(yVals: dataEntries, label: "ここ最近の得点グラフ")
        let lineChartData = LineChartData(xVals: xLabels, dataSet: lineChartDataSet)
        
        //LineChartViewのインスタンスに値を追加する
        self.lineChartView.data = lineChartData
        
        //UIViewの中にLineChartViewを追加する
        self.resultGraphView.addSubview(self.lineChartView)
    }
    
    //レイアウト処理が完了した際の処理
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        //レイアウトの再配置
        self.lineChartView.frame = CGRectMake(0, 0, self.resultGraphView.frame.width, self.resultGraphView.frame.height)
    }
}

ライブラリのインポートができたら、いよいよ実装に入っていきます。
今回は直近5つのデータを折れ線グラフにプロットしていきます。

実装の概要としましては、折れ線グラフ用のメンバ変数を作成(今回はLineChartView型のインスタンス)し、その中に直近5回分のデータを配列をセットして、LineChartViewのインスタンスにセットする形になります。
X軸に表示する項目の一覧・Y軸にプロットする値の一覧ともに配列にする必要があります。
※ゲームの記録データが5つ未満の場合は、配列の要素数と合わせるためにInt型のenum(GraphXLabelList)を作成し、配列の要素数を合わせるようにしています。

ScoreController.swift
//lineChartDataSetメソッドの引数に(Y軸にプロットする値の一覧)と(グラフのタイトル名)をセットする
let lineChartDataSet = LineChartDataSet(yVals: dataEntries, label: "ここ最近の得点グラフ")

//LineChartDataメソッドの引数に(X軸に表示する項目の一覧)と(前の変数lineChartDataSet)をセットする
let lineChartData = LineChartData(xVals: xLabels, dataSet: lineChartDataSet)

LineChartDataSetメソッドの(引数:vVals)にはY軸にプロットする値が入り、(引数:label)にはグラフのタイトル名が入ります。
またLineChartDataSetメソッドの前の処理はY軸にプロットするためのデータを作成する処理になっています。データ自体はRealmに格納されているものを最新のものから5件を取得するような形にしています。

GameScore.swift
//登録日順のデータを最新から5件取得をする
static func fetchGraphGameScore() -> [Double] {
    let gameScores: Results<GameScore> = realm.objects(GameScore).sorted("createDate", ascending: false)
    var gameScoreList: [Double] = []
    for (index, element) in gameScores.enumerate() {
        if index < 5 {
            let target: Double = Double(element.correctAmount)
            gameScoreList.append(target)
        }
    }
    return gameScoreList
}

以上がざっくりとにはなりますが折れ線グラフをプロットするプログラムに関する部分です。あとは一覧の表示とグラフの表示をセグメントコントロールで切り替える処理と合わせてあります。
ios-chartsに関する実装については下記のドキュメントを参考にしました。

記録系のアプリを作成する際には、やはりグラフがあると見た目もユーザーへの印象もぐっと良くなりますが、まったくのゼロからの実装を行うと相当しんどい部分もあるので、上記のようなライブラリを活用してみるのも一興かと思います。今回使用した「ios-charts」についてはQiita等でまとめて下さっている方や、英語のサンプルドキュメントや実装解説があったのでアプリ公開に向けてブラッシュアップしたい等の際には検討してみても良いかと思います。

5. 問題の管理をCSVファイルで行い、extensionを用いて配列をランダムにソートするメソッドを作成する

今回のサンプルはWeb上にあるデータを読み込む形ではなく、プロジェクト内にあるCSVファイルで問題やオープニング画面の文言の管理をしています。この形ですと新しく問題を追加するたびにアプリのアップデートを行うような運用になりますが、今回はあくまでサンプルなのでこの形でよしとします。ただCSVを始めとするローカルファイルの読み込み処理は覚えておいて損はないと思います。

★CSVのデータ構成

各々のCSVファイルの構成と、各列の格納データの意味づけは下記のようになります。

■ guidance.csv(オープニング画面の文言):

//1列目:タイトル, 2列目:説明文

1行目のタイトルが入ります。,1行目のタイトルに対する説明文が入ります。
2行目のタイトルが入ります。,2行目のタイトルに対する説明文が入ります。

...
以降続く

■ problem.csv(問題のデータ):

//1列目:問題文, 2列目:正解の番号, 3列目:1番目の選択肢, 4列目:2番目の選択肢, 5列目:3番目の選択肢, 6列目:4番目の選択肢

一般的に胃腸の働きを弱めてしまう効果があるので、「天ぷら」と食べ合わせが良くないとされている食べ物は次のうちどれか?,1,スイカ,梅干し,ゆで卵,ソーセージ
トマトにはビタミンCが豊富に含まれていますが、生で食べるとビタミンCを破壊する作用があるのでトマトと食べ合わせない方が良い野菜は次のうちどれか?,3,かぼちゃ,だいこん,きゅうり,れんこん

...
以降続く

データのもち方や管理方法につきましては表示したい要素が増えたり、更新の頻度が頻繁になった際にはデータに関してはサーバーサイドのプログラムやmBaaS等を活用してデータの管理を行うようにする等の工夫をするともっと良くなるのではないかと思います。

★CSVデータの読み込み処理をする

プロジェクト内に格納しているローカルのファイルを読み込む際は下記のような処理を書いてCSV内のファイルデータの中身を取得します。

この処理の流れとしては、CSVデータを1行単位で取得して、カンマを区切りになっている文字列を ["aaa", "bbb", ... , "zzz"] の配列形式に変換してNSMutableArray型のメンバ変数へ代入します。データの持ち方としては、配列の中にさらに配列があるような形にはなります。

QuizController.swift
//CSVデータから問題を取得するメソッド
func setProblemsFromCSV() {
    
    //問題を(CSV形式で準備)読み込む
    let csvBundle = NSBundle.mainBundle().pathForResource("problem", ofType: "csv")
    
    //CSVデータの解析処理
    do {
        
        //CSVデータを読み込む
        var csvData: String = try String(contentsOfFile: csvBundle!, encoding: NSUTF8StringEncoding)
        
        csvData = csvData.stringByReplacingOccurrencesOfString("\r", withString: "")
        
        //改行を基準にしてデータを分割する読み込む
        let csvArray = csvData.componentsSeparatedByString("\n")
        
        //CSVデータの行数分ループさせる
        for line in csvArray {
            
            //カンマ区切りの1行を["aaa", "bbb", ... , "zzz"]形式に変換して代入する
            let parts = line.componentsSeparatedByString(",")
            self.problemArray.addObject(parts)
        }
        
        //配列を引数分の要素をランダムにシャッフルする(※Extension.swift参照)
        self.problemArray.shuffle(self.problemArray.count)
        
    } catch let error as NSError {
        print(error.localizedDescription)
    }
    
}

以上がCSVファイルからデータを取得し配列に格納する処理になります。その次はSwiftのExtensionを利用してNSMutableArrayにメソッドを追加する処理について見ていきます。

★extensionを用いてNSMutableArrayに配列要素をシャッフルするメソッドを追加する

CSVから取得したデータを配列に格納するところまでできましたが、NSMutableArrayクラスには配列要素をシャッフルする機能はありません。自前で前述のプログラムに追加する方法もありますが、その他でも使いたい局面もあるかと思いますので、別ファイルとして切り出してNSMutabeArrayクラスを拡張する形にしました。

Extension.swift
import Foundation

//NSMutableArrayクラスのshuffleメソッドを実装
extension NSMutableArray {
    
    //NSMutableArrayの中身をランダムに並べ替えする処理
    func shuffle(count: Int) {
        for i in 0..<count {
            let nElements: Int = count - i
            let n: Int = Int(arc4random_uniform(UInt32(nElements))) + i
            self.exchangeObjectAtIndex(i, withObjectAtIndex: n)
        }
    }
    
}

このファイルを追加してあげることによって、shuffleメソッドが使えるようになります。
また、このメソッドの引数はシャッフルする配列の対象要素数にしています。

■ エクステンションに関する参考資料:

ProtocolやExtensionに関しては私自身もまだまだ理解が浅いので、きっちりと説明できないことがとても恐縮なのですが、今回は登録されている問題をCSVファイルから取得して配列に格納し、その要素をシャッフルしたかったので、NSMutableArrayクラスにメソッドを追加しました。

あとがき

アプリ開発を始めたばかりの頃はそんなに多くのことができるわけではないので、まずは出来る範囲で動くものを作ってみるという訓練をAppStoreに公開する・しないは一旦置いておいてすると良いかとも思います。今回のサンプルは自分がiPhoneアプリ開発を始めたばかりの頃にプロトタイプで作成していたクイズアプリに、

  • タイマーを使った時間制限のロジック
  • データ永続化の仕組みを利用した履歴データの格納
  • 履歴データをもとにしたグラフの表示
  • 便利なライブラリを活用

という要素を加えて作ってみました。今にして思えば触り立ての頃は、本当に拙いサンプルではあったと思いますが、楽しむことと少しずつでいいのでコードと向き合うということの繰り返しをすることで、そしてGithub等で公開をしたり勉強会の登壇をすることで自分自身の勉強にもなりました。

私自身もアプリ開発が本職ではない為、XCodeを触らない日が続くと思い出すのに時間がかかってしまいます。アプリ開発に出会ってようやく1年ぐらいが経とうとしていますが、いつまでも初心と楽しむことを忘れずにアプトプットできたらということを思いながら今回のサンプルを書いてみました。

Swift最新版対応ブランチについて

こちらの記事では過去のバージョンのものだったので、Swift最新版でも動く様に改修しています。
問題を選び出すロジックに関しては、ほとんど変更はしていませんが、Chartsライブラリを使用したゲーム結果のグラフ表示に関しては、下記のような折れ線グラフに変更をしています。

swift3_capture.jpg

★イントロ画面:

ViewController.swift
//
//  ViewController.swift
//  TimerQuiz
//
//  Created by 酒井文也 on 2016/03/17.
//  Copyright © 2016年 just1factory. All rights reserved.
//

import UIKit

//テーブルビューに関係する定数
struct GuidanceTableStruct {
    static let cellCount: Int = 5
    static let cellSectionCount: Int = 1
}

class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, UINavigationControllerDelegate {

    //Outlet接続をした部品
    @IBOutlet var guideTableView: UITableView!
    
    //テーブルビューに表示する文言の内容を入れておくメンバ変数
    var guidanceArray: NSMutableArray = []
    
    //画面出現のタイミングに読み込まれる処理
    override func viewWillAppear(_ animated: Bool) {
        
        //ガイダンス用のテーブルビューに表示するテキストを(CSV形式で準備)読み込む
        let csvBundle = Bundle.main.path(forResource: "guidance", ofType: "csv")
        
        //CSVデータの解析処理
        do {
            
            //CSVデータを読み込む
            var csvData: String = try String(contentsOfFile: csvBundle!, encoding: String.Encoding.utf8)
            
            csvData = csvData.replacingOccurrences(of: "\r", with: "")
            
            //改行を基準にしてデータを分割する読み込む
            let csvArray = csvData.components(separatedBy: "\n")
            
            //CSVデータの行数分ループさせる
            for line in csvArray {
                
                //カンマ区切りの1行を["aaa", "bbb", ... , "zzz"]形式に変換して代入する
                let parts = line.components(separatedBy: ",")
                guidanceArray.add(parts)
            }
            
        } catch let error as NSError {
            print(error.localizedDescription)
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        //ナビゲーションのデリゲート設定
        self.navigationController?.delegate = self
        self.navigationItem.title = "食べ合わせクイズ"
        
        //テーブルビューのデリゲート設定
        guideTableView.delegate = self
        guideTableView.dataSource = self
        
        //自動計算の場合は必要
        guideTableView.estimatedRowHeight = 48
        guideTableView.rowHeight = UITableViewAutomaticDimension
        
        //Xibのクラスを読み込む
        let nibDefault:UINib = UINib(nibName: "guidanceCell", bundle: nil)
        guideTableView.register(nibDefault, forCellReuseIdentifier: "guidanceCell")
    }

    //TableViewに関する設定一覧(セクション数)
    func numberOfSections(in tableView: UITableView) -> Int {
        return GuidanceTableStruct.cellSectionCount
    }
    
    //TableViewに関する設定一覧(セクションのセル数)
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return GuidanceTableStruct.cellCount
    }
    
    //TableViewに関する設定一覧(セルに関する設定)
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        //Xibファイルを元にデータを作成する
        let cell = tableView.dequeueReusableCell(withIdentifier: "guidanceCell") as? guidanceCell
        
        //取得したデータを読み込ませる
        //配列 → 0番目:タイトル, 1番目:説明文,
        let guidanceData: NSArray = self.guidanceArray[indexPath.row] as! NSArray
        
        cell!.guidanceTitle.text = guidanceData[0] as? String
        cell!.guidanceDescription.text = guidanceData[1] as? String
        
        //セルのアクセサリタイプと背景の設定
        cell!.accessoryType = UITableViewCellAccessoryType.none
        cell!.selectionStyle = UITableViewCellSelectionStyle.none
        
        return cell!
    }
    
    //データをリロードした際に読み込まれるメソッド
    func reloadData() {
        guideTableView.reloadData()
    }
    
    //クイズ画面に遷移するアクション
    @IBAction func goQuizAction(_ sender: AnyObject) {
        performSegue(withIdentifier: "goQuiz", sender: nil)
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
}

★クイズ画面:

QuizController.swift
//
//  QuizController.swift
//  TimerQuiz
//
//  Created by 酒井文也 on 2016/03/24.
//  Copyright © 2016年 just1factory. All rights reserved.
//

import UIKit

//回答した番号の識別用enum
enum Answer: Int {
    case one = 1
    case two = 2
    case three = 3
    case four = 4
}

//ゲームに関係する定数
struct QuizStruct {
    static let timerDuration: Double = 10
    static let dataMaxCount: Int = 5
    static let limitTimer: Double = 10.000
    static let defaultCounter: Int = 10
}

class QuizController: UIViewController, UINavigationControllerDelegate, UITextViewDelegate {

    //Outlet接続をした部品
    @IBOutlet var timerDisplayLabel: UILabel!
    
    @IBOutlet var problemCountLabel: UILabel!
    @IBOutlet var problemTextView: UITextView!
    
    @IBOutlet var answerButtonOne: UIButton!
    @IBOutlet var answerButtonTwo: UIButton!
    @IBOutlet var answerButtonThree: UIButton!
    @IBOutlet var answerButtonFour: UIButton!
    
    //タイマー関連のメンバ変数
    var pastCounter: Int = 10
    var perSecTimer: Timer? = nil
    var doneTimer: Timer? = nil
    
    //問題関連のメンバ変数
    var counter: Int = 0
    
    //正解数と経過した時間
    var correctProblemNumber: Int = 0
    var totalSeconds: Double = 0.000
    
    //問題の内容を入れておくメンバ変数(計5問)
    var problemArray: NSMutableArray = []
    
    //問題毎の回答時間を算出するための時間を一時的に格納するためのメンバ変数
    var tmpTimerCount: Double!
    
    //タイム表示用のメンバ変数
    var timeProblemSolvedZero: Date!  //画面表示時点の時間
    var timeProblemSolvedOne: Date!   //第1問回答時点の時間
    var timeProblemSolvedTwo: Date!   //第2問回答時点の時間
    var timeProblemSolvedThree: Date! //第3問回答時点の時間
    var timeProblemSolvedFour: Date!  //第4問回答時点の時間
    var timeProblemSolvedFive: Date!  //第5問回答時点の時間
    
    //画面出現中のタイミングに読み込まれる処理
    override func viewWillAppear(_ animated: Bool) {
        
        //計算配列のセット
        setProblemsFromCSV()
    }

    //画面出現しきったタイミングに読み込まれる処理
    override func viewDidAppear(_ animated: Bool) {
        
        //ラベルを表示を「しばらくお待ちください...」から「あと10秒」という表記へ変更する
        timerDisplayLabel.text = "あと" + String(self.pastCounter) + "秒"
        
        //ボタンを全て活性状態にする
        allAnswerBtnEnabled()
        
        //問題を取得する
        createNextProblem()
        
        //1問目の解き始めの時間を保持する
        timeProblemSolvedZero = Date()
        
        //タイマーをセットする
        setTimer()
    }
    
    //画面が消えるタイミングに読み込まれる処理
    override func viewWillDisappear(_ animated: Bool) {
        
        //ラベルを表示を「しばらくお待ちください...」へ戻す
        timerDisplayLabel.text = "しばらくお待ちください..."
        
        //タイマーをリセットしておく
        resetTimer()
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        //ナビゲーションのデリゲート設定
        self.navigationController?.delegate = self
        self.navigationItem.title = "問題を解く"
        
        //テキストフィールドのデリゲート
        problemTextView.delegate = self
    }
    
    //各選択肢のボタンアクション
    @IBAction func answerActionOne(_ sender: AnyObject) {
        judgeCurrentAnswer(Answer.one.rawValue)
    }
    
    @IBAction func answerActionTwo(_ sender: AnyObject) {
        judgeCurrentAnswer(Answer.two.rawValue)
    }
    
    @IBAction func answerActionThree(_ sender: AnyObject) {
        judgeCurrentAnswer(Answer.three.rawValue)
    }
    
    @IBAction func answerActionFour(_ sender: AnyObject) {
        judgeCurrentAnswer(Answer.four.rawValue)
    }

    //タイマーをセットするメソッド
    func setTimer() {
        
        //毎秒ごとにperSecTimerDoneメソッドを実行するタイマーを作成する
        self.perSecTimer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(QuizController.perSecTimerDone), userInfo: nil, repeats: true)
        
        //指定秒数後にtimerDoneメソッドを実行するタイマーを作成する(問題の時間制限に到達した場合の実行)
        self.doneTimer = Timer.scheduledTimer(timeInterval: QuizStruct.timerDuration, target: self, selector: #selector(QuizController.timerDone), userInfo: nil, repeats: true)
    }
    
    //毎秒ごとのタイマーで呼び出されるメソッド
    func perSecTimerDone() {
        pastCounter -= 1
        timerDisplayLabel.text = "あと" + String(self.pastCounter) + "秒"
    }
    
    //問題の時間制限に到達した場合に実行されるメソッド
    func timerDone() {
        
        //10秒経過時は不正解として次の問題を読み込む
        totalSeconds = self.totalSeconds + QuizStruct.limitTimer
        pastCounter = QuizStruct.defaultCounter
        
        switch counter {
        case 0:
            timeProblemSolvedOne = Date()
        case 1:
            timeProblemSolvedTwo = Date()
        case 2:
            timeProblemSolvedThree = Date()
        case 3:
            timeProblemSolvedFour = Date()
        case 4:
            timeProblemSolvedFive = Date()
        default:
            tmpTimerCount = 0.000
        }
        
        //カウンターの値に+1をする
        counter += 1
        
        //タイマーを再設定する
        reloadTimer()
    }
    
    //CSVデータから問題を取得するメソッド
    func setProblemsFromCSV() {
        
        //問題を(CSV形式で準備)読み込む
        let csvBundle = Bundle.main.path(forResource: "problem", ofType: "csv")
        
        //CSVデータの解析処理
        do {
            
            //CSVデータを読み込む
            var csvData: String = try String(contentsOfFile: csvBundle!, encoding: String.Encoding.utf8)
            
            csvData = csvData.replacingOccurrences(of: "\r", with: "")
            
            //改行を基準にしてデータを分割する読み込む
            let csvArray = csvData.components(separatedBy: "\n")
            
            //CSVデータの行数分ループさせる
            for line in csvArray {
                
                //カンマ区切りの1行を["aaa", "bbb", ... , "zzz"]形式に変換して代入する
                let parts = line.components(separatedBy: ",")
                problemArray.add(parts)
            }
            
            //配列を引数分の要素をランダムにシャッフルする(※Extension.swift参照)
            problemArray.shuffle(self.problemArray.count)
            
        } catch let error as NSError {
            print(error.localizedDescription)
        }
        
    }
    
    //タイマーを破棄して再起動を行うメソッド
    func reloadTimer() {
        
        //タイマーを破棄する
        resetTimer()
        
        //結果表示ページへ遷移するか次の問題を表示する
        compareNextProblemOrResultView()
    }
    
    //次の問題を表示を行うメソッド
    func createNextProblem() {
        
        //取得した問題を取得する
        let targetProblem: NSArray = self.problemArray[self.counter] as! NSArray
        
        //ラベルに表示されている値を変更する
        //配列 → 0番目:問題文, 1番目:正解の番号, 2番目:1番目の選択肢, 3番目:2番目の選択肢, 4番目:3番目の選択肢, 5番目:4番目の選択肢
        problemCountLabel.text = "第" + String(self.counter + 1) + "問"
        problemTextView.text = targetProblem[0] as! String
        
        //ボタンに選択肢を表示する
        answerButtonOne.setTitle("1." + String(describing: targetProblem[2]), for: UIControlState())
        answerButtonTwo.setTitle("2." + String(describing: targetProblem[3]), for: UIControlState())
        answerButtonThree.setTitle("3." + String(describing: targetProblem[4]), for: UIControlState())
        answerButtonFour.setTitle("4." + String(describing: targetProblem[5]), for: UIControlState())
    }
    
    //選択された答えが正しいか誤りかを判定するメソッド
    func judgeCurrentAnswer(_ answer: Int) {
        
        //ボタンを全て非活性にする
        allAnswerBtnDisabled()
        
        //カウントを元に戻す
        pastCounter = QuizStruct.defaultCounter
        
        //[問題の回答時間] = [n問目の回答した際の時間] - [(n-1)問目の回答した際の時間]として算出する
        switch counter {
            case 0:
                timeProblemSolvedOne = Date()
                tmpTimerCount = self.timeProblemSolvedOne.timeIntervalSince(self.timeProblemSolvedZero)
            case 1:
                timeProblemSolvedTwo = Date()
                tmpTimerCount = self.timeProblemSolvedTwo.timeIntervalSince(self.timeProblemSolvedOne)
            case 2:
                timeProblemSolvedThree = Date()
                tmpTimerCount = self.timeProblemSolvedThree.timeIntervalSince(self.timeProblemSolvedTwo)
            case 3:
                timeProblemSolvedFour = Date()
                tmpTimerCount = self.timeProblemSolvedFour.timeIntervalSince(self.timeProblemSolvedThree)
            case 4:
                timeProblemSolvedFive = Date()
                tmpTimerCount = self.timeProblemSolvedFive.timeIntervalSince(self.timeProblemSolvedFour)
            default:
                tmpTimerCount = 0.000
        }
        
        //合計時間に問題の回答時間を加算する
        totalSeconds = totalSeconds + tmpTimerCount
        
        //該当の問題の回答番号を取得する
        let targetProblem: NSArray = problemArray[counter] as! NSArray
        let targetAnswer: Int = Int(targetProblem[1] as! String)!
        
        //カウンターの値に+1をする
        counter += 1
        
        //もし回答の数字とメソッドの引数が同じならば正解数の値に+1する
        if answer == targetAnswer {
            correctProblemNumber += 1
        }
        
        //タイマーを再設定する
        reloadTimer()
    }
    
    //結果表示ページへ遷移するか次の問題を表示するかを決めるメソッド
    func compareNextProblemOrResultView() {
        
        if counter == QuizStruct.dataMaxCount {
            
            /**
             *(処理)規定回数まで到達した場合は次の画面へ遷移する
             */
            
            //タイマーを破棄する
            resetTimer()
            
            //Realmに計算結果データを保存する
            let gameScoreObject = GameScore.create()
            gameScoreObject.correctAmount = self.correctProblemNumber
            gameScoreObject.timeCount = NSString(format:"%.3f", self.totalSeconds) as String
            gameScoreObject.createDate = Date()
            gameScoreObject.save()
            
            //次のコントローラーへ遷移する
            self.performSegue(withIdentifier: "goScore", sender: nil)
            
        } else {
            
            /**
             *(処理)規定回数に達していない場合はカウントをリセットして次の問題を表示する
             */
            
            //ボタンを全て活性にする
            allAnswerBtnEnabled()
            
            //次の問題をセットする
            createNextProblem()
            
            //ラベルの値を再セットする
            timerDisplayLabel.text = "あと" + String(pastCounter) + "秒"
            
            //タイマーをセットする
            setTimer()
        }
    }
    
    //セグエを呼び出したときに呼ばれるメソッド
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        
        //セグエ名で判定を行う
        if segue.identifier == "goScore" {
            
            //遷移先のコントローラーの変数を用意する
            let scoreController = segue.destination as! ScoreController
            
            //遷移先のコントローラーに渡したい変数を格納(型を合わせてね)
            scoreController.correctProblemNumber = correctProblemNumber
            scoreController.totalSeconds = NSString(format:"%.3f", totalSeconds) as String
            
            //計算結果を入れる変数を初期化
            self.resetGameValues()
        }
    }
    
    //ゲームのカウントに関する数を初期化する
    func resetGameValues() {
        counter = 0
        correctProblemNumber = 0
        totalSeconds = 0.000
    }
    
    //タイマー処理を全てリセットするメソッド
    func resetTimer() {
        perSecTimer!.invalidate()
        doneTimer!.invalidate()
    }
    
    //全ボタンを非活性にする
    func allAnswerBtnDisabled() {
        answerButtonOne.isEnabled = false
        answerButtonTwo.isEnabled = false
        answerButtonThree.isEnabled = false
        answerButtonFour.isEnabled = false
    }
    
    //全ボタンを活性にする
    func allAnswerBtnEnabled() {
        answerButtonOne.isEnabled = true
        answerButtonTwo.isEnabled = true
        answerButtonThree.isEnabled = true
        answerButtonFour.isEnabled = true
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }   
}

★結果表示画面:

ScoreController.swift
//
//  ScoreController.swift
//  TimerQuiz
//
//  Created by 酒井文也 on 2016/03/24.
//  Copyright © 2016年 just1factory. All rights reserved.
//

import UIKit

//Realmクラスのインポート
import RealmSwift

//Charsクラスのインポート
import Charts

//グラフに描画する要素数に関するenum
enum GraphXLabelList : Int {
    
    case one = 1
    case two = 2
    case three = 3
    case four = 4
    case five = 5
    
    static func getXLabelList(_ count: Int) -> [String] {
        
        var xLabels: [String] = []
        
        //※この画面に遷移したらデータが登録されるので0は考えなくて良い
        if count == self.one.rawValue {
            xLabels = ["最新"]
        } else if count == self.two.rawValue {
            xLabels = ["最新", "2つ前"]
        } else if count == self.three.rawValue {
            xLabels = ["最新", "2つ前", "3つ前"]
        } else if count == self.four.rawValue {
            xLabels = ["最新", "2つ前", "3つ前", "4つ前"]
        } else {
            xLabels = ["最新", "2つ前", "3つ前", "4つ前", "5つ前"]
        }
        return xLabels
    }
    
}

//日付の相互変換用
struct ChangeDate {
    
    //Date → Stringへの変換
    static func convertDateToString (_ date: Date) -> String {
        let dateFormatter: DateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "ja_JP")
        dateFormatter.dateFormat = "yyyy/MM/dd"
        let dateString: String = dateFormatter.string(from: date)
        return dateString
    }
}

//テーブルビューに関係する定数
struct ScoreTableStruct {
    static let cellSectionCount: Int = 1
    static let cellHeight: CGFloat = 79.5
}

class ScoreController: UIViewController ,UITableViewDelegate, UITableViewDataSource, UINavigationControllerDelegate {

    //QuizControllerより引き渡される値を格納する
    var correctProblemNumber: Int!
    var totalSeconds: String!
    
    //テーブルデータ表示用に一時的にすべてのfetchデータを格納する
    var scoreArrayForCell: NSMutableArray = []
    
    //棒グラフ用のメンバ変数
    var barChartView: BarChartView = BarChartView()
    
    //Outlet接続した部品一覧
    @IBOutlet var resultDisplayLabel: UILabel!
    @IBOutlet var analyticsSegmentControl: UISegmentedControl!
    @IBOutlet var resultHistoryTable: UITableView!
    @IBOutlet var resultGraphView: UIView!
    
    //画面出現中のタイミングに読み込まれる処理
    override func viewWillAppear(_ animated: Bool) {
        
        //QuizControllerから渡された値を出力
        resultDisplayLabel.text = "正解数:合計" + String(correctProblemNumber) + "問 / 経過時間:" + totalSeconds + "秒"
        
        //Realmから履歴データを呼び出す
        fetchHistoryDataFromRealm()
        
        //セグメントコントロールの初期値を設定する
        analyticsSegmentControl.selectedSegmentIndex = 0
        resultHistoryTable.alpha = 1
        resultGraphView.alpha = 0
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        //ナビゲーションのデリゲート設定
        self.navigationController?.delegate = self
        self.navigationItem.title = "ゲーム結果"
                
        //テーブルビューのデリゲート設定
        resultHistoryTable.delegate = self
        resultHistoryTable.dataSource = self
        
        //Xibのクラスを読み込む
        let nibDefault: UINib = UINib(nibName: "scoreCell", bundle: nil)
        resultHistoryTable.register(nibDefault, forCellReuseIdentifier: "scoreCell")
        
        //データを成型して表示する(変数xLabelsとunitSoldに入る配列の要素数は合わせないと落ちる)
        let unitsSold: [Double] = GameScore.fetchGraphGameScore()
        let xLabels = GraphXLabelList.getXLabelList(unitsSold.count)
        let xLabelsCount = xLabels.count
        
        var dataEntries: [BarChartDataEntry] = []
        
        for i in 0..<xLabelsCount {
            
            print(unitsSold[i])
            
            let dataEntry = BarChartDataEntry(x: Double(i), y: Double(unitsSold[i]))
            dataEntries.append(dataEntry)
        }
        
        //グラフに描画するデータを表示する
        let barChartDataSet = BarChartDataSet(values: dataEntries, label: "直近5回の得点を表示しています")
        let barChartData = BarChartData(dataSet: barChartDataSet)

        //BarChartViewのインスタンスに値を追加する
        // (参考) バーチャートの装飾参考
        // http://stackoverflow.com/questions/40323288/how-to-set-x-axis-labels-with-ios-charts
        // https://fussan-blog.com/swift3-charts/

        barChartView.chartDescription?.text = "得点推移グラフ"
        barChartView.animate(xAxisDuration: 1.0, yAxisDuration: 1.0)
        barChartView.data = barChartData
        barChartView.xAxis.valueFormatter = IndexAxisValueFormatter(values: xLabels)
        barChartView.xAxis.granularity = 1

        //UIViewの中にLineChartViewを追加する
        resultGraphView.addSubview(barChartView)
    }

    //レイアウト処理が完了した際の処理
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        //レイアウトの再配置
        barChartView.frame = CGRect(x: 0, y: 0, width: resultGraphView.frame.width, height: resultGraphView.frame.height)
    }
    
    //TableViewに関する設定一覧(セクション数)
    func numberOfSections(in tableView: UITableView) -> Int {
        return ScoreTableStruct.cellSectionCount
    }
    
    //TableViewに関する設定一覧(セクションのセル数)
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return scoreArrayForCell.count
    }
    
    //TableViewに関する設定一覧(セルに関する設定)
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        //Xibファイルを元にデータを作成する
        let cell = tableView.dequeueReusableCell(withIdentifier: "scoreCell") as? scoreCell
        
        //取得したデータを読み込ませる
        let scoreData: GameScore = self.scoreArrayForCell[indexPath.row] as! GameScore
        
        cell!.scoreDate.text = ChangeDate.convertDateToString(scoreData.createDate)
        cell!.scoreAmount.text = "あなたの正解数:" + String(scoreData.correctAmount) + "問正解"
        cell!.scoreTime.text = "あなたのかかった時間:" + String(scoreData.timeCount) + "秒"
 
        //セルのアクセサリタイプと背景の設定
        cell!.accessoryType = UITableViewCellAccessoryType.none
        cell!.selectionStyle = UITableViewCellSelectionStyle.none
        
        return cell!
    }
    
    //TableView: セルの高さを返す
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return ScoreTableStruct.cellHeight
    }
    
    //TableView: テーブルビューをリロードする
    func reloadData(){
        self.resultHistoryTable.reloadData()
    }
    
    //Realmに計算結果データを持ってくるメソッド(履歴一覧に表示するためのもの)
    func fetchHistoryDataFromRealm() {
        
        //履歴データをフェッチしてTableViewへの一覧表示用のデータを作成
        scoreArrayForCell.removeAllObjects()
        let gameScores = GameScore.fetchAllGameScore()
        
        if gameScores.count != 0 {
            for gameScore in gameScores {
                scoreArrayForCell.add(gameScore)
            }
        }
        
        //テーブルビューをリロード
        reloadData()
        
        //セグメントコントロール位置の初期設定
        analyticsSegmentControl.selectedSegmentIndex = 0
    }
    
    //セグメントコントロールで表示するものを切り替える
    @IBAction func changeDataDisplayAction(_ sender: AnyObject) {
        
        switch sender.selectedSegmentIndex {
            
            case 0:
                resultHistoryTable.alpha = 1
                resultGraphView.alpha = 0
                break
            
            case 1:
                resultHistoryTable.alpha = 0
                resultGraphView.alpha = 1
                break
            
            default:
                resultHistoryTable.alpha = 1
                resultGraphView.alpha = 0
                break
        }
    }
    
    //このサンプルの解説のページをSafariで立ち上げる
    @IBAction func goExplainHowtoAction(_ sender: AnyObject) {
        
        //Safariで立ち上げるようにする
        let url = URL(string: "http://qiita.com/fumiyasac@github/items/18ae522885b5aa507ca3")
        
        if #available(iOS 10, *) {
            UIApplication.shared.open(url!, options: [:])
        } else {
            let app: UIApplication = UIApplication.shared
            app.openURL(url!)
        }
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
}

このサンプルで使用しているグラフ表示ライブラリChartsがSwiftのバージョンアップの影響が比較的大きかったので、今回は実装例が多かった棒グラフでの実装に置き換えた感じです。

追記とその他

2017/05/05:

  • masterブランチをSwift最新版に対応&折れ線グラフから棒グラフに変更しました。(Swift2.3ブランチはそのまま折れ線グラフになっています。)

2016/10/30:

  • こちらのサンプルをSwift2.3に対応したブランチを作成しました。

Swift2.3対応版のサンプルソースはこちら

2016/04/02:

2016/03/28:

  • GithubへのPull Requestならびに要望や改善に関する提案も受け付けていますのでお気軽にどうぞ!
76
73
12

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
76
73

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?