Swiftで遊ぼう! on Hatena

あしたさぬきblogでやってた初心者オヤジのiOSプログラミング奮闘記がHatenaに来ました

Swiftで遊ぼう! - 371 - Developing iOS Apps (Swift) Implement a Custom Control

チュートリアル索引に戻る→

2017年3月27日:Swift 3に向けて改訂中*1

ここまでスムーズに理解できました。やはりだてに1年勉強していたわけじゃないなと思えました(笑)。じゃあ次の課題はどうでしょう。

今日からカスタムUIViewに取り組みます。

developer.apple.com

習うべき項目は以下です。

  • storyboardのエレメントをカスタムコードで制御する。
  • カスタムクラスを定義
  • カスタムクラスに初期化ステップを組み込む
  • コンテナとしてUIViewクラスを使う
  • プラグラム的に画面を表示させるステップの理解

重要なステップですね。今まで挫折していた内容を含みます。さて今回どれだけ理解できることやら。

これまでのチュートリアルでPhotoLibraryにある食事の写真を取り込むことができるようになりました。

f:id:yataiblue:20150708065504j:plain

次にその食事にレイティングを付ける機能を実装していくのですが、白い☆が5つ並んでタップすると黒い★に変化するオブジェクトをカスタムで作ります。

画面に設置するエレメントを用意するためにUIViewクラスを使っていましたが、Swift 3からUIViewを継承した「UIStackView」の使用に替わりました。

Swiftで遊ぼう! - ref1: カスタムクラスの準備 - Swiftで遊ぼう! on Hatena

ここの手順通りにUIStackViewクラスを継承した「RatingControl」を作ります。

プロジェクト・ナビゲーションに「RatingControl.swift」ができたので選択します。コメントやテンプレートのコードが並んでいるので、すべて消去します。

import: UIKit

class RatingControl: UIStackView {

}

UIStackViewをマニュアル的に実装するために必要なのは初期化ステップです。

init()を使ったイニシャライザが必要になります。

  • frameを組み込んで手動でUIStackViewを初期化
  • トーリーボードを使って初期化

UIStackViewの初期化ステップはUIViewの初期化ステップと同様です。スタンフォード大学のポール先生の説明では、できるだけ初期化を避けてUIViewを実体化するのが望ましいと仰っています。なぜ? 彼はデモの中でinit()を使っていません。

yataiblue.hatenablog.com

ちゃんと理解できていなかったようです。今でも理解できていないのですが、UIViewの初期化の勉強をもう少ししないといけませんね。

コードから初期化する場合はinit(frame: CGRect)を使い、ストーリーボードを使って初期化する場合はinit(coder: NSCoder)なんで、「// MARK: Initializers」のカテゴリーを作ってその下に加えます。

    
    //MARK: Initialization

    override init(frame: CGRect) {
        super.init(frame: frame)
    }
    
    required init(coder: NSCoder) {
        super.init(coder: coder)
    }

次にオブジェクト・ライブラリから「Horizontal Stack View」を選択して、「Stack View」のイメージの下に設置します。

このジェネリックなUIStackViewオブジェクトのコードを自分で作ったカスタムビューのコード、RatingControlに変更する必要があります。ジェネリックなViewを選択したまま、アイデンティティ・インスペクタを開いて、「Class」を「RatingControl」に切り替えます。

f:id:yataiblue:20170327201756j:plain

カスタムUIStackViewのRaitingControlビューの上に5つの星型のボタン(UIButton)をサブビューとして設置していきます。

f:id:yataiblue:20150709182425j:plain

まずこのUIStackViewこのイニシャライザの下に「//MARK: Private Methods」を作ってUIButtonを作成するメソッドを用意します。

private func setupButtons() {
    let button = UIButton()
    button.backgroundColor = UIColor.red
}

UIButtonは、frameデータを引数として与えてボタンの初期化生成します。Stack Viewは自動的にサイズ0のボタンで位置は(0, 0)になります。UIColor.redというクラスメソッドを使って、ボタンの属性を変更します。

サイズをコンストレイントとして加えます

button.translatesAutoresizingMaskIntoConstraints = false
button.heightAnchor.constraint(equalToConstant: 44.0).isActive = true
button.widthAnchor.constraint(equalToConstant: 44.0).isActive = true

このままでは、RatingControlビューに表示されません。

Stack Viewの場合は次のメソッド「addArrangedSubview()」で組み込むことで表示されます。「self.addArrangedSubview()」のことで「self」は推測機能で省略可能です。

Xcode6までならこれで十分だったのですが、「Stack View」でサイズを確保するために「intrinsicContentsSize」メソッドをオーバーライドする必要があります。

addArrangedSubview(button)

ボタンをセットアップするメソッドをまとめると次のようにあんります

private func setupButtons() {

    let button = UIButton()
    button.backgroundColor = UIColor.red
        
  
    button.
        translatesAutoresizingMaskIntoConstraints = 
                                                 false
    button.
        heightAnchor.constraint(equalToConstant: 44.0).
            isActive = true
    button.
        widthAnchor.constraint(equalToConstant: 44.0).
            isActive = true

    addArrangedSubview(button)
}

そしてこのセットアップメソッドをイニシャライザで呼びます。

    //MARK: Initialization

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupButtons()
    }
    
    required init(coder: NSCoder) {
        super.init(coder: coder)
        setupButtons()
    }

ここでラン(Cmd + R)すると赤い四角がRatingContolビューの左端に表示されます。

f:id:yataiblue:20170327204011j:plain

このボタンは表示だけではなくタップすることで反応する機能がいるので、ボタンのアクションメソッドをコードします。

RatingControl.swiftの最後「}」直前に次のリファレンスを入れます。

// MARK: Button Action

func ratingButtonTapped(button: UIButton) {
    print("Button pressed 👍")
}

コンソールに「Button pressed 👍」というメッセージを表示するだけです。

このメソッドをボタンに組み込むために、UIButtonの「addTarget()」メソッドを使います。これもまだ覚えていないメソッドです。

これをsetupButtons()メソッドに組み込みます。

button.addTarget(self, 
       action: #selector(RatingControl.ratingButtonTapped(button:)), 
       for: .touchUpInside)

このボタンのアクションの組み込み方法ですが、ストーリーボードにボタンが無いので@IBActionは使えません。コードでボタンを生成する場合は、このようにアクションメソッドを組み込む必要があります。

ラン(Cmd + R)すると、赤いボタンが左端に設置されます。タップするとコンソールに「Button pressed 👍」というメッセージが流れます。

f:id:yataiblue:20170327204805j:plain

しかし、今のところ赤いボタンが1つだけ表示されてボタンをタップするとメッセージがコンソールに流れるというプリミティブな機能だけなので。ここからボタンを拡張していきます。

ここでプロパティを用意します。

  1. レイティングを表示するためのInt型変数「rating
  2. 星形のボタンを保持するUIButton型のアレー型変数「ratingButtons

レイティングを「0」から「5」の6段階で表現するのですが、Int型の変数ratingを用意します。初期値は「0」です。そしてこの数字に合わせて「星」が並ぶので、星をボタンとして扱って、アレー型ratingButtonsを用意して格納させていきます。

// MARK: Properties
var rating = 0
var ratingButtons = [UIButton]()

星ボタンは5つ並ぶので、イニシャライザの中にボタンを5つ並べるステップを組み込みます。「for-in」ループを使いっていきます。

for _ in 0...4 {
 let button = UIButton(frame: CGRect(x: 0, y: 0, width: 44, height: 44))
 button.backgroundColor = UIColor.redColor()
 button.addTarget(self, action: "ratingButtonTapped:", forControlEvents: .TouchDown)
 ratingButtons += [button]
 addSubview(button)
}

この「for - in 0...4」に使われている「_」は省略形で、無条件で回数を繰り返したいときに使います。「0...4」はclosed rangeで、half-open rangeの「0..<5」も同様に使えます。

なんせ5回繰り返すのですが、0からスタートする理由はあります。5つの赤いボタンを[UIButton]型アレー変数、ratingButtonsに加えていきますが、アレー型は順番が決められていて、最初の項目は「0」からスタートするからです。

これで5つのボタンが生成されて、画面に追加されるのですが、これをランしても赤い四角のボタンが1つしか表示されません。5つのボタンが重なっているからです。

UIViewのサブビューとしてのボタンのレイアウトを調整は、UIViewクラスが持っている「layoutSubviews」メソッドを使うというのは覚えなければならない項目です。

// MARK: Initializationセクションズの下で、layoutSubviewsをオーバーライドします。

override func layoutSubviews() {
 var buttonFrame = CGRect(x: 0, y: 0, width: 44, height: 44)

 for (index, button) in enumerate(ratingButtons) {
   buttonFrame.origin.x = CGFloat(index * (44 + 5))
   button.frame = buttonFrame
 }
}

まず、ボタン1個のサイズのframeを変数buttonFrameに設定します。「for-loop」とタップルで繰り返すことで、button1つずつ、frameのx値を横に49ずつずらし設定してます。indexが「0」のbuttonのframeのx値は「0」で、indexが「1」のbuttonは、frameのx値が「49」になります。こんな調子で横に並べていきます。

注意して欲しいのですが、このbutton.frameのサイズは、最終的にwisdhは「245」でheightは「44」なので、幅で「5」だけ飛び出した形になっています。

ここでラン(Cmd + R)させると、赤いボタンが5つ横に並びました。
f:id:yataiblue:20150718112601j:plain

ここで「ハードコーディング」の説明が入ります。ハードコード値(hardcoded values)とは次のような説明です。

プログラマの専門用語を覚えた。「ハードコーティング」という単語だ。上に書いたコードの中で「var start = 0」とすることで、start変数を使うことで、コードが読みやすくなり汎用化しやすくなる。あえて「0」を使ってコードすることを「ハードコーディング」と呼ぶようです。ちょっとプログラマ的になってきたよ。
Swiftで遊ぼう! - 70 プログラマの考え方 10 アレーの所でソート

プログラムコードの中で使う定数は宣言して使うのがベテランプログラマーです。

「44」はボタンのサイズなので
let buttonSize = Int(frame.size.height)
「5」はボタンとボタンのスペースなので
let spacing = 5
もう1つ「5」を使っているけど、これは星の数は
let stars = 5
ボタンすべての幅は
let width = (buttonSize + spacing) * stars

これらをコード内で使います。

まだまだカスタムViewの実装ができていません。赤いボタンが5つ並んだだけではどうしようもないので、星のイメージを加えていきます。

f:id:yataiblue:20150718113545j:plainf:id:yataiblue:20150718113600j:plain

アプリで使うイメージはすべてpngファイルを使用して、XcodeのImages.xcassetsで管理します。

Images.xcassetsを選択して、下にある「+」ボタンを押して「New Folder」を選択して、ダブルクリックで「Rating Images」に名前を変えてフォルダーを作ります。

次に「+」を押して、「New Image Set」を選ぶと、「Image」という新しいイメージセットができるので、ダブルクリックして、「emptyStar」と名前を変えます。同様に新しいイメージセットを作って、「filledStar」というイメージセットも作ります。

それぞれのイメージセットの「2x」ボックスにアップルのサイトからダウンロードした星のイメージを同じ名前の組み合わせでドラッグ&ドロップします。白い星のemptyStar.pngをemptyStarイメージセットの「2x」ボックス、黒い星はfilledStarイメージセットにドロップします。

次はコードを使ってプログラム内にロードします。

Xcodeで用意したオブジェクトはinit(coder:)イニシャライザで初期化します。

let filledStarImage = UIImage(named: "filledStar")
let emptyStarImage = UIImage(named: "emptyStar")

Images.xcassets内にフォルダー分類していても、オブジェクトとして指定するのにファイルパスは要らないようです。名前だけの指定で、オブジェクトを利用できるんですね。余りの単純さに感動です。

このオブジェクトをbuttonsのbuttonに取りこむメソッドは、setImageを使います。

required init(coder aDecoder: NSCoder) {
 super.init(coder: aDecoder)
        
 let filledStarImage = UIImage(named: "filledStar")
 let emptyStarImage = UIImage(named: "emptyStar")
        
 for _ in 0..<stars {
  let button = UIButton()
            
  button.setImage(emptyStarImage, forState: .Normal)
  button.setImage(filledStarImage, forState: .Selected)
  button.setImage(filledStarImage, forState: [.Highlighted, Selected]) 
            
  button.adjustsImageWhenHighlighted = false
            
  button.addTarget(self, action: "ratingButtonTapped:", forControlEvents: .TouchDown)
  ratingButtons += [button]
  addSubview(button)
 }
}

buttonはstateによって、イメージを切り替える機能があるようです。まず元あったbutton.backgroundColor = UIColor.redColor()を消します。

「button.adjustsImageWhenHighlighted = false」これでボタンがハイライトにならないようにします。

ランをします。

f:id:yataiblue:20151024150550j:plain

5つのボタンにイメージを加えて画面に加えられました。

まだボタンのActionメソッドがコーディングされていないので、コンソールに「ボタンがタップされました」という表示がされるだけです。

ボタンメソッドをコーディングするので下のメソッドのコードを消去します。

func ratingButtonTapped(button: UIButton) {
  print("ボタンがタップされました")
}

まず、どのボタンが押されるかで変数ratingを変化させます。

func ratingButtonTapped(button: UIButton) {
 rating = ratingButtons.indexOf(button)! + 1
}
}

次に重要なのが、この変化を画面に反映させるメソッドを作ります。

func updateButtonSelectionStates() {
 for (index, button) in ratingButtons.enumerate() {
  button.selected = index < rating
// こういう発想のコードは、まだ自分で書けないですね。
// これも慣れて自由に利用できるようにしないといけないでしょう。
// 「index < rating」をBoolean型として利用するんです。
// index値がraiting値より小さい間は、button.selectedが「true」
// ということはイメージが選択された状態ということです。
 }
}

この関数をボタンを押した時に呼び出せばいいんです。

func ratingButtonTapped(button: UIButton) {
 rating = ratingButtons.indexOf(button)! + 1
        
 updateButtonSelectionStates()
        
}

このupdateButtonSelectionStates()メソッドですが、ボタンのレイアウトを決定するlayoutSubviews() の中でもアップデイトさせます。

override func layoutSubviews() {
 let buttonSize = Int(frame.size.height)
 var buttonFrame = CGRect(x: 0, y: 0, width: buttonSize, height: buttonSize)
 for (index, button) in enumerate(ratingButtons) {
   buttonFrame.origin.x = CGFloat(index * (buttonSize + spacing))
   button.frame = buttonFrame
 }
        
 updateButtonSelectionStates()
}

そしてもう一つ。raitingの値が変化した後にボタンのアップデイトをさせる必要があるので、プロパティ・オブザーバーを使用します。

var rating = 0 {
 didSet {
  setNeedsLayout()
 }
}

ここで呼ばれているメソッド、setNeedsLayout()は、layoutSubviews()メソッドの関連メソッドで、覚えていないと使えません。こういうメソッドを覚えるという作業がiOSデベロップメントに必要なんです。

2015年10月23日:ここで再び大きな疑問にぶち当たりました。setNeedsLayout()メソッドは、レイアウトを調整する必要があるよというメソッドです。たぶん、RatingControlビュー全体のレイアウトの描画(コンテントビューも含む)でいいのでしょう。しかし、変化したのはサブビューの星なので、こおこでlayoutSubviews()メソッドを呼ぶだけでもいいような気がします。試しに変更してみたらやっぱり問題無く動きました。これじゃいけませんか?

最終的にコードを整理してレイアウトの微調整をします。

まず、RatingControlクラスのコードをまとめて以下のようになりました。

class RatingControl: UIView {
    
    // MARK: Propeties
    var rating: Int = 0 {
        didSet {
            setNeedsLayout()
        }
    }
    var ratingButtons = [UIButton]()
    let stars = 5
    let spacing = 5
    
    // MARK: Initializers
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        
        let filledStarImage = UIImage(named: "filledStar")
        let emptyStarImage = UIImage(named: "emptyStar")
        
        for _ in 0..<stars {
            let button = UIButton()
            
            button.setImage(emptyStarImage, forState: .Normal)
            button.setImage(filledStarImage, forState: .Selected)
            button.setImage(filledStarImage, forState: [.Highlighted, .Selected])
            button.adjustsImageWhenHighlighted = false
            button.addTarget(self, action: "ratingButtonTapped:", forControlEvents: .TouchDown)
            ratingButtons += [button]
            addSubview(button)
        }
    }
    
    override func layoutSubviews() {
        let buttonSize = Int(frame.size.height)
        var buttonFrame = CGRect(x: 0, y: 0, width: buttonSize, height: buttonSize)
        
        // Offset each button's origin by the length of the button plus spacing.
        for (index, button) in ratingButtons.enumerate() {
            buttonFrame.origin.x = CGFloat(index * (buttonSize + spacing))
            button.frame = buttonFrame
        }
        
        updateButtonSelectionStates()
    }
    
    override func intrinsicContentSize() -> CGSize {
        let buttonSize = Int(frame.size.height)
        let width = (buttonSize + spacing) * stars
        
        return CGSize(width: width, height: buttonSize)
    }
    
    // MARK: Actions
    func ratingButtonTapped(button: UIButton) {
        rating = ratingButtons.indexOf(button)! + 1
        
        updateButtonSelectionStates()
        
    }
    
    func updateButtonSelectionStates() {
        for (index, button) in ratingButtons.enumerate() {
            button.selected = index < rating
        }
    }
}

これでRatingControlクラスが用意できたので、最後にstoryboardにあるジェネリックなUIViewクラスオブジェクトをViewControllerに関連付けます。「Ctrl + ドラッグ」して@IBOutletのプロパティを作ります。

f:id:yataiblue:20151024155315p:plain

Nameを「ratingControl」にして、Tyoeは「RatingControl」を選択すればできあがりです。

そしてレイアウトの最終調整をMain.storyboardでします。

ドキュメント・アウトラインで「Stack View」を選択した状態で「アトリビュート・インスペクタ」を開きます。「Alignment」の項目から「Center」を選ぶと、オブジェクトは中央に位置します。

次にStack Viewに設置していた「Set Default Label Text」を消去して、ViewControllerにコードしている下記の「setDefaultLabelText()」を消去します。

@IBAction func setDefaultLabelText(sender: UIButton) {
 mealNameLabel.text = "Default Text"
}

これでできあがり。

f:id:yataiblue:20151024160457j:plain

次に進む→Swiftで遊ぼう! - 379 - Define Your Data Model - Swiftで遊ぼう! on Hatena

*1:2015年10月18日改訂:環境環境:Xcode7.1Swift2.1