vol.4 問題スタート画面を制作する – SwiftでiOS用のクイズアプリを作る!

iOS Simulator Screen Shot 2015.04.10 1.38.29

今回は前回の問題選択画面の作成に続き、問題スタート画面を作成していきます。

ポイント

start

今回のポイントとしてはまずは「画面の切り替わり」を実現しなければならない点ですね。

今回以降の「問題スタート画面〜解答画面〜問題終了画面」は画面遷移無しでひとつのViewController内ですべて完了したいと考えています。

なので今回のスタート画面は一連の画面構成の中でひとつであり、管理やメンテナンスがしやすいように分離しておいたり他画面との差別化をしっかりしておきたいですね。

あとはタイマーですね。解答にかかった時間を「スタートボタン」を押した直後から計測して保持する仕組みがいります。

ワイヤーフレームでは分かりづらいところなのですが画面上部に現在地、残り問題数を示す「プログレスバー」を配置するので、JSONから取得した当該問題(集)の問題数をカウントしてバーの長さが可変するような仕組みも必要です。

作業手順の想定

  1. 前回で選択された問題に属するクイズをJSONから取得(値渡し)
  2. 問題タイトル、全問題数を表示するUILabelとUIView郡、スタートボタンを作成して配置
  3. プログレスバーをUIViewで作成して画面上部に配置
  4. タイマーを作成してスタートボタンで発火するようにする

こんなところでしょうか。

前述のように今回作業は次回以降と同じViewController内に記述するのでわかりやすく内部メソッドなどにするなどして分離しておく必要がありますね。

実際の作業手順

今回の画面は起動画面にレイアウトが似ていたので油断していたのですが、中身はまったく別作業やハマりどころが多くて泣けました;;

import UIKit

class QuestionViewController: UIViewController {
    
    var quiz = []
    var quiz_title:String?
    var time : Float = 0
    
    //ステータスバーを非表示に
    override func prefersStatusBarHidden() -> Bool {
        return true
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        //値を受け取る
        var appDelegate:AppDelegate = UIApplication.sharedApplication().delegate as AppDelegate
        var genre = appDelegate.genre
        var id = appDelegate.id
        
        // jsonファイル読み込み
        var path = NSBundle.mainBundle().pathForResource("quiz", ofType: "json")
        var jsondata = NSData(contentsOfFile: path!)
        
        //swify
        var json = JSON(data:jsondata!)
        
        for ( i:String , v:JSON) in json[genre!] {
            if(v["id"].int == id){
                self.quiz_title = v["title"].string!
                self.quiz = v["quiz"].arrayObject!
                break
            }
        }
                
        //スタート画面背景
        let viewA = UIView(frame:CGRectZero)
        viewA.backgroundColor = UIColor(red:0.29,green:0.53,blue:0.91,alpha:1.0)
        viewA.setTranslatesAutoresizingMaskIntoConstraints(false)
        self.view.addSubview(viewA)
        
        //中央寄せのためのエリア
        let viewB = UIView(frame:CGRectZero)
//        viewB.backgroundColor = UIColor.cyanColor()
        viewB.setTranslatesAutoresizingMaskIntoConstraints(false)
        viewA.addSubview(viewB)
        
        //タイトルのラベルを作成
        let titleLabel = UILabel(frame:CGRectZero)
        titleLabel.textColor = UIColor.whiteColor()
        titleLabel.textAlignment = NSTextAlignment.Center
        titleLabel.font = UIFont(name: "HiraKakuProN-W6", size: 20)
        titleLabel.numberOfLines = 0;
        titleLabel.sizeToFit()
        //タイトルのラベルの装飾関連
        var style = NSMutableParagraphStyle()
        style.lineHeightMultiple = 1.5
        style.alignment = NSTextAlignment.Center
        let attributes = [NSParagraphStyleAttributeName : style]
        titleLabel.attributedText = NSAttributedString(string: self.quiz_title!, attributes:attributes)
        
        titleLabel.setTranslatesAutoresizingMaskIntoConstraints(false)
        viewB.addSubview(titleLabel)
        
        //問題数のラベルを作成
        let descLabel = UILabel(frame:CGRectZero)
        descLabel.text = "問題数:" + String(quiz.count) + "問"
        descLabel.textColor = UIColor.whiteColor()
        descLabel.font = UIFont(name: "HiraKakuProN-W3", size: 15)
        descLabel.textAlignment = NSTextAlignment.Center
        descLabel.setTranslatesAutoresizingMaskIntoConstraints(false)
        viewB.addSubview(descLabel)
        
        //スタートボタン
        let startBtn = UIButton(frame:CGRectZero)
        startBtn.backgroundColor = UIColor.greenColor()
        startBtn.setTitle("スタート!", forState: UIControlState.Normal)
        startBtn.setTitleColor(UIColor.whiteColor(), forState: UIControlState.Normal)
        startBtn.setTitle("ボタン(押された時)", forState: UIControlState.Highlighted)
        startBtn.setTitleColor(UIColor.whiteColor(), forState: UIControlState.Highlighted)
        startBtn.titleLabel!.font = UIFont.systemFontOfSize(UIFont.buttonFontSize())
        startBtn.layer.cornerRadius = 5.0
        startBtn.tag = 7//intじゃないとダメです
        startBtn.addTarget(self, action: "start:", forControlEvents: .TouchUpInside)
        startBtn.setTranslatesAutoresizingMaskIntoConstraints(false)
        viewB.addSubview(startBtn)

        //戻るボタン
        let backBtn = UIButton(frame:CGRectZero)
        backBtn.backgroundColor = UIColor.grayColor()
        backBtn.setTitle("戻る", forState: UIControlState.Normal)
        backBtn.setTitleColor(UIColor.whiteColor(), forState: UIControlState.Normal)
        backBtn.setTitle("ボタン(押された時)", forState: UIControlState.Highlighted)
        backBtn.setTitleColor(UIColor.whiteColor(), forState: UIControlState.Highlighted)
        backBtn.layer.cornerRadius = 5.0
        backBtn.titleLabel!.font = UIFont.systemFontOfSize(UIFont.buttonFontSize())
        backBtn.tag = 5//intじゃないとダメです
        backBtn.addTarget(self, action: "back:", forControlEvents: .TouchUpInside)
        backBtn.setTranslatesAutoresizingMaskIntoConstraints(false)
        viewB.addSubview(backBtn)
        
        let vd = [
            "viewA":viewA,
            "viewB":viewB,
            "titleLabel":titleLabel,
            "descLabel":descLabel,
            "startBtn":startBtn,
            "backBtn":backBtn,
            "sv":super.view
        ]
        
        //viewA
        let viewACn1:Array = NSLayoutConstraint.constraintsWithVisualFormat("|-0-[viewA]-0-|", options: NSLayoutFormatOptions(0), metrics: nil, views: vd)
        let viewACn2:Array = NSLayoutConstraint.constraintsWithVisualFormat("V:|-0-[viewA]-0-|", options: NSLayoutFormatOptions(0), metrics: nil, views: vd)
        super.view.addConstraints(viewACn1)
        super.view.addConstraints(viewACn2)
        
        //viewB
        var w1 = UIScreen.mainScreen().bounds.size.width
        let w2:String = "\(w1)"
        let viewBCn1:Array = NSLayoutConstraint.constraintsWithVisualFormat("[viewA]-(<=1)-[viewB(" + w2 + ")]",options: NSLayoutFormatOptions.AlignAllCenterY,metrics: nil,views: vd)
        let viewBCn2:Array = NSLayoutConstraint.constraintsWithVisualFormat("V:[viewA]-(<=1)-[viewB(250)]",options: NSLayoutFormatOptions.AlignAllCenterX,metrics: nil,views: vd)
        viewA.addConstraints(viewBCn1)
        viewA.addConstraints(viewBCn2)
        
        //titleLabel
        let titleLabelCn1:Array = NSLayoutConstraint.constraintsWithVisualFormat("|-50-[titleLabel]-50-|", options: NSLayoutFormatOptions(0), metrics: nil, views: vd)
        let titleLabelCn2:Array = NSLayoutConstraint.constraintsWithVisualFormat("V:|-0-[titleLabel]", options: NSLayoutFormatOptions(0), metrics: nil, views: vd)
        viewB.addConstraints(titleLabelCn1)
        viewB.addConstraints(titleLabelCn2)
        
        //descLabel
        let descLabelCn1:Array = NSLayoutConstraint.constraintsWithVisualFormat("|-0-[descLabel]-0-|", options: NSLayoutFormatOptions(0), metrics: nil, views: vd)
        let descLabelCn2:Array = NSLayoutConstraint.constraintsWithVisualFormat("V:[titleLabel]-10-[descLabel]", options: NSLayoutFormatOptions(0), metrics: nil, views: vd)
        viewB.addConstraints(descLabelCn1)
        viewB.addConstraints(descLabelCn2)
        
        //startBtn
        let startBtnCn1:Array = NSLayoutConstraint.constraintsWithVisualFormat("|-40-[startBtn]-40-|", options: NSLayoutFormatOptions(0), metrics: nil, views: vd)
        let startBtnCn2:Array = NSLayoutConstraint.constraintsWithVisualFormat("V:[startBtn(50)]-30-[backBtn]", options: NSLayoutFormatOptions(0), metrics: nil, views: vd)
        viewB.addConstraints(startBtnCn1)
        viewB.addConstraints(startBtnCn2)
        
        //backBtn
        let backBtnCn1:Array = NSLayoutConstraint.constraintsWithVisualFormat("|-50-[backBtn]-50-|", options: NSLayoutFormatOptions(0), metrics: nil, views: vd)
        let backBtnCn2:Array = NSLayoutConstraint.constraintsWithVisualFormat("V:[backBtn(30)]-0-|", options: NSLayoutFormatOptions(0), metrics: nil, views: vd)
        viewB.addConstraints(backBtnCn1)
        viewB.addConstraints(backBtnCn2)
        
    }
    
    func start( sender : UIButton){
//        println("sender.tag:\(sender.tag)")
        
        NSTimer.scheduledTimerWithTimeInterval(0.1, target: self, selector: "timer:", userInfo: nil, repeats: true)
    }
    
    func timer(timer : NSTimer) {
        self.time += 0.1
        println(self.time)
    }

    func back( sender : UIButton){
        println("sender.tag:\(sender.tag)")
        let listViewController: UIViewController = ListViewController()
        listViewController.modalTransitionStyle = UIModalTransitionStyle.FlipHorizontal
        self.presentViewController(listViewController, animated: true, completion: nil)
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
}

今回は画面のシンプルさに見合わぬコード量でした。

というのも、これまでは要素をループなどで表示できていたのでコード量が少なく収まっていたのですが、今回のようなレイアウトだとひとつひとつの要素に設定をしていかなければならないからなんですね。

ちょっとStoryboardの優秀さを実感させられましたね・・・汎用性のコードレイアウトを取るか、コード量を劇的に減らすことのできるStoryboardを取るべきか・・・悩みます。

今回のコードで実現したのが以下の画面です。

iOS Simulator Screen Shot 2015.04.10 1.38.29

実はワイヤーフレームには無かった「戻る」ボタンがあったりします(苦笑)。

ではその辺も含めてポイントやハマりどころについて書いていきたいと思います。

「:」・・・たったこれだけに数時間

これはもうなんと言ったら良いのか・・・UIButtonが押された時に発火するメソッドを指定する時になぜかメソッド名の後ろに「:(コロン)」を付けないとアプリが落ちてしまうんです。

        startBtn.addTarget(self, action: "start:", forControlEvents: .TouchUpInside)

このactionのところで指定している「start」メソッドに最初はコロンを付けていなかったんです・・・それで動かなかったので四苦八苦していたのですが、まさかこんなことが原因とは思い当たることすらできずに時間がかかってしましました。

「なぜ:を付けなければならないのか?」が未だによくわかってはいませんが、今は意味よりも先に進むことを優先します。いつか理由がわかったら記事にしたいと思いますが。

問題データの読み込みについて

以下は問題データ(情報)を取得して処理する箇所なのですが、思うところがあるので個人的なメモとして残しておきます。

        // jsonファイル読み込み
        var path = NSBundle.mainBundle().pathForResource("quiz", ofType: "json")
        var jsondata = NSData(contentsOfFile: path!)
        
        //swify
        var json = JSON(data:jsondata!)
        
        for ( i:String , v:JSON) in json[genre!] {
            if(v["id"].int == id){
                self.quiz_title = v["title"].string!
                self.quiz = v["quiz"].arrayObject!
                break
            }
        }

これはjsonデータの作り方に問題があったということもあるのですが、この方法だとjsonの当該ジャンルの問題をひとつひとつ走査していって該当したタイミングでストップするという方法になってしまうのであまりよろしくないかな、と。

json側でidの持ち方を工夫することでいちいち走査しなくもいけるようにできると思うので次回はその辺を気をつけたいですね。

あと、ココでもジャンルデータをすべて取得していますが、同じことをジャンル選択後の問題選択画面でもやっているので、他の方向性としてはジャンル選択画面→問題選択画面に遷移した際にjsonから当該データをすべて取得して定数に入れておく方法もあるのかな、と。

今回はそこまでやりませんが、そうすることで問題をすべて終えたときに速やかに次の問題集に移ったりするのに便利そうです。

タイマーの作成

    func start( sender : UIButton){
//        println("sender.tag:\(sender.tag)")
        
        NSTimer.scheduledTimerWithTimeInterval(0.1, target: self, selector: "timer:", userInfo: nil, repeats: true)
    }
    
    func timer(timer : NSTimer) {
        self.time += 0.1
        println(self.time)
    }

タイマーに関してはNSTimerという便利なモノが用意されていたので簡単でした。

これまで見たどんな言語よりもタイマーに関して「だけ」は実装が簡単ですね(笑)。

ただ、ここでもselectorのところのメソッド名の後ろにコロンがあるところに気をつけたいですね。

戻るボタンの追加

これは単純に問題の一覧に戻れないのは不便だと感じたからなんですね。

このボタンが無いと一覧からこの画面に入ったときに問題をすべて終えないとどこにも行けなくなってしまうことに気がつけていませんでした。

ただ、この戻るボタンを配置して前回の画面を作る際に採用したPHPのセッションのような値の持ち回り方法にしておいて正解でした。

レイアウトについて

レイアウトに関しての基本的な考え方は起動画面を作成した時とほぼ一緒です。

iOS Simulator Screen Shot 2015.04.10 1.39.01

上下中央にするための要素(viewB)に色をつけるとこんな感じです。

ポイントとしてはタイトルの上部分に隙間ができているところで、これはバグではなくてタイトルにCSSでいうところの「line-height」を設定したからなんですね。

        //タイトルのラベルの装飾関連
        var style = NSMutableParagraphStyle()
        style.lineHeightMultiple = 1.5
        style.alignment = NSTextAlignment.Center
        let attributes = [NSParagraphStyleAttributeName : style]
        titleLabel.attributedText = NSAttributedString(string: self.quiz_title!, attributes:attributes)

これが当該箇所のコードですが、こんなに面倒くさいことをしないとline-heightをSwiftで実現することはできないんですね。

今回はあえてタイトルラベルの横幅を狭くして折り返すようにしてライン高と中央寄せがわかりやすいようにしています。

VFL内での変数の使用

これはやってみてわかったのですが、VFL(Visual Format Language)内で変数が使えました。

        //viewB
        var w1 = UIScreen.mainScreen().bounds.size.width
        let w2:String = "\(w1)"
        let viewBCn1:Array = NSLayoutConstraint.constraintsWithVisualFormat("[viewA]-(<=1)-[viewB(" + w2 + ")]",options: NSLayoutFormatOptions.AlignAllCenterY,metrics: nil,views: vd)
        let viewBCn2:Array = NSLayoutConstraint.constraintsWithVisualFormat("V:[viewA]-(<=1)-[viewB(250)]",options: NSLayoutFormatOptions.AlignAllCenterX,metrics: nil,views: vd)
        viewA.addConstraints(viewBCn1)
        viewA.addConstraints(viewBCn2)

VFLはエラーチェックに引っかからない時点でなんとなくそんな気はしていたのですが、VFLは「文字列」として認識されているようなので、外部から上記コードのようなカタチで変数を使用することができるんですね。

プログレスバーについて

予定していた問題の進行状況を示すプログレスバーはこの画面に配置してみたら単なるデザインのようになってしまったのでやめました^^;

次画面(問題の解答画面)から出して問題終了後の画面ではまた消えるほうが自然でしたね。


今回は前述したように「繰り返し処理」で画面を構成する箇所がなく、すべて一点モノだったのでコード量が増えてしまいました。

これだけシンプルな画面でこのボリュームですからね・・・まぁ、確かにhtml+CSS+Javascript+PHPを同じところに書いているようなモノ、と捉えれば決して多くはないのかもしれませんが・・・少しでも減らす方法はないものか、今後の課題ですね。

さて、次はいよいよクイズの解答画面の制作にとりかかっていきます!