「Start Developing iOS Apps (Swift)」でFoodTracker Appを実際に作ってみる その5[カスタムコントロールを実装する]

Start Developing iOS AppでFoodTrackerを実際に作ってみる、その5

公式にある「Start Developing iOS Apps (Swift)」を使って実際にFoodTracker Appを作ってみる、というシリーズの第五回目です。ここで折り返し。前回はこちら

「Start Developing iOS Apps (Swift)」でFoodTracker Appを実際に作ってみる その4[View Controllersを加工する]
公式にある「Start Developing iOS Apps (Swift)」を使って実際にFoodTracker Appを作ってみる、と...

前回はview controllerを掘り下げて、選んだ写真をUIに表示させる機能を追加しました。今回は、独自のUI(カスタムコントロール)を追加する方法を学びます。具体的には、料理の評価を表示できるUI(rating control)を追加します。

この記事を書いている時点でのXcodeのバージョンは7.3です。

追記: Swift 3がリリースされたので、Swift 3/Xcode 8.0の仕様に合わせました。バージョンによる違い、警告やエラー等に関してはXcode 7.3の記述をそのまま載せる場合がありますが、その際は補足します。また、一部の画像は以前のバージョンのXcodeやシミュレータのモノを流用している場合があります。

目次

カスタムコントロールを実装する | Implement a Custom Control

今回の学習目的(Learning Objectives)は以下のとおりです。

  • 自作のソースコードファイルを作って、それをストーリーボードの要素(オブジェクト)と関連付ける
  • 自作クラスを定義する
  • 自作クラスの初期化子を実装する
  • UIViewをcontainerとして使う
  • viewsをプログラミング的にどうやって表示するかを理解する

Part5が終わった時点で、

Xcode: UIとcodeを整理した後のシミュレータ動作確認
このような見た目になります。評価ボタンを実装したので、6段階(5段階+評価なし)評価を動的に決めることが出来るようになり、アプリっぽさがさらに増した感じがします。

カスタムビューを作る | Create a Custom View

今回作りたいUIは、FoodTracker Appに取り込んだ料理を評価する(独自の)システム(rating control)です。色々な実装方法があると公式レッスンでは書いてますが、今回は自作クラス(UIViewのサブクラス)を作って、それをストーリーボード上で使うという方法です。ここでサブクラス(subclass)と読んでいるのは、UIViewを継承したサブクラスのことです。この時の継承元クラス(今はUIView)のことをスーパークラス(superclass)と呼んだりします。

では、早速独自のクラスを作っていきます。作るクラスはズバリRatingControlです(分かりやすいですね)。

UIViewのサブクラスを作る | To create a subclass of UIView

Xcodeのプロジェクトで独自のクラスを作ります。最初なので詳しく説明します。

新しいファイルを開く

メニューから

File > New > File(またはキーボードショートカット⌘+N

を選択して、新しいファイルを開きます。

iOSの「Source」、テンプレートは「Cocoa Touch Class」

新しいファイルを開き、左側のダイアログから「iOS」という項目の「Source」を選択

Xcode: 新しいCocoa Touch Classファイルを選択
上記画像のような画面になっているはずなので、ここから「Cocoa Touch Class」(左上のファイル)を選択して、「Next」をクリックします。

クラス名は「RatingControl」で「UIView」のサブクラス

Xcode: 独自クラスRatingControlの名前とスーパークラスの決定
次に、クラス名とスーパークラスの設定。

  • クラス名(Class): RatingControl
  • スーパークラス(Subclass of): UIView

クラス名はRatingControlRatingControlクラスのスーパークラスをUIViewにセット。一番下の言語(Language)が「Swift」になっていることも一応確認して、右下の「Next」をクリック。

ソースファイルの保存先

Xcode: 作ったソースファイル(RatingControl.swift)の保存先を決める
デフォルトの保存先は、自分が今作っているプロジェクトフォルダです。上の画像に表示されているように、デフォルトのグループはFoodTracker。これはnavigator areaに表示されている上位ディレクトリの名前で、どこのグループ先にファイルを保存するかを決めているだけだと思います。また、一番下の「Targets」の項目では、「FoodTracker」のみがチェックされていることを確認します(FoodTrackerTestsにチェックを入れない)。

つまり、保存先などの指定はデフォルトのままで良いということです。右下にある「Create」ボタンをクリックしてファイルを作成します。

RatingControl.swift

ファイルを作ると「RatingControl.swift」というファイルが、FoodTracker内に作られるはずです。クラスの中にコメントが書いてあると思いますが、必要ないようなのでコメントを全消去します。

import UIKit

class RatingControl: UIView {

}

そうすると、このように空のRatingControlクラスが出来ます。ここから必要な機能を順番に実装していきます。

Viewをどうやって初期化するのか?

「Viewを作る」というのは結局「viewを初期化する」ことです。Swiftでの初期化は、初期化子(initializers)initという特別なメソッド(methods)を通して行います。

Viewの基本的な初期化子は2つあって、

  • init(frame:)、または
  • init?(coder:)

です。1つめの初期化子init(frame:)は、ソースコードで(またはプログラミング的に)作られたviewに対して呼び出されるもので、2つ目の初期化子init?(coder:)はストーリーボード(interface builder)で使用されるviewに対して呼び出されるもの、という分類のようです。

今回のRatingControlは、ストーリーボード上で使う予定のクラスなので、2番目のinit(coder:)を使って初期化します。まずは、スーパークラスであるUIViewから初期化子をオーバーライド(overriding)して、実際にRatingControlへ実装してみます。

UIViewの初期化子をオーバーライドする | To override the initializer

先ず初期化子をスーパークラスUIViewからオーバーライドして、外枠だけですが作ってみます。初期化子の中身の実装は次のセクションで行います。

// MARK: Initializationコメントの追加

タイトルのままですが、ここに初期化子を追加しますよ、というコメント

// MARK: Initialization

を追加します。また、function menuから探せるようになるので便利です。

initとタイプして該当の初期化子を探し、初期化子の外枠を実装

先程入れたコメントの下で

init

とタイプすると、該当する初期化子などの候補がXcode上で表示されます。

Xcode: initとタイプして初期化子の候補を探す
上の画像のように、初期化子等々が表示されますので、ここから今オーバーライドしたい初期化子を選択して「Return」キーをタイプすると、

init?(coder aDecoder: NSCoder) {
    code
}

という具合に、初期化子の外枠だけが追加されるはずです。

初期化子の後ろに?が付いているのは、failable initializerという特別な初期化子で、インスタンス初期化の際に、そのインスタンスがnilになる可能性がある初期化子のことです

初期化子追加後のエラー

初期化子を追加した直後にエラーが出ます。エラーメッセージは

'required' modifier must be present on all overrides of a required initializer

で、初期化子の直前にrequired指定子をつけなさいと言って怒られています。手で追加する事もできますが、Xcodeでは自動で直してくれる機能も付いていますので、今回はそれを利用します。

エラーを直す「Fix it」を使ってrequiredキーワードを追加

Xcode: initのエラーをfix-it-toで修正する
上の画像で示しましたが、エラーが出ているラインの左側に赤いボタンのようなアイコン(赤丸の中に白丸)があるので、そこをクリックします。そうすると、エラーメッセージの下に

Fix-it Insert “required”

というメッセージが出て来ます。この状態で「Return」キーを押すか、またはこのメッセージをダブルクリックすると、requiredが自動的に挿入されます。Fix-itはエラーが出た時に、どう解決したら良いか?ということをユーザに教えてくれる(補完もする)機能のようです。便利ですね。

requiredキーワードが挿入された後は

required init?(coder aDecoder: NSCoder) {
    code
}

このような表示になっているはずです。init?のパラメータは(coder aDecoder: NSCoder)となっています。このように、パラメータ名が2つ並んでいる時は最初の名前が外部呼び出し用ラベルで、2番目が内部で使うパラメータ名です。

UIViewの初期化(初期化の委譲)

スーパークラスの初期化子を呼び出しておく必要がある(初期化の委譲、initializer delegation)ので、

super.init(coder: aDecoder)

の一行を初期化子に追加します。

これでRatingControlの初期化子は

required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)
}

このようになっているはずです。

カスタムビューの表示 | Display the Custom View

RatingControlを画面に表示させるためには、

  • viewをUIに追加して
  • viewとコード(RatingControl)を繋ぐ

ことが必要です。ほとんどの作業はviewをUIに追加する部分です。

ストーリーボードを開き、Object LibraryでViewを検索

RatingControl.swiftを開いているはずなので、ストーリーボードに切り替えます。Utility areaの下側にあるObject Libraryから「View」を選択。

Xcode: Object LibraryでViewを追加
いつものようにfilter窓から「view」で検索をかけるのですが、「view」と名前の付くオブジェクトが多すぎて絞り込めません。トラックパッドやマウスで画面をスクロールすると、下の方に「View」というオブジェクトが見つかります(上の画像参照)。

Viewオブジェクトをstack view内の、image viewの下に追加

Object LibraryでViewを見つけたら、つかんでドラッグします。

Xcode: Viewオブジェクトをstack viewに落とす
落とす場所はimage viewの下側ですが、stack view内になるように調整(上の画像参照)。画像を見てもらうと分かりますが、オブジェクトを落とそうとしているターゲットのview(ここではStack View)が右下に表示されているので、ここを見ながらstack viewに落とすように、viewオブジェクトの位置を調整します。余り位置を下げ過ぎると、stack viewじゃなくて(その上の階層の)viewに落とすことになります。

間違えてstack viewじゃなくて、viewの方に落としてしまった場合は⌘+Zでやり直せます。このキーボードショートカットはMacでは主に「やり直す(Undo)」というコマンドに割り当てられているものです。

Size inspectorでオブジェクトの大きさを調整

Viewを落とした直後は

Xcode: Viewをstack viewに落とした直後
このようになっています。少しラベルの高さ(縦の長さ)が大きいので、size inspectorを使って大きさを調整しておきます。

Xcode: Viewのsize inspectorを開く
Viewを選択した状態でsize inspector(utility areaのメニュー右から2番目、または左から5番目)を開きます。設定は下記の通り(上の画像も参照)

  • 一番下の「Intrinsic Size」で「Placeholder」を選択。見当たらない時はsize inspectorで下にスクロール
  • 「Width」を240、「Height」を44に設定。「Width」はデフォルトで240になっているかもしれません。

Xcode: Viewのサイズをsize inspectorで調整した後
Viewのサイズを設定すると、このようになっているはずです(上の画像参照)。ラベルの枠が透明で見えにくいですが、高さが縮まっているのが分かると思います。

ViewとRatingControlを繋ぐ

最後に今作ったviewとRatingControlクラスを繋ぎます。Viewを選択した状態でidentity inspectorを開きます。

Xcode: Viewのidentity inspectorでRatingControlと紐付ける
identity inspectorはutility areaのメニューの左から3番目です(上の画像参照)。Identity inspectorを開いたら、一番上にある「Class」ラベルの所で、RatingControlを選択。キーボードから直接打ち込んでも良いですし、右端の青い部分をクリックすると上の画像のようにクラス名一覧が出て来ますので、ここからRatingControlを選択します。

Viewにボタンを追加する | Add Buttons to the View

ここまでで、RatingControlというUIViewサブクラスの枠組みは追加出来ました。次にやることは、

ボタンをviewに追加して、ユーザが評価(5点満点で何点か?)を決められるようにする

ことです。

最初は四角いボタンを追加して、そのボタンに好きな色を付けます。公式では赤色でしたが、色々試してみます。

UIButtonクラスでボタンを追加

UIButtonでボタンを作る

RatingControl.swiftを編集するので、navigator areaからRatingControl.swiftを選択。以下のコードを、init?(coder:)内部に追加します。

let button = UIButton(frame: CGRect(x: 0, y: 0, width: 44, height: 44))
button.backgroundColor = UIColor.blue
addSubview(button)

UIButtonの初期化子は色々あるようですが、今回使うのはCGRectという構造体(structures)を使った初期化です。追加する場所は、super.init(coder:)よりも後です。ボタンの色は青色にしてみましたが、他にも色があるようなので、後で変更して結果を比べてみます。

補足:参照型なので定数でもプロパティを変更可能

ここで、

ボタンインスタンスbuttonは定数なのにプロパティbutton.backgroundColorが変更できるのはなぜ?

という疑問が湧くと思いますが。UIButtonはクラスで、クラスは参照型です。今定数になっているのはインスタンス自身ではなく、その参照ですから、たとえ定数指定(letしようがプロパティを変更することが可能です。

addSubview(_:)メソッド

最後の

addSubview(button)

は、RatingControlのviewにbuttonを追加しています。この指定がないと、buttonがviewに表示されません。

これでRatingControlの初期化子は、

required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)

    let button = UIButton(frame: CGRect(x: 0, y: 0, width: 44, height: 44))
    button.backgroundColor = UIColor.blue
    addSubview(button)
}

このようになっているはずです。

View固有のサイズを決めるintrinsicContentSizeプロパティ

また、stack view内でボタンをどう配置するのかというのを決めるのに、ボタン固有のサイズを指定しないといけないようです。先程viewのIntrinsic SizeをInterface Builderで決めましたが、その大きさと合わせます。

override var intrinsicContentSize: CGSize {
    return CGSize(width: 240, height: 44)
}

使うプロパティはintrinsicContentSizeです。型がCGSizeで、インスタンス生成と同時にreturnしています。サイズは幅が240で高さが44(IBで指定した大きさと同じ)です。

シミュレータで動作確認

シミュレータを起動すると、

Xcode: シミュレータでRatingControlの確認(青色)
確かに青いボタンが表示されました。色を変更する場合は、

button.backgroundColor = UIColor.blue

blueを変更します。試しに茶色brownをセットしてみると、

Xcode: シミュレータでRatingControlの確認(茶色)
こんな感じになります。他にもredなどなど様々な色がデフォルトで用意されています。

今追加したボタンは、ただ色が付いた四角いボタンで、タップしても何も起こりません。次は、アクションを追加して、ユーザがタップした時にちゃんと反応するようにします。

タップした時のアクションを定義

コンソールにメッセージを表示するアクションを追加

まずはタイトル通り、標準出力にメッセージを表示するアクションを追加しますが、いつも通りコメントを追加しておきます。

// MARK: Button Action

実装するメソッドは

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

という独自メソッドで、print(_:)関数を使って文字列を表示しています。文字列は、Xcodeのdebug area(editor areaの下に表示される領域)に表示されるので、アプリの動作確認には便利です。

print()内のメッセージは分かりやすいように日本語にしてあります。これは動作確認のためだけのメソッドなので、最終的にはこのアクションを本来実装したいアクションに置き換えます。

ボタンとメソッドratingButtonTapped(_:)を繋ぐ

初期化子内部のaddSubView(_:)の上に、以下のコードを挿入します。

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

このメソッドによって、

ボタンをタップすると、RatingControlのメソッドratingButtonTapped(_:)を実行する

というアクションを実装出来ます。

Part3で出て来た「ターゲットアクションパターン」では、ストーリーボード上のオブジェクトとソースコードを繋ぎました。ここではソースコードのみで、ボタンオブジェクトとアクション(RatingControlのメソッド)を繋いでいます。

addTarget(_:action:for:)メソッドの最初のパラメータがターゲットで、今はselfRatingControl)になっています。実行したいアクションが2番目のパラメータです(後述)。

3番目のパラメータforがイベント(アクションを引き起こすイベント)です。セットされているイベントは.touchDownで、これは「タップされた瞬間」というイベントのようです。

.touchDownUIControlEventという構造体の型プロパティです

セレクター#selector | Object-Cで使われるメソッドを参照する表現

セレクター(Selector)というのは、メソッド名に対応する識別子で、元々Object-Cでメソッド名を参照するために使われていた仕組みです。これをSwiftから利用するための機能が#selector表現です。Swiftでは、このObject-CセレクターはSelectorという構造体に格納されていて、#selector表現の返り値もSelector構造体になっています。

#selector表現を使う理由は、ここで出てくるaddTarget(_:action:for:)メソッド等、未だセレクターを引数として受け取っているからです。これは仕様なので、こういうものだという理解で良いと思います。

また、ここではInterface Builderを使ってUIとソースコードを繋げているわけではないので、メソッドの先頭にIBAction attribute(@IBAction)を付ける必要もありません。

ここまでの初期化子実装

ここまで初期化子に実装した機能全体を見てみると、

required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)

    let button = UIButton(frame: CGRect(x: 0, y: 0, width: 44, height: 44))
    button.backgroundColor = UIColor.blue
    button.addTarget(self,
        action: #selector(RatingControl.ratingButtonTapped(button:)),
        for: .touchDown)
    addSubview(button)
}

このような感じです。段々複雑になってきました。

シミュレータで動作確認

再びシミュレータで動作確認です。

Xcode: initからの標準出力を確認
シミュレータを起動してボタンをクリックすると、上の画像のように、editor areaの下にあるdebug areaに

ボタンがタップされた!

というメッセージが表示されるはずです。Debug areaが表示されていない場合は、ツールバー(tool bar)の一番右側にあるボタンの真ん中をアクティブにして表示します。ボタンを押す度にメッセージが表示されるはずなので、例えば三回ボタンをクリックすると、同じメッセージが3つ表示されます。

「料理の評価」を表現するためのプロパティ

次は評価システムに必要なプロパティを追加していきます。今回作りたい評価機能は、

料理を6段階で評価(5段階の評価+評価なし)

出来るようなシステムです。

これを実現するために必要な2つのプロパティ

  • 実際の評価値、1,2,3,4,5という整数値
  • 評価値をUIとして表示させるボタン、UIButtonオブジェクトの配列

を作ります。

クラス定義の下に

// MARK: Properties

var rating = 0
var ratingButtons = [UIButton]()

を追加します。いつものように// MARK: Propertiesコメントを追加して、評価値を保存するプロパティrating、評価用ボタンを保存する配列ratingButtonsを作りました。

ボタンを5個作る

for-inループを追加する

(評価有りの場合の)5段階評価を表すのに、ボタンは5つ必要です。先程初期化子に実装したコードでは、ボタンを1個表示させることが出来ます。今必要なのは同じボタンなので、ループを回して5個ボタンを作れば良さそうです。また、個数は5個と決まっているので、ループとしてはfor-inループが最適です。

for _ in 0..<5 {
    let button = UIButton(frame: CGRect(x: 0, y: 0, width: 44, height: 44))
    button.backgroundColor = UIColor.brown
    button.addTarget(self, action: #selector(RatingControl.ratingButtonTapped(button:)), for: .touchDown)
    addSubview(button)
}

実際にfor-inループを実装したのが上のコードです。範囲演算子Half-open range operator(..<を使っているので、上限5は含んでいません。したがって、ループで実行されるのは0,1,2,3,4の5つです。また、一時変数を指定する部分にアンダースコア_をセットしているのは、ループ内で一時変数が必要ないからです。

上記のケースのように、ループを後からセットした場合は、ソースコードが正しくインデントされていないはずです。そのような場合は、インデントしたいコード部分を選択状態にして、control+Iを押すとXcodeがインデントしてくれます。

配列要素を追加する

addSubview(button)の直前に、

ratingButtons += [button]

を追加します。配列要素を追加する場合、複合代入演算子+=と組み合わせて、このような書き方も出来ます。

ここまででRatingControlの初期化子は、

required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)

    for _ in 0..<5 {
        let button = UIButton(frame: CGRect(x: 0, y: 0, width: 44, height: 44))
        button.backgroundColor = UIColor.blue
        button.addTarget(self, action: #selector(RatingControl.ratingButtonTapped(button:)), for: .touchDown)
        ratingButtons += [button]
        addSubview(button)
    }
}

このようになっているはずです。

シミュレータで動作確認

シミュレータを起動して見た目を確認してみると、

Xcode: シミュレータでボタンが5つ表示されているか確認
ボタンが1つしか表示されていません。ループを回すまでは良かったのですが、ボタンの配置を考慮していませんので、全てのボタンが重なって表示されています。したがって、次にやるべきことは、5つのボタンが並んで表示されるように位置を調整することです。

5個のボタンをどう配置するか

layoutSubviewsメソッドでサブクラスのsubviewsの配置を決める

今回のように、

5個のボタンを並べて表示する

といった配置を実装するためのメソッドがlayoutSubviewsです。もう少し具体的には、UIViewを継承したサブクラス(今回のケースだとRatingControl)が持つsubviewsの細かい配置を調整したい場合に使えるメソッドがlayoutSubviewsになります。

layoutSubviewsはデフォルトでは何もしないメソッドになっているようなので、サブクラスであるRatingControlでオーバーライドして中身を実装する必要があります。

layoutSubviewsメソッドをオーバーライドして実装

初期化子init?(coder:)の下にメソッド

override func layoutSubviews() {
}

を追加します。Xcodeの補完機能をを使うと簡単に挿入できるはずです。

公式の通りに中身を実装すると、メソッド全体は

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

    // Offset each button's origin by the length of the button plus spacing
    for (index, button) in ratingButtons.enumerated() {
        buttonFrame.origin.x = CGFloat(index * (44+5))
        button.frame = buttonFrame
    }
}

このようになるはずです。少し回りくどいやり方をしていますが、1つずつ順番に見ていきます。

ループを回してボタンを取り出す

まず、元々配置したボタンと同じ位置に(新しい)フレームをセットします。

var buttonFrame = CGRect(x: 0, y: 0, width: 44, height: 44)

次に、ratingButtonsから配列要素を1つずつ順番に取り出します。

for (index, button) in ratingButtons.enumerated() {
}

要素として取り出しているのはtuple(index, button)で、indexは配列要素のインデックス、buttonは配列要素本体です。配列からこれらを取り出すためのメソッドがenumerated()です。

フレームの横位置をずらしてセットする

後は、index0,1,2,...と増えていきますので、indexにフレームの幅44とオフセット5の和を掛け合わせることで、ボタンの横の配置を決めることが出来ます。それをフレームのx方向の位置(origin.x)に代入し、ずらしたフレーム全体を配列要素のフレームに代入しています。

for (index, button) in ratingButtons.enumerated() {
    // indexは0,1,2,...と増加。index * (44+5)でフレームの横位置をずらしていく
    // ずらした位置をフレームのx方向の位置としてセット
    buttonFrame.origin.x = CGFloat(index * (44+5))
    // ずらしたフレームbuttonFrameを、配列要素のフレームに代入
    button.frame = buttonFrame
}

x方向の位置をセットする際、CGFloatに型変換しています。これはorigin.xの型がCGFloatだからです。この明示的型変換を外すとコンパイルエラーになります。

シミュレータで動作確認

ボタンが並んで表示されているか、シミュレータで確認してみます。

Xcode: シミュレータで5つのボタンが並んで表示されていることを確認
うまく配置出来ていれば、このようにボタンが5つ並んで表示されているはずです。また、それぞれのボタンをクリックすると、メッセージ「ボタンがタップされた!」がdebug areaに表示されます。

ボタンの配置に関して、ソースコードをもっとスッキリさせる方法

ボタン配置関連のソースコードがかなり回りくどかったので、2通りの方法でソースコードをスッキリさせる方法を考えてみました。

フレームの一時変数buttonFrameを介さずに配置する方法

上記のコードがややこしいと感じる理由は、フレームの配置自体は新しい一時変数buttonFrameに対して行い、そのずらしたbuttonFrameをボタンのフレーム(button.frame)に代入している点です。

一時変数buttonFrameを介さず、直接ボタンが持つフレームの位置を指定することも可能で、

override func layoutSubviews() {
    for (index, button) in ratingButtons.enumerated() {
        button.frame.origin.x = CGFloat(index * (44+5))
    }
}

のようにスッキリと書くことが出来ます。公式の書き方が回りくどいのは、敢えてそうしているのかもしれません。

大元のフレームの初期位置をずらしてセット

初期化子でUIButtonを作る時に、フレームframeをパラメータとして作っていました。したがって、そもそもここでボタンの初期位置が重ならないようにセットすることも可能です。該当する部分だけ書き換えて抜き出すと、

for index in 0..<5 {
    let button = UIButton(frame: CGRect(x: index * (44+5), y: 0, width: 44, height: 44))

こんな感じになります。こうすると、layoutSubviewsを実装せずに、最初からボタンの配置をずらすことが可能です。シミュレータで確認しましたが、これでもちゃんとボタンが配置出来ます。

プロパティを追加して再利用する

ここまでで、ボタンをずらして配置することで、5つのボタンを表示することが出来ました。配置を決める時に、ボタンの間にあるスペースとして5という整数を直接使っていました。また、評価のためのボタンの数は5個ですが、これも5という数字を直接for-inループに放り込んでいます。

このように数字(または文字列等)自体を直接コードに埋め込むのは、

  • 後で編集する時に大変
  • 同じ数字を繰り返し使いたい時に手間がかかる
  • 同じ数字でも意味が違う(ボタンのスペース、評価の数)

などなど、様々な理由から、出来る限り避けるべきです。

ここでは、RatingControlのソースコードに直接埋め込んできた数字をプロパティに変更して、再利用出来るようにします。まずはボタンのスペースを決めるプロパティと、評価の数(ボタンの数)を決めるプロパティを新たに作ります。さらに、次のセクションではボタンの大きさを決めるプロパティを作ります。

ボタンのスペース用と評価数のプロパティを追加する | Add Properties for Spacing and Number of Stars

プロパティセクションにspacing定数を追加

// MARK: Propertiesのセクションで、これまでに導入したプロパティの下に

let spacing = 5

を追加します。名前の通り、2つのボタンの間のスペース(spacing)を決めるプロパティです。

layoutSubviewsメソッドでspacingを使う

layoutSubviewsで、

buttonFrame.origin.x = CGFloat(index * (44+spacing))

のように、これまで直接数字の5を使ってスペースをセットしていた部分を、spacingプロパティで置き換えます。

評価数を決めるプロパティstarCount

同様に定数starCountを導入します。

let starCount = 5

これを、初期化子内部でfor-inループの範囲演算子の上限にセットします。

for _ in 0..<starCount {

変更したらFoodTrackerをビルドして、これまでと同様に動くことを確認します。

ボタンの大きさを決める定数を宣言 | Declare a Constant for the Button Size

ボタンの大きさを決めるのにも44という整数を直接書いていますので、これもプロパティを使って置き換えます。RatingControlのデフォルトフレームサイズは、実はsize inspectorで決めていました。ストーリーボード上でRatingControlのviewをセットした時、size inspectorを使って、Intrinsic SizeをPlaceholderにセットし、高さを44、幅を240と決めましたが、それがフレームの大きさを決めています。

UIViewはプロパティとしてフレームを参照する変数frameを持っていますので、UIViewのサブクラスであるRatingControlでも当然frame変数を使うことが出来ます。

フレームの高さを取り出して定数としてセット

layoutSubviews()で、フレームの高さを取り出し、それを定数としてセットします。

// Set the button's width and height to a square the size of frame's height
let buttonSize = Int(frame.size.height)

後で整数として使うので、Intに型変換しています(元の型はCGFloat)。

プロパティbuttonSizeを使って44を置換

これまで44という整数を使って書いていた部分を全てbuttonSize定数で置き換えます。

// Set the button's width and height to a square the size of frame's height
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.enumerated() {
    buttonFrame.origin.x = CGFloat(index * (buttonSize+spacing))
    button.frame = buttonFrame
}

そうすると、上記のようにソースコード上に埋め込まれた数字が(0以外は)全て無くなりました。

intrinsicContentSize()メソッドも編集

intrinsicContentSize()内でも幅と高さを直接数字で指定しています。これだとまずいので、フレームの高さを使ってサイズを計算します。

override var intrinsicContentSize: CGSize {
    let buttonSize = Int(frame.size.height)
    let width = (buttonSize * starCount) + (spacing * (starCount - 1))

    return CGSize(width: width, height: buttonSize)
}

実際のコードは上記のようになりますが、幅の指定が少し複雑です。

[図解]フレームの長さからRatingControlの大きさを計算する
幅の計算に関しては、図示すると分かりやすいと思います(上の図参照)。RatingControlのボタン全体の長さを決めているのは、ボタンの幅とスペースです。ボタンは全部で5つあるので、ボタンの間にあるスペースの数は全部で4つ。これらを足し合わせると全体の幅になるという計算です。

UIButtonの初期化

最後に、初期化子init?(coder:)内部でのUIButtonの初期化を

let button = UIButton()

に変更します。

UIButtonを初期化する時に、デフォルトの幅や高さを指定していました。今layoutSubviews()で、ボタンの幅や高さはフレームの大きさから自動的に計算して決めているので、最早UIButtonの大きさを手で設定する必要はありません。

変更したソースコードまとめ

プロパティ等を追加したことによるソースコード変更部分をまとめると、

required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)

    for _ in 0..<starCount {
        let button = UIButton()
        button.backgroundColor = UIColor.blue
        button.addTarget(self, action: #selector(RatingControl.ratingButtonTapped(_:)), for: .touchDown)
        ratingButtons += [button]
        addSubview(button)
    }
}

override func layoutSubviews() {
    // Set the button's width and height to a square the size of frame's height
    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.enumerated() {
        buttonFrame.origin.x = CGFloat(index * (buttonSize+spacing))
        button.frame = buttonFrame
    }
}

override var intrinsicContentSize: CGSize {
    let buttonSize = Int(frame.size.height)
    let width = (buttonSize * starCount) + (spacing * (starCount - 1))

    return CGSize(width: width, height: buttonSize)
}

このようになっているはずです。

また、シミュレータを起動して、

  • ボタンが5個並んで表示されていること
  • ボタンをクリックすると「ボタンがタップされた!」というメッセージが表示されること

を確認します。

星形の画像をボタンとして追加する | Add Star Images to the Buttons

これまでは四角形を評価用のボタンとして表示していましたが、実際のFoodTrackerアプリで使用したいのは、星形のボタンです。評価有りの場合には黒塗りの星で、評価しない場合には白抜きにしたいので、星形の画像は2種類必要になります。

FoodTracker評価用に使う星形ボタン(白抜き)FoodTracker評価用に使う星形ボタン(黒塗り)
これらの画像は、公式で用意されているサンプルコードImages/フォルダに入っています。

星形画像をプロジェクトに追加する

Part4でプロジェクトに画像を追加しましたが、その時と手順は全く一緒です。

Asset.xcassetsを開き、New Folderを追加

Asset catalogというのが、Xcodeで画像を保存するための場所ですので、project navigatorでAsset.xcassetsをクリックして開きます。

Xcode: Asset catalogにNew folderを追加
Project navigatorの右側に画像のグループ一覧表示が出て来ますが、その一番下にあるプラスボタン(+)を押して、ポップアップメニューを開き、「New Folder」をクリック。

新しいフォルダの名前を「Rating Images」に変更

タイトルのままですが、新しく作ったフォルダ「New Folder」の名前を「Rating Images」に変更します。フォルダをダブルクリックすると名前を変更出来るようになります。

フォルダを選択した状態で、もう一度クリックしても(またはリターンキーを押しても)名前が変更出来る状態になります。これはフォルダだけじゃなくて、後述するimage setでも同様です。

星形の画像(白抜き)をImage Setとして追加する

新しく作ったフォルダ「Rating Images」が選択された状態で、

(下にある)+ボタンをクリック > ポップアップメニューから「New Image Set」を選択

そうすると、「image」という名前のimage setが「Rating Images」フォルダ直下に出来るはずです。

Xcode: フォルダの下にimage setを追加。名前をemptyStarに変更
先程と同様に、新しいimage setの名前「image」を「emptyStar」に変更します(image setをダブルクリックして変更)。変更した直後の状態が、上の画像のようになっているはずです。

今回はサンプルコードの星形画像を流用するので、ダウンロードした画像を今作ったimage set「emptyStar」の「2×」の部分にドラッグ&ドロップして追加します。

星形の画像(黒塗り)をImage Setとして追加する

手順は全く同じですが、追加する画像が違うだけです。

  • 「Rating Images」フォルダを選択した状態で、+ボタンを押し、「New Image Set」をクリック
  • 新しいimage setの名前「image」を「filledStar」に変更する
  • 黒塗りの星形画像を「2×」の部分に、ドラッグ&ドロップする

最終的にasset catalogは

Xcode: 星形画像のためのasset catalogを追加した後
このようになっているはずです。

星形画像をボタンとしてセットする

次は、先程追加した星形の画像をボタンとしてセットします。Project navigatorからRatingControl.swiftファイルを選択して、ソースコードを編集していきます。

星形画像をUIImageで追加

初期化子init?(coder:)の中に、

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

を追加します。場所はfor-inループの手前です。UIImageで指定する名前(named)は、先程asset catalogに入れた画像の名前と一致するようにします。

ボタン用画像として星形の画像をセットする

次はfor-inループ内部で、ボタンを定義した部分

let button = UIButton()

このコードの直後に、

button.setImage(emptyStarImage, for: .normal)
button.setImage(filledStarImage, for: .selected)
button.setImage(filledStarImage, for: [.highlighted, .selected])

を追加します。UIButtonsetImage(_:for:)メソッドは、ボタンがどういう状態の場合に、どの画像を表示するか?、ということを決めているメソッドになります。例えば、一番最初の

button.setImage(emptyStarImage, for: .normal)

は、

ボタンが選択されていない状態(.normal)では、白抜きの星形画像emptyStarImageを表示する

という指定になります。一番最後の[.highlighted, .selected]、はどちらの状態も満たす場合(ANDの条件)のようで、ユーザがボタンをタップしている瞬間がそれに該当するようです。

後でタップしたら黒塗りの星を表示する機能を実装しますが、実際にシミュレータでテストすると、最後の条件が無い場合タップしてから画像が変わるまでに少し時間差が出ます。

ボタンの色指定に関する設定

元々四角ボタンの色を決めていた

button.backgroundColor = UIColor.blue

を消して、新たに

button.adjustsImageWhenHighlighted = false

を追加します。このプロパティはボタンの状態が遷移する際に、画像をさらに強調するようなオプションのようです。今は必要ないのでfalseにしておきます(デフォルトではtrueのようです)。

ここまでで初期化子は、

required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)

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

    for _ in 0..<starCount {
        let button = UIButton()

        button.setImage(emptyStarImage, for: .normal)
        button.setImage(filledStarImage, for: .selected)
        button.setImage(filledStarImage, for: [.highlighted, .selected])

        button.adjustsImageWhenHighlighted = false

        button.addTarget(self, action: #selector(RatingControl.ratingButtonTapped(_:)), for: .touchDown)
        ratingButtons += [button]
        addSubview(button)
    }
}

このようになっているはずです。

シミュレータで動作確認

シミュレータを起動して確認してみると、

Xcode: シミュレータで動作確認、白抜きの星形ボタンが表示される
ボタンが四角から白抜きの星形になっていることが分かります。この時点では、星形ボタンをクリックするとratingButtonTapped(_:)が実行されるようなコードになっているので、黒塗りの星が表示されることはありません。次はボタンにアクションを追加して、この点を改良していきます。

ボタンにアクションを実装する | Implement the Button Action

ここまではボタンを押すと、メソッドratingButtonTapped(_:)を実行し、debug areaにメッセージを表示するという仕様でした。ここではいよいよ実際の仕様に変更して、

星形ボタンを押すことで、ユーザが(料理の)評価を決める

という形にします。

タップしたボタンから評価値を決める

ratingButtonTapped(_:)の中身を、

rating = ratingButtons.index(of: button)! + 1

で置き換えます。ratingは整数のプロパティで、評価を数値で保存するためのものです。

配列ratingButtonsからメソッドindex(of:)を呼び出していますが、これはパラメータとして取った配列要素(今はbutton)が、配列の何番目の要素に入っているか?というインデックスを返すメソッドです。パラメータとして入れた要素が配列に含まれているかどうか分からないので、このメソッドの戻り値はオプショナル型(optionals)になっています。今パラメータとして取るbuttonは、必ず配列ratingButtonsの要素になっているので、ここではforce unwrap operatorを使って強制的にオプショナルを外しています。

また、評価値(rating)は1から始めたいので、最後に1を加えています(配列のインデックスが0から始まるため)。

評価値から「選択された」状態のボタンを決める

タップしたボタンによって評価値(rating)を決めるコードを実装したので、次は

決まった評価値から、どのボタンが選択された状態なのかを決める

メソッドを実装します。「選択された状態」と言っているのは、黒塗りの星を表示するボタンのことを指します。

ですから、言い換えると

決まった評価値から、どのボタンを黒塗りの星として表示するのかを決める

という機能を実装するということになります。

タップしたボタンを含めて左側にあるボタンが選択された状態

もう少し具体的に書くと、例えば、2番目の星がタップされた場合、評価値は2なので、左から1番目と2番目の星が選択された状態(黒塗りの星)になる、という感じのメソッドを実装します。評価値2の場合は、2番目の星だけじゃなくて、それよりも左側(配列で言う所のインデックスが小さい要素)にある要素も選択された状態になる、というのが肝です。

ボタンが選択されたかどうか決めるメソッド

先にメソッド本体を書きますが、

func updateButtonSelectedStates() {
    for (index, button) in ratingButtons.enumerated() {
        // If the index of a button is less than the rating, that button should be selected
        button.isSelected = index < rating
    }
}

このようなメソッドをRatingControl.swiftに実装します。場所はどこでも良いと思いますが、公式では一番下に置いているようです。

まずはボタン配列ratingButtonsの要素をtuple形式で取り出します。

for (index, button) in ratingButtons.enumerated() {
}

ボタンが選択された状態かどうかは、UIButtonが持つプロパティisSelectedで決まっていて、これはBool型です。isSelectedtrueなら選択された状態、ということになります。

先程ratingButtonTapped(_:)で、タップされたボタンから評価値(rating)を計算しました。これは結局

(タップされた)配列要素のインデックス+1

で決まっているので、

index < rating

の配列要素が選択されている状態である、と言い換えることが出来ます。isSelectedBool型を取るプロパティなので、最終的には上記の条件判定をそのままisSelectedに代入して

button.isSelected = index < rating

という形になります。

具体的に考えると簡単に分かります。例えば先程取り上げたように、2番目の星がタップされた場合、rating = 2になりますから、index < 2を満たすのは0, 1です。つまり、配列要素で最初の2つ(ボタンで言うと左から1番目と2番目)が選択された状態(button.isSelected = true)、残りは選択されていない状態(button.isSelected = false)になります。

updateButtonSelectedStatesメソッドの呼び出し

ratingButtonTapped(_:)で、updateButtonSelectedStates()メソッドを呼び出します。

func ratingButtonTapped(button: UIButton) {
    rating = ratingButtons.index(of: button)! + 1

    updateButtonSelectedStates()
}

これで白抜きの星がタップされると、それに応じて黒塗りの星が表示されるようになります。

またlayoutSubviews()にも

override func layoutSubviews() {
    // Set the button's width and height to a square the size of frame's height
    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
    }

    updateButtonSelectedStates()
}

一番下にupdateButtonSelectedStates()メソッドの呼び出しを入れておきます。

It’s important to update the button selection states when the view loads, not just when the rating changes.

評価値(rating)が変わった時だけじゃなく、viewがロードされた場合にもボタン選択状態を更新するのが重要であると書いてありますが、今いちこの効用が分かりません。

プロパティ監視でsetNeedsLayoutメソッド呼び出し

プロパティratingに、プロパティ監視(property observer)didSetを追加して、

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

のようにメソッドsetNeedsLayout()を呼び出します。didSetはプロパティが変更された直後に実行されるので、setNeedsLayout()ratingが変わった直後に呼び出されることになります。ちなみに、setNeedsLayout()UIViewのメソッドです。

layoutSubviews()メソッドの説明欄に書いてありますが、

You should not call this method directly. If you want to force a layout update, call the setNeedsLayout method instead to do so prior to the next drawing update. If you want to update the layout of your views immediately, call the layoutIfNeeded method.

viewのレイアウトを更新したい場合は、layoutSubviews()を呼び出さず、setNeedsLayout()またはlayoutIfNeeded()メソッドのどちらかを使うことを推奨しています。setNeedsLayout()メソッドは、即座にviewのレイアウトのアップデートをしなくても良いけど、次のアップデート前には必ず呼ぶという場合に使うメソッドのようです。即座にviewを更新したい場合はlayoutIfNeeded()メソッドだそうです。

シミュレータで動作確認

実際にシミュレータで動作確認してみると、
Xcode: シミュレータで、星形画像をタップすると黒塗りに変わることを確認
確かにタップ(この場合クリックですが)した星と、それよりも左側にある星が全て黒塗りの画像に変わります。

Rating controlとview controllerを繋ぐ | Connect the Rating Control to the View Controller

View controllerからRatingControlクラスを参照できるように、view controllerでアウトレットを作っておきます。手順はこれまでにアウトレットを作った場合と全く同じですので、箇条書きにします。

  • ストーリーボードを開く
  • Assistant editorを開く
  • 右側にViewController.swiftが表示されていなければ、上のメニューからAutomatic > ViewController.swiftを選択
  • スペースが必要な場合、project navigatorやutility areaを隠す
  • Rating controlをcontrol+クリックして、ViewController.swiftへドラッグ
  • photoImageViewの下へドロップ
  • ダイアログが出てくるので、名前(Name)にratingControlをセット、右下の「Connect」をクリック

Xcode: RatingControlとViewControllerを繋ぐ
ダイアログが出て名前(ratingControl)を入れた状態が上の画像のようになります。

ViewController.swiftには

@IBOutlet weak var ratingControl: RatingControl!

というアウトレットが出来ているはずです。これでview controllerからrating controlを参照出来るようになりました。

プロジェクトの整理 | Clean Up the Project

ここで一旦これまで作ったプロジェクトの整理をします。整理と言っているのは、

  • 不要になったUI(オブジェクト)の消去
  • UIの配置変更(中央寄せ)
  • 関連するソースコードの整理

です。最初の2つはストーリーボード上での操作、3つ目は書いたとおりで、整理したUIに関連するソースコードを編集します。

UIの整理 | To clean up the UI

ラベルを消去

先程ストーリーボードとassistant editorを開いたはずなので、assistant editorを閉じます。また、project navigatorやutility areaを隠した場合は表示させておきます。

「デフォルト料理名をセット」のボタンは必要ないので、ここで消去します。UIを消すには、それを選択した状態でdeleteボタンを押すだけです。

Xcode: ボタンを消した後のscene、UIの再配置
ボタンを消した状態が上の画像のようになるはずです。UIを消すと空白ができますが、Xcodeは自動的にその空白を埋めるように別のUIを再配置します。

Stack viewを中央寄せ

次にstack viewを中央寄せします。

Xcode: Stack viewを中央寄せ
Outline viewを開き、Stack Viewを選択。Attribute inspector(左から4番目のメニュー)を開き、「Alignment」を「Center」に変更します(上の画像参照)。上の画像を見ると分かりますが、stack viewを中央寄せしたので、その中に入っているUIも全部中央寄せ表示になっています。

ソースコードの整理 | To clean up the code

ボタンを消したので、それに付随したアクションも消しておきます。ViewController.swiftを開き、

@IBAction func setDefaultLabelText(_ sender: UIButton) {
    mealNameLabel.text = "デフォルトテキスト"
}

を消します。ラベル名に関しては、次回以降で変更を加えるようなので、今は放っておきます。

シミュレータで動作確認

いつものようにシミュレータで動作確認します。

Xcode: UIとcodeを整理した後のシミュレータ動作確認
実装が上手く行っていれば、ボタンが無くなり、全てのUIが中央寄せになっているはずです。またRatingControlのボタンもちゃんと動くと思います。

まとめ

今回は独自クラス(RatingControl)を作って、それを評価システムのUIとして実装する方法を学びました。

次回はデータモデル(data model)を導入して、料理名等の情報を保管し、その情報をsceneに表示させます。

「Start Developing iOS Apps (Swift)」でFoodTracker Appを実際に作ってみる その6[データモデルを定義する]
公式にある「Start Developing iOS Apps (Swift)」を使って実際にFoodTracker Appを作ってみる、と...
スポンサーリンク
広告1
広告1

シェアする

フォローする