Swiftで遊ぼう! on Hatena

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

Swiftで遊ぼう! - 252 - Happiness - カスタムViewチュートリアル

2016年11月12日:改訂数が増えたので整理*1

やっとLecture 5 のデモ*2に取りかかります。

プロジェクトの目的

カスタムビューを用意してコードを使ってハッピーフェイスの描画とピンチやスワイプを使って表情を変化させるためにGoFデザインパターンで重要なMVCの概念とプロトコール&デリゲーションの概念を学びます。

新規プロジェクト作成

新しいプロジェクト立ち上げます。当然のように「Single View Application」を選択します。

f:id:yataiblue:20160923164503j:plain

名前を「Happiness」にします。

f:id:yataiblue:20160923173900j:plain

適当な場所にセーブするとお馴染みのプロジェクトウインドウが開きます。ナビゲータ・エリアにあるテンプレートのUIViewControllerを消去します。

次にメニューから「File > New > File...」を選択して、「Cocoa Touch Class」ファイルを選びます。名前を「HappinessViewController」にして「Subclass...」からUIViewControllerを選択します。

UIViewControllerインスタンスを作成すると、自動的にスーパービューとしてViewが最上位に設定されていますが、そのViewはそのまま手をつけないで置いておきます。

Main.storyboardを選択して、オブジェクト・ライブラリから新たにジェネリックなViewをドラッグしてstoryboard上のViewの上に設置してブルーラインが表示される画面一杯に広げてコンストレイントを設定するんですが、画面一杯に広げると下にあるスーパービューと完全に重なってストーリーボード上で選択するのが難しくなります。

知っておくべきXcodeのショートカットとして、「Ctrl + shift + クリック」があります。オブジェクトのリストがメニュー表示されるので重なって見えないオブジェクトが選択しやすくなります。

f:id:yataiblue:20160924104919j:plain

2つ並んでいる下のView{メニューの下が階層下位に位置します}を選択します。コンストレイントを設定する時に「Ctrl + ドラッグ」して他のオブジェクトとの関係性を作る事ができるんですが、完全に重なっているとこの手が使えません。そういう時は、「Resolve Auto Layout Issuesボタン」を使います。

f:id:yataiblue:20160924114327j:plain

「Reset to Suggested Contraints」を選択するとブルーラインがコンストレイントとして設定されます。

同様にもう一つCocoa Touch Classファイルを作成して名前を「FaceView」にします。「Subclass...」を「UIView」にします。

f:id:yataiblue:20160923174938j:plain

次は、storyboardにあるジェネリックなViewControllerと関連付ける作業が残っています。ナビゲータ・エリアにあるMain.storyboardを選択してドキュメント・アウトラインに並んでいるView Controllerを選択します。アトリビュート・インスペクタの「Class」から作成した「HappinessViewController」を選択するとドキュメント・アウトラインのView Controllerが「Happiness View Controller」に変化します。その下にあるViewにもう1つ階層があり同様にViewが存在します(オブジェクト・ライブラリからドラッグしたVIew)。これを選択してアトリビュート・インスペクタの「Class」からFaceViewを選択します。するとドキュメント・アウトラインは次のようになります。

f:id:yataiblue:20160924130038j:plain

次はナビゲータ・エリアからFaceViewを選択して以下のコードを加えます。取りあえず画面に「まる」を描画するだけです。

import UIKit

class FaceView: UIView {

    var lineWidth: CGFloat = 5 {
        didSet { setNeedsDisplay() } }
    var color: UIColor = UIColor.blue {
        didSet { setNeedsDisplay() } }
    var scale: CGFloat = 0.9 {
        didSet { setNeedsDisplay() } }
        
    var faceCenter: CGPoint {        
        return convert(center, from: superview)
    }
        
    var faceRadius: CGFloat {
        return min(bounds.size.width, 
               bounds.size.height) / 2 * scale
    }
        
    override func draw(_ rect: CGRect) {
        let facePath = 
           UIBezierPath(arcCenter: faceCenter,
                           radius: faceRadius, 
                       startAngle: 0,
                         endAngle: CGFloat(2*M_PI), 
                        clockwise: true)
        facePath.lineWidth = lineWidth
        color.set()
        facePath.stroke()
    }
}

これをランさせると次のようになります。

f:id:yataiblue:20160924130153j:plain

一点だけ分からないところがありましたが、コメントでmassyanさんが教えてくださいました。感謝m(_ _)m

定数「M_PI」は円周率、「3.14」を意味します。UIBezierPathクラスの初期化引数の「startAngle」は角度の始まり「0度」をラジアンで与えると「0」、そして終了の角度「endAngle」は「360度」で、ラジアンで与えると「2π」これをSwiftの型で与えると、CGFloat型の「2*M_PI」になります。
f:id:yataiblue:20160105212005j:plain
ラジアン - Wikipedia

Xcode 8という作業空間でハッピーフェイスを描画するプロジェクトHappinessに取り組んでいます。iOSシステムにできた設計図を手渡し、ランさせることで設計図からハッピーフェイスを実体化させることに成功すれば画面にハッピーフェイスが表示される仕組みです。

ハッピーフェイスは、単純な曲線で描かれたオブジェ、いやオブジェクトです。

このオブジェクトはなぜか、インスタンスと呼ばれています。カップ麺にお湯を入れるとできあがりってのと違います(^_^;) 「クラス」という設計図に「宣言」という形で記述して、「()」という魔法のことばをかけて生まれます。まあ、この表現も微妙で「間違っている!」とツッコまれるかもしれませんが、プログラミングを例えると、こんな感じじゃないでしょうか。

「設計図の書き方にはルールがある」

当然ですね。ルール無視で書いても何も生まれません...
f:id:yataiblue:20150319125200j:plain

規定ルール無視で描く前衛芸術的なプログラミングなんてあり得ないでしょう。当然ルールと言えば、Swift言語ってことになります。

勉強を始めた頃に勘違いしていたのですが、設計図を書くルールを知れば、作品はできると思っていました...

英語の勉強に似ています。文法書を読んでも英語は喋れません。単語やフレーズという道具を覚え、どういう状況でどう使うか知る必要があります。iOSアプリの設計図の書き方も同様です。コードを組み立てるために道具の使い方を知らなければ何もできないんです。

家具や家の設計図でも、道具に、鉛筆やペン、定規、コンパス、計算機など必要になります。これらの使い方は直感的に分かるのですが、iOSプログラミングの道具は、実体が無い道具として目の前に並んでいます。いや、見えないんだから並んでいるんじゃなくただ存在するって感じなんです。見えないものを意識するって難しいですよね。

では、ハッピーフェイスというオブジェクトの設計図を考えていきます。

まず、白紙の設計図、FaceViewを次のように用意します。

class FaceView: UIView {

}

この設計図は「クラス宣言」と呼ばれます。「class」と頭に書いてから名前をつけ、「{}」の中に設計図を書き込んでいきます。「class」と書けば宣言です。宣言する位置も重要になるんですが、今は触れません。

道具の話をしていたので、次のクラス宣言と比べてみます。

class FaceView {

}

どこが異なるか明白です。クラス宣言のFaceViewの後に「: UIView」の記述の有無です。実はこれが道具になるんです。プログラミング的に「スーパークラスを継承」といいます。しかし、素人的に言えば、道具の継承、それより技術の伝承と言っていいでしょう。

先人が書いたプログラムを自分で拡張することができるのがクラスと呼ばれる「型」の特徴です。

継承されるクラスはスーパークラスと呼ばれ、継承することで、スーパークラスの持っている道具を自分のもののように使うことができます。なんて素晴らしい機能でしょう。

この道具ですが、ちゃんと名前を覚えていないと使い物にならないんです(T_T) 英語を喋っているときは、少しぐらいスペルが間違っていても話は通じます。しかし、プログラミングでは全く通じないんです。こういうところがプログラミングの道具の難しいところであり奥深さでもあります。

もう少し具体的なコードをみていきます。

class FaceView: UIView {
    var lineWidth: CGFloat = 5 {
        didSet { setNeedsDisplay() } }
    var color: UIColor = UIColor.blue {
        didSet { setNeedsDisplay() } }
    var scale: CGFloat = 0.9 {
        didSet { setNeedsDisplay() } }
// まだまだ続く...

まず、「var」が並んでいます。これはSwift言語のもつ道具の1つで、変数を意味します。

  • let:定数
  • var:変数

こういう説明はいらないかもしれませんが、定数と変数はプログラミングの基本になります。

「let」は定数宣言です。定数とは、プログラム内で変更する必要は無いけど繰り返し使う「値」に「定数名」をつけて使用します。逆に「var」は変数宣言になり、変数とは、定数と同様に繰り返し使う「値」ですが、変更が必ず生じる場合に「変数名」をつけて利用します。一般的に変数は「値の変更が可能」という説明が見うけられ、値が変化しなくても構わない印象を受けます。プログラム内で定数の代わりに利用されるケースが多いのですが、Xcodeの開発環境では無用な「var」の使用に警告を与えるようになっています。「var」のlet的な使用はプログラム的に問題にはならないのですが、メモリーを余分に消費してパフォーマンスを低下させる原因になります。取るに足らないことかもしれませんが、こういう警告を出すXcodeの開発環境というのは素晴らしいですね。また、プログラミング初心者は何でも「var」宣言してしまいがちですが、できるだけ「let」を使うことがプログラミング上級者への近道とも言われています。

lineWidthは、自分で設計した変数名で、次の「: CGFloat」が道具の種類といっていいでしょう。クラスの継承時と同じ意味です。クラス継承で、スーパークラスの全ての属性を道具として使えます。しかし、この場合、継承ではなく「型宣言」です。言い方は異なりますが、CGFloat型の道具が使えるって話です。

「5」という見慣れた数字が「=」によって代入されています。この「5」は見た目で型が分からないところがプログラミングです。Swiftで用意されているのは、Int型、Double型、Float型などあります。ここであえてCGFloatを使っていますが、それも理由があります。それはObjective-Cとの互換性を持たなければならないからです*3

こういうところで純粋なSwift使いは混乱するかもしれません。しかし、Objective-Cを無視することができない実状があるんです。しょうが無いので、こういうものだと納得しましょう。

その後の「{}」で囲まれたプロパティ・オブザーバーは今のところ無視します。

少しずつ話を進めますm(_ _)m

class FaceView: UIView {
    var lineWidth: CGFloat = 5 {
        didSet { setNeedsDisplay() } }
    var color: UIColor = UIColor.blue {
        didSet { setNeedsDisplay() } }
    var scale: CGFloat = 0.9 {
        didSet { setNeedsDisplay() } }
// まだまだ続くよ

「var」は変数で、「lineWidth」を作って、道具のCGFloatを設定したところです。

次は「color」という変数も作ります。今度は道具としてUIColorを与えています。何度も言いますがこれは「型」で、数々の機能が付帯されています。

ちょとまて、ちょっとまてよ、UIViewにUIColorの機能があるの? という疑問も生じるでしょう。

結局のところUIViewにしても、UIColorにしても、どんどん遡れば、1番最上に位置するNSObjectというベースクラスに行きつきます、NSObjectから派生するクラスの使用は自由ということです。UIViewのクラスにUIColorの機能が備わった瞬間です。

// 続き
    var faceCenter: CGPoint {        
        return convert(center, from: superview)
    }

    var faceRadius: CGFloat {
        return min(bounds.size.width, 
               bounds.size.height) / 2 * scale
    }

次に続く変数宣言...

このように設計図を書いていると色々な値を扱わなければなりません。それを保持する変数や定数をクラスのプロパティ*4と呼びます。lineWidth、color、scaleなどの変数は値を直接保持する「値保持型プロパティ(Stored Properties)」といい、faceCenterとfaceRadiusは値を保持しません。計算した値を返す(returen)プロパティなので「計算型プロパティ(Computed Properties)」といいます。returnは、まさに戻すって意味です。

次にconvert()はなんでしょう?

これは関数、正確にはメソッドといいます。()の中に入力を受けて、計算された値をCGPoint型で返します。

Xcode上で「Opt」を押しながらconvertにカーソルを持っていくと「?」に変化するのでクリックします。下のような説明が現れます。

f:id:yataiblue:20160926121426j:plain

この関数(func)もUIKitにあるメソッドなので、道具です。しかし、使い方をマスターしていないと自分では使えないってことになります。

convert()

次のように記述しないと、道具として使えません。ミススペルは許されません(T_T)
func convert(_ point: CGPoint, from view: UIView?) -> CGPoint

()内の引数は2つあり、最初のパラメータの外部名に「_」があるので、直接CGPoint型の値を入力します。2番目は、外部パラメータを必要とするので「from」と記述してから、UIView?*5 型の値を与えます。すると、CGPoint型の値を返してくれるんです。

関数の引数の渡し方も、道具によって外部パラメーターがあったり無かったり複雑です。こういう道具を覚えることは、英語学習の英単語を覚えるのと似ています。使える道具が増えればiOSフレームワークを自由に扱えるということです。実践的に使いながら慣れないと覚えられないので時間はかかります(^_^;)

じゃあ実際のコードをみていきます。

    var faceCenter: CGPoint {        
        return convert(center, from: superview)
    }

convert(center, from: superview)と書かれています。

実は...「center」という変数や「superview」という変数も道具として用意されている計算型プロパティです。ということは知らないと使えないってことです!

単語を知らないと英語が喋れないのと全く同じ。プログラミングは語学なんです。

こう考えると「9ヶ月Swiftの勉強をして全くプログラミングできなくても当たり前だ!」と思えます(^_^;)

まあ私の理解力がかなり悪いんで、グダグダ何やってんだと思われるかもしれませんが。

引数で入力するcenterは、CGRect型のsuperviewのセンター値を計算して与えることになります。このsuperviewは、必ずXcodeでプロジェクトを作ると必ず存在するビューシステムで、新しくその上にFaceViewを載っけています。FaceViewのcenter値はスーパービューのcenter値から変換してやるってことです。

center値ってそもそも何なのよ?って思っている人はもう一度boundsとframeの関係*6を復習するといいでしょう。

ここまでで画面に顔の輪郭としての円が描画されました。ハッピーフェイスにするために後2つの眼と口を用意する必要あります。眼と口の大きさや位置は顔の大きさに依存させるため比率をグローバル定数化します。

    private struct Scaling {
        static let FaceRadiusToEyeRadiusRatio: CGFloat = 10
        static let FaceRadiusToEyeOffsetRatio: CGFloat = 3
        static let FaceRadiusToEyeSeparationRatio: CGFloat = 1.5
        static let FaceRadiusToMouthWidthRatio: CGFloat = 1
        static let FaceRadiusToMouthHeightRatio: CGFloat = 10
        static let FaceRadiusToMouthOffsetRatio: CGFloat = 10
    }

まず目は2つあるので、Enum型を使って、右眼と左眼のパターンを作ります。

    private enum Eye { case Left, Right }

次はこの比率定数を使って「眼」を描画するカスタムメソッドを作ります。当然引数として「Eye」を受け取り、右眼と左眼を描画させるようにします。

private func bezierPathForEye(whichEye: Eye) -> UIBezierPath {

}

この眼の大きさ(半径)を顔の大きさ(半径)から計算させます。FaceRadiusToEyeRadiusRatioが「10」なので、次のようにすれば10分の1ということです。FaceRadiusToEyeOffsetRatioは水平の位置決めの比率で、「3」なのでちょうど顔の半径の3分の1ってことになります。FaceRadiusToEyeSeparationRatioは両眼のスペースを顔の半径の1.5分の1に設定しています。

let eyeRadius = faceRadius / Scaling.FaceRadiusToEyeRadiusRatio
let eyeVerticalOffset = 
        faceRadius / Scaling.FaceRadiusToEyeOffsetRatio
let eyeHorizontalSeparation = 
        faceRadius / Scaling.FaceRadiusToEyeSeparationRatio

まず最初に顔の中心と眼の中心を同じにします。次にy軸のみ半径の3分の1だけ上に動かします*7

    var eyeCenter = faceCenter
    eyeCenter.y -= eyeVerticalOffset

垂直方向の位置は右眼も左目も共通ですが、水平方向はパラメーターで判断させて右と左を区別させます。

    switch whichEye {
    case .Left: eyeCenter.x -= eyeHorizontalSeparation / 2
    case .Right: eyeCenter.x += eyeHorizontalSeparation / 2
    }

ここまでで眼の大きさと位置を決定したのでUIBezierPathのインスタンス化をさせて戻り値にします。

    let path = UIBezierPath(arcCenter: eyeCenter, 
                               radius: eyeRadius, 
                           startAngle: 0, 
                             endAngle: CGFloat(2*M_PI), 
                            clockwise: true)
    path.lineWidth = lineWidth
    return path

これで眼を描画するメソッドができました。

じゃあ何処でこのメソッドをCallします?

そうですね。draw() で呼ぶんですよね。

    let rightEye = bezierPathForEye(whichEye: Eye.Right)
    rightEye.stroke()

こうでもいいし、

    bezierPathForEye(whichEye: Eye.Left).stroke()

直接メソッドをCallしてもいいんです。

これをランしてみましょう。

f:id:yataiblue:20160927101118j:plain

次は微笑みを口元の曲線で表現します。ベジェ曲線*8で表現するのですが、メソッドを用意します。曲線の大きさをDouble型値の引数として与えてUIBeierPath型を戻り値とする次のメソッドです。

private func bezierPathForSmile(fractionOfMaxSmile: Double) 
                                           -> UIBezierPath {

}

口の大きさも顔の輪郭の半径を使って相対的な大きさにします。

    let mouthWidth = 
         faceRadius / Scaling.FaceRadiusToMouthWidthRatio
    let mouthHeight = 
         faceRadius / Scaling.FaceRadiusToMouthHeightRatio
    let mouthVerticalOffset = 
         faceRadius / Scaling.FaceRadiusToMouthOffsetRatio

微笑みの大きさはパラメータとして与えるfractionOfMaxSmileの値を使います。min()とmax()を使ってパラメータ値の範囲を限定させます。

    let smileHeight = 
      CGFloat(max(min(fractionOfMaxSmile, 1), -1)) * mouthHeight

ベジェ曲線に必要な引数は4つです。開始点、コントロールポイント1*9、コントロールポイント2,終了点を用意します。

    let start = CGPoint(x: faceCenter.x - mouthWidth / 2, 
                        y: faceCenter.y + mouthVerticalOffset)
    let end = CGPoint(x: start.x + mouthWidth, 
                      y: start.y)
    let cp1 =CGPoint(x: start.x + mouthWidth / 3, 
                     y: start.y + smileHeight)
    let cp2 = CGPoint(x: end.x - mouthWidth / 3, 
                      y: cp1.y)

UIBezierPathオブジェクトの実体化ステップをコードします。

    let path = UIBezierPath()
    path.move(to: start)
    path.addCurve(to: end, controlPoint1: cp1, controlPoint2: cp2)
    path.lineWidth = lineWidth
    return path

これでメソッドの用意ができたので、draw()メソッド内でインスタンス化させます。

override func draw(_ rect: CGRect) {
    let facePath = 
          UIBezierPath(arcCenter: faceCenter,
                          radius: faceRadius, startAngle: 0,
                        endAngle: CGFloat(2*M_PI), clockwise: true)
    facePath.lineWidth = lineWidth
    UIColor.orange.set()
    facePath.stroke()
    bezierPathForEye(whichEye: Eye.Left).stroke()
    let rightEye = bezierPathForEye(whichEye: Eye.Right)
    rightEye.stroke()
            
// 今回加わったコードは以下        
    let smiliness = 0.75
    let smilePath = bezierPathForSmile(fractionOfMaxSmile: smiliness)
    smilePath.stroke()
}     

ここまででランさせます。ちゃんとハッピーフェイスが現れました!

f:id:yataiblue:20160928151810j:plain

ここから少しアプリケーション開発に必要なプログラミング概念の理解が必要になってきますiOS APIを利用するためにOOP(オブジェクト指向プログラミング)を理解する必要があります。GUIがベースのiPhoneiPadiOSプログラミングの基本設計はMVCで構成されています。

MVCモデルを最初に説明したのは随分前*10になります。M がモデル、V がビュー、そしてC がコントローラーを意味します。

f:id:yataiblue:20150325072653j:plain

まず、FaceViewというカスタムビューは当に「V」にあたるため、できるだけ一般化(ジェネリック)しておく必要があります。「このプログラムで何をしたいのか?」を最初に明確にしておかなければなりません。

顔をにんまりさせて幸福度を表現したいんですよね。

じゃあ「にんまり度」の変化がこのプロジェクトのコアとなる「M」のモデルにします。

「Calculatorプロジェクト」に取り組んでいた時のことを覚えていますか?プログラムの神髄にあたる計算部分は複雑だったので、CalculatorBrainというオブジェクトをユーザーインターフェイスのViewControler(C)から独立さて、モデル(M)にしました。

今日取り組んでいる「にんまり度」はどうだろう? Int型変数1つで表現する最も単純なモデル(M)です。「0」が1番不幸な状態で「100」が最も幸福な状態を表現しています。さて、モデルといってもInt型変数1つなので、このチュートリアルでは独立させず、HappinessViewController(C)に加えていきます。

f:id:yataiblue:20150325102019j:plain

happinessというInt型変数がモデル(M)です。

happinessの入力値を0から100の間に制限させる方法としてプロパティ・オブザーバーを使っています。この方法は覚えておくといいでしょう。勉強になります。

var happiness: Int = 50 { // 0 = もっども悲しく、100 = もっとも嬉しい
    didSet {
        happiness = min(max(happiness, 0), 100)
    }
}

さすがにもう説明はしませんが、入力された値が0と100の間に収まります。入力があればUIをアップデイトさせる必要もあるので、アップデイトさせる関数も用意します。

class HappinessViewController: UIViewController  {
    var happiness: Int = 50 { // 0 = もっども悲しく、100 = もっとも嬉しい
        didSet {
            happiness = min(max(happiness, 0), 100)
            print("happiness = \(happiness)") // コンソールで確認用
            updateUI()
        }
    }
    
    func updateUI() {
        // まだ実装してませーん
    }
}

これでモデルが完成! じゃあランすれば動くか? happinessの値を変化させても、FaceViewのにんまり度は変化しません。HappinessViewControllerとFaceViewが繋がっていないからです。これを繋げるために、プロトコールとデリゲーションの理解が必要になってくるという話の展開。ポール先生、教え方が上手い!

FaceView(V)HappinessViewController(C)を繋げていくためにプロトコールとデリゲーションの理解をすすめます。

6ステップ プロトコール・デリゲーション実装法*11

FaceViewクラスはジェネリック(一般的)な存在なので、表示機能だけ備わっていて何を表示するか具体的なコンテンツを知る必要はありません。そのため、コンテンツの内容に関して他のオブジェクトに任せる必要があります。この任せることをデリゲーション(委譲)と呼びます。ちょっと分かりにくいのですが、処理を任されたオブジェクトのことをデリゲートと呼びます。すなわちHappinessViewContorllerクラスがデリゲートということです。日本語が妙なんですが気にしないで、デリゲーションの説明をします。

f:id:yataiblue:20161019103026j:plain

  1. FaceViewクラスがHappinessViewControllerクラスに任せたい(デリゲーション)処理をデリゲート・プロトコールとして用意します。FaceViewクラスにあるsmilinessの値をHappinessViewControllerクラスで操作してもらいたい(委任)んです。この値を操作する関数「smilinessForFaceView(sender:)」をプロトコールとしてパブリックにオープンにします。
  2. デリゲート・プロトコールとして、デリゲート・プロパティのdataSourceをFaceViewクラス内で保持します。初期化ステップが発生するまで変更が加わらないようにするためにオプショナル設定にします。また循環参照を避けるためにweakにしてメモリーリークを回避します。
  3. デリゲート・プロパティにFaceViewである自分自身(self)を引数として与えることで、smilinessの具体的な値を取り出します。dataSourceというプロパティは、HappinessViewControllerクラス内で自分自身である(HappinessViewController自身であるself)を指定しているため、このdataSourceはHappinessViewControllerクラスで具体的に記述している「smilinessForFaceView(sender:)」メソッドだけ持つ「FaceViewDataSource型」になっているんです。ということで「delegate?.smilinessForFaceView(sender: self)」 を使ってHappinessViewControllerに記述されているメソッドを利用して具体的なDouble型の値を取り出すことができます。
  4. 処理を委任(デリーゲート)されたHappinessViewControllerクラスに先ほどのプロトコールを準拠させます。
  5. 先ほど説明したHappinessViewController自身をFaceViewクラスのプロパティ(dataSource)に指定するところです。dataSourceは「FaceViewDataSource型」になります。ここのステップはコードを書くよりも、Xcode8を使って設定する方法*12が一般的です。ストーリーボード上のFaceViewを「Ctrl + ドラッグ」してIBOutletプロパティを作ってやるんです。
  6. プロトコールはブループリントなので、ここで具体的な処理をコードします。

これでHappinessViewControllerとFaceViewが繋がりました。HappinessViewControllerのプロパティの値を0から100で変化させると、FaceViewが変化...

おっとHappinessViewControllerにあるhappiness値を変化させてもFaceViewは変化しません。変化を察知して描画し直す仕組みが備わってないからです。

HapinessViewControllerのプロパティHappinessはプロパティ・オブザーバーを使っているので、変化を察知する仕組みは整っています。その部分を注目します。

var happiness: Int = 50 { // 0 = もっども悲しく、100 = もっとも嬉しい
    didSet {
        happiness = min(max(happiness, 0), 100)
        print("happiness = \(happiness)") // コンソールで確認用
        updateUI()
    }
}

しかし、「updateUI()」メソッドがまだ未実装なので、ここで再描画をさせてやればいいんです。

func updateUI() {
    faceView.setNeedsDisplay()
}

これでHapinessViewControllerのhappiness値の値を変更すると、FaceViewの表情が変化します。

ユーザーが画面を操作(HappinessViewControllerに働きかけ)して、画面をコントロールする(FaceViewを変化させる)しくみの実装に入ります。

ジェスチャー(Gesture)というハードウェアを制御するプログラム動作を理解する必要はないのですが、UIGestureRecognizerのサブクラス(個別の反応)からインスタンスを発生させてViewクラスオブジェクトに組み込めば、ジェスチャーが使用できるようになります。UIGestureRecognizerが組み込まれると、ジェスチャー情報はhandlerというオブジェクトを発生します。

まずハッピーフェイスをピンチ(Pinch)して顔の大きさを変更させるようにします。

ピンチジェスチャー(PinchGesture)でfaceViewのscale値を変更

まず、ジェスチャー・レコグナイザーをfaceViewに加えるのですが、FaceViewクラスのインスタンス(faceVIew)が生成されるのはHappinessViewControllerです。インスタンス化は次の@IBOutlet部分で生じます。

@IBOutlet weak var faceView: FaceView! {
    didSet {
        faceView.dataSource = self
    }
}

dataSourceはFaceViewクラスオブジェクトがデリゲーションした機能を使用してリフレッシュさせるコードです*13。デリゲートは、デリゲーションとデータソースという名前にする慣習があります。この2つの名前の意味の違いはわかりません〜 まあ次に進みましょう。

このプロパティ・オブザーバー内にジェスチャーを加えていくと次のようになります。

@IBOutlet weak var faceView: FaceView! {
    didSet {
        faceView.dataSource = self
        let recognizer = 
               UIPinchGestureRecognizer(target: faceView, 
                                        action: #selector(""))
        faceView.addGestureRecognizer(recognizer)
    }
}

これでfaceViewインスタンス(オブジェクト)にピンチの機能が組み込まれました。なんかあまりにも簡単なので納得できないというか...

ここで赤いエラーマークが付くけど無視します。ピンチが及ぶtargetは「faceView」です。actionはハンドラーで、Swift 3以前は文字列を指定していたのですが、Objective-C的なAPI発動のために#selector()を使用するようになりました。「""」なのは「まだ説明していないので指定していない」というだけでエラーになります。後でここにコードを加えていきます。taregetであるfaceViewに定義されているメソッドを指定していきます。

どちらにしろ、ターゲットがfaceViewなのでfaceViewにこのメソッドを加える必要があります。

次のコードをもう少し詳しくみていきます。

let recognizer = 
       UIPinchGestureRecognizer(target: faceView, 
                   action: #selector(""))

ターゲットを「faceView」にしていますが、「self」にすることもできます。

このジェスチャーをどこで制御すべきか少し考察する必要があります。制御する場所をselfにしてHappinessViewController(C)でコントロールするのが本当は一般的なようです。しかし、そうするのであればデリゲーションが必要になります。説明が複雑になるので今回はfaceView(V)でコントロールする方法をとっています。

そして次の「 #selector("")」はコードとして不十分なんでエラーになっています。

どういうメソッドにジェスチャーインスタンス(ここではrecognaizer)を渡すか指定するところです。

まだfaceViewにコードにメソッドを実装していないため空白にしていましたが、faceViewにgestureを受け取るメソッドを加えます。

FaceViewクラスに次のメソッドを加えます。

func scale(gesture: UIPinchGestureRecognizer) {
    if gesture.state == .changed {
        scale *= gesture.scale
        gesture.scale = 1
    }
}

ジェスチャーのプロパティstateはイーナム型で、普通は「switch - case」を使いますが、「.changed」の条件しか使用しないので「if文」を使用します。gestureのプロパティscaleとメソッドscaleの名前が同じなので勘違いしないように注意します。gesture.scaleはピンチの最初は1で大きくすれば拡大して小さくすれば縮小されていきます。そして処理の後に再びscaleを1に戻すというコーディングです。

これが「action:」で指定されたメソッドになります。次にHappinessViewControllerで指定する「action:」を次のように変更します。

let recognizer = 
     UIPinchGestureRecognizer(target: faceView, 
           action: #selector(faceView.scale(gesture:)))

これで赤いエラーが消えます。そしてこれだけでfaceViewのサイズを変えることができるんです。

なんか素晴らしいですね。

なぜかジェスチャーに関する理解に時間がかかります。何度も弁明していますが、1番大きな要因は私の衰えた理解力にあるんですが、プログラミング言語が外国語に似ているところにもあります。表現法にバリエーションがあるということです。

UIGestureRecognizerクラスの利用のための実装方法にバリエーションがあり混乱してました(^_^;)

プログラミングは正確無比な文法記述を必要とするところから、OOP技法に答えは1つしかないものだと思い込んでいました。しかし、これは誤解でした。いくらでもバリエーションは存在します。リーダブルコーディングや作法という言葉が巷に溢れているのに気がつかなかったんでず。

このことを理解すると、ポール先生の講義やデモの説明を聴いていても、「他にやり方があるんじゃないか?」と考えられるようになりました。

これから説明するUIPanGestureRecognaizerの実装法ですが、ポール先生の講義を拡張して私なりに説明を加えます。

UIGestureRecognizerクラスには具体的な動作を扱うサブクラスが分類され、大きくDiscrete GestureContinuous Gestureがありまずが、Discreteジェスチャーには次の2つがあります。

  • UITapGestureRecognizer
  • UISwipeGestureRecognizer

これらは画面から指が離れた時だけに呼ばれます。

ちょっと勝手にUITapgestureRecognizerをHappinessプロジェクトに組み込んでみましょう。タップしたら背景が緑色に変化して、もう一度タップすると白色に戻る単純なしくみをピンチと同じ手順で組み込みます。1つ違うところは、バックグラウンドの色の状態を保持するプロパティをFaceViewクラスに持たせる必要があるところです。FaceViewクラスの冒頭にあるプロパティ宣言のところに「var bkChanged: Bool = false」を加えます。最初は変化していないので「false」です。

class FaceView: UIView {

    var lineWidth: CGFloat = 5 {
        didSet { setNeedsDisplay() } }
    var color: UIColor = UIColor.blue {
        didSet { setNeedsDisplay() } }
    var scale: CGFloat = 0.9 {
        didSet { setNeedsDisplay() } }
    var bkChanged: Bool = false
...

そして次はHappinessViewControllerクラスの「@IBOutlet weak var faceView: FaceView!」に注目します。既にUIPinchGestureRecognizerのインスタンス宣言がありますが、その下に次のコードを加えます。

let tapRecognizer = 
    UITapGestureRecognizer(target: faceView, 
      action: #selector(faceView.changeColor(sender:)))

まだ、FaceViewクラスに「changeColor(sender:)」メソッドをコードしていないので、今の段階でエラーマークが出現しますが無視します。そしてこのレコグナイザーをfaceViewに登録するコードを加えます。

faceView.addGestureRecognizer(tapRecognizer)

これでHappinessViewControllerクラスの準備はできました。次はFaceViewクラスにうつります。クラスに次の関数を用意します。

func changeColor(sender: UITapGestureRecognizer) {
    if sender.state == .ended {
        if bkChanged == false {
            self.backgroundColor = UIColor.green
            bkChanged = true
        } else {
            self.backgroundColor = UIColor.white
            bkChanged = false
        }            
    }
}

これで実行するとタップで背景が変わります。できたできた!

f:id:yataiblue:20161025114342j:plain

そして、先ほど説明した「ピンチ」はContinuousジェスチャーに分類され次の4つがあります。

  • UIPinchGestureRecognizer
  • UIPanGestureRecognizer
  • UIRotationGestureRecognizer
  • UILongPressGestureRecognizer

まずHappinessViewControllerでUIPinchGestureRecognizerクラスのインスタンスをイニシャライザーで生成させます。まだこのインスタンスは独立した状態です。

UIPinchGestureRecognizer(target: Any?, action: Selector?)

target」がfaceViewになっているので、ピンチの操作はテーゲットのfaceViewにだけ及びます。そして「action」で指定するSelectorはfaceViewに定義されていなければなりません。

このインスタンスをfaceViewのメソッドaddGestureRecognizer()を使って組み込むことでfaceViewはピンチに反応するようになります。

FaceViewに定義しているカスタムscaleメソッドの「scale *= gesture.scale」は指定されている右辺のgesture.scaleの「scale」は既に登録されているプロパティで、画面サイズから相対的な値がCGFloat型として得られ、範囲は0.0から1.0のようです。左辺のscaleはfaceViewの持っているカスタム変数です。

本来ならfaceViewをよりジェネリックな存在にさせるため、この変数scaleをデリゲーションしてHappinessViewControllerで操作するのがMVCモデルとして理想的です。

次は「パンジェスチャー」を制御するUIPanGestureRecognizerで、faceViewの持っているプロパティ「smiliness」をコントロールしてみます。FaceViewDataSourceとしてデリゲーションされているので、HappinessViewControllerで操作できます。

UIPanGestureRecognizerクラスのインスタンスを生成するときにtargetをselfにしてHappinessViewControllerでsmilenessをコントロールするメソッドを作ります。

@IBOutlet weak var faceView: FaceView! {
    didSet {
        faceView.dataSource = self
        let recognizer = 
          UIPinchGestureRecognizer(
               target: faceView, 
               action: #selector(faceView.scale(gesture:)))
        faceView.addGestureRecognizer(recognizer)
        faceView.addGestureRecognizer(
            UIPanGestureRecognizer(
               target: self, 
               action: #selector(changeHappiness(gesture:))))
    }
}

HappinessViewControllerに次の「changeHappiness()」メソッドをコードします。パンジェスチャーはContinuousジェスチャーなので、複数のstate状態が変化し続けるので「switch-case文」を使って制御します。

func changeHappiness(gesture: UIPanGestureRecognizer) {
    switch gesture.state {
    case .ended: fallthrough
    case .changed:
        let translation = gesture.translation(in: faceView)
        let happinessChange = 
          -Int(translation.y / Constants.HappinessGestureScale)
        if happinessChange != 0 {
            happiness += happinessChange
            gesture.setTranslation(CGPoint.zero, in: faceView)
        }
    default: break
    }
}

このコードで私が感銘を受けたところは、スタンフォード大学のポール先生のさりげないコーディング作法です。「Constnts.HappinessGestureScale」は、ポール先生がカスタマイズして用意したものです。単なるCGFloat型の定数なんですが、どうして感銘を受けたか?恥ずかしいのですが、私みたいな素人にとって、ポール先生の説明無しで加えるさり気ないコーディングに関心してるんです。自分で定義する定数をStructure型にまとめて使用するだけのことですが、私には思いつきもしなかったんです。やっぱり先生ですね。こういうコーディング作法はマスト・リメンバーです。

private struct Constants {
    static let HappinessGestureScale: CGFloat = 4
}

自分で定義する定数が増えれば増えるほど、「Constants.〜」という形で一元管理できるのでミスを予防できます。

そしてジェスチャークラスに実装されているイーナム型のstateも覚える必要があります。このstateはUIGestureRecognaizerState型で、次のように定義されています。

enum UIGestureRecognizerState : Int {
    case possible
    case began
    case changed
    case ended
    case cancelled
    case failed
    static var recognized: UIGestureRecognizerState
}

このイーナム型はそれぞれの項目にInt型のRaw Valueを設定できます。

どう扱うといいのか説明は割愛します。私も完全に理解しているわけではないので(^_^;)

取りあえず、このstateを使用するときは、「switch-case文」を使うのが一般的です。

「.changed」はパンをしている間なので、指が画面上で動いている間はcase文の中が実行されるってことです。

UIPanGestureRecognizerには次の3つの主要なメソッドが用意されています。

  1. func translation(in view: UIView?) -> CGPoint
  2. func setTranslation(_ translation: CGPoint, in view: UIView?)
  3. func velocity(in view: UIView?) -> CGPoint

transLation()は、タップしたろ位置を起点としてCGPointで返し、velocityはそのスピードをCGPoint/秒で返します。また、setTranslationはパンしている位置を始点に戻すリセットを意味します。

ここでちょっと分かりにくいのがCGPointZeroを使ったsetTranslationメソッドです。

HappinessViewControllerクラスにコードしたメソッドchangeHappiness()を詳しくみていきます。

func changeHappiness(gesture: UIPanGestureRecognizer) {
    switch gesture.state {
    case .ended: fallthrough
    case .changed:
        let translation = gesture.translation(in: faceView)
        let happinessChange = 
          -Int(translation.y / Constants.HappinessGestureScale)
        if happinessChange != 0 {
            happiness += happinessChange
            gesture.setTranslation(CGPoint.zero, in: faceView)
        }
    default: break
    }
}

この中でパンジェスチャーメソッドのsetTranslationメソッドの挙動が理解できなかったので、実際にコードの有無で挙動がどう変化するのかシュミレーターで確認してみました。

コードで見えなかった挙動ですが、実際に動かすと理解できました!

このコードが無くても、指を上に動かし続けるとtranslationInViewの値は変わり続けsmiliness値は上限100に達します。しかし、途中で指の動きを反転させても即座にsmilinessの値が減りません。最初にタップした位置まで戻らないとsmiliness値が減らないことが分かりました。

このsetTranslationメソッドはパンジェスチャーを動かすたびに動いた先を始点に戻すコードでした。これを書かないとスムーズなパンの動きを再現できないようです。

実はスタンフォード大学のポール先生は、このコーディングによるGestureの組み込み方法をスクリーンで説明しただけで実際のデモでは動かしませんでした。コードを使った実装法より、storyboardを使ったビジュアルな実装法が一般的なためです。

UIGestureRecognizerのビジュアル実装法

まずHappinessViewControllerのfaceViewプロパティにある最後の1行「faceView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: "changeHappiness:"))」がコードによる実装です。

@IBOutlet weak var faceView: FaceView! {
    didSet {
        faceView.dataSource = self
        let recognizer = 
          UIPinchGestureRecognizer(
               target: faceView, 
               action: #selector(faceView.scale(gesture:)))
        faceView.addGestureRecognizer(recognizer)
        faceView.addGestureRecognizer(
            UIPanGestureRecognizer(
               target: self, 
               action: #selector(changeHappiness(gesture:))))
    }
}

この1行を削除します。

@IBOutlet weak var faceView: FaceView! {
    didSet {
        faceView.dataSource = self
        let recognizer = 
          UIPinchGestureRecognizer(
               target: faceView, 
               action: #selector(faceView.scale(gesture:)))
        faceView.addGestureRecognizer(recognizer)
    }
}

Main.storyboardに移ります。右コラムのオブジェクト・ライブラリから「Pan Gesture Recognizer」を選んでfaceViewにドラッグするとジェネリックなUIPanGestureRecognizerクラスオブジェクトが生成されます。

f:id:yataiblue:20150404090235j:plain

次にstoryboardの上部にあるパンジェスチャーのアイコンを「Ctrl」+「ドラッグ」してHappinessViewVontrollerのコード上にもってきます。

f:id:yataiblue:20150404092343j:plain

リリースするとポップアップウインドウが出現するので、それぞれの項目を図のように入力します。

f:id:yataiblue:20150404092507j:plain

するとfaceView上にコネクトされたUIPanGestureRecognizerのメソッドが自動で作られます。コードを書いたとき引数を「gesture」にしましたが、@IBActionで作られる引数は「sender」になります。これを「gesture」に変えるのがポール先生のお勧めです。

メソッドは前回と同様なのでコピペしてランするとちゃんと動きます。

まとめ

ジェスチャーのターゲットをViewにしたりControllerに変更してみたり、実装の実装法もコーディングとストーリーボードを使ったビジュアルな方法の説明をしました。

*1:2016年10月26日:UIGestureRecognizerの実装法を改訂、2016年10月18日:プロトコールとデリゲーションの説明を訂正、2016年09月25日:MVC概念の説明を追加、2016年09月22日:眼と口をUIBezierPathを使って描画、2016年09月20日:足らなかったコードを追加、2016年09月18日:古い記事を一部統合してXcode 8向けに変更、2016年09月16日:忘却とは恐ろしいもので昨日の修正も間違っていました(^_^;)、2016年09月15日:Xcode 8 & Swift 3 で動くコードに変更してイメージも差し替え、2016年01月05日:追記 疑問を解消

*2:このレクチャーはiOS8向けのスタンフォード大学の講義です。

*3:iOSシステムの大半にObjective-Cで構成されているからです。

*4:Swiftで遊ぼう! - 166 - プロパティ(Properties):Life-LOG OtherSide

*5:Swiftで遊ぼう! - 227 - Developing iOS 8 Apps with Swift - Optionalは単なるenum - Swiftで遊ぼう! on Hatena

*6:Swiftで遊ぼう! - 250 - bounds vs frame - Swiftで遊ぼう! on Hatena

*7:iOS開発において座標システムの理解は基本です→Swiftで遊ぼう! - 249 - UIViewの座標システム: iOS10 - Swiftで遊ぼう! on Hatena

*8:ベジェ曲線の基本 -> Swiftで遊ぼう! - 680 - Views 4 - Swiftで遊ぼう! on Hatena

*9:cpの意味だと思います...

*10:Swiftで遊ぼう! - 46 アプリを作ってみよう2 MVCモデル:Life-LOG OtherSide

*11:一般論での説明は->Swiftで遊ぼう! - 260 - プロトコールとデリゲーション ProtocolsとDelegation - Swiftで遊ぼう! on Hatena

*12:詳しい説明→Swiftで遊ぼう! - 318 - My Picker Project : SingleComponentPicker Xcodeでデリゲーション - Swiftで遊ぼう! on Hatena

*13:前回説明したプロトコールとデリゲーションのステップにあたります