Swiftで遊ぼう! on Hatena

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

Swiftで遊ぼう! - 677 - FaceIt プロジェクト

2017年5月17日:間違っていたところを訂正*1

iOS 9: Developing iOS 9 Apps with Swift - Free Course by Stanford on iTunes U
iOS 10: Developing iOS 10 Apps with Swift - Free Course by Stanford on iTunes U

講義4「Views」でUIViewの扱いを勉強します。

iOSのView関連の説明が前半にあります。これはDeveloping iOS 8 Apps with Swiftの時から同じです。やはり学習時間が長くなっているので理解が進んでいます。

過去の記事を復習しながら改訂します。

yataiblue.hatenablog.com

UIBezierPathの復習もしました。

yataiblue.hatenablog.com

UIViewに関する知識を整理したのでデモプロジェクトに取りかかります。

今回のプロジェクト名は「FaceIt」です。基本的にCalculatorデモと同じステップで新規プロジェクトを作ります。

Swiftで遊ぼう! - 365 - Developing iOS Apps (Swift) Build a Basic UI - Swiftで遊ぼう! on Hatena

Product Nameを「FaceIt」にして、Organization NameとOrganization Identifierを決めて、Languageは「Swift」を選びます。Devicesは、「Universal」にしてiPhoneiPadの両方に対応、「Use Core Data」「include Unit Tests」「include UI Tests」のオプションは選択しません。

次に出てくる「Create Git respository on」も選ばないでファイルを保存するとプロジェクトができてXcodeが開きます。

ナビゲータ・エリアに並んでいるファイルも「Main.storyboard」と「ViewController.swift」以外のファイルは「Supporting Files」にまとめて隠します。

またViewControllerのライフサイクルの説明をしていないのでViewController.swiftを選択してクラス内にあるデフォルトのメソッドを全て消します。

import UIKit

class ViewController: UIViewController {

}

2017年のiOS 10講座ではViewController.swiftファイルのリネームをしませんが、iOS 9ではここでリネームの仕方の説明が入ります。ViewControllerはジェネリックな名前なので「FaceViewController」にリネームします。

リネームの方法は次のリンクで説明しています。「デフォルトのViewControllerの名前を変更してつなぎ替える方法」の手順をみてください。

yataiblue.hatenablog.com

FaceItプロジェクトは、スマイルフェイスをViewに描出させます。Calculatorデモでは「MVC」の「M」と「C」にしか注目しましたが。このプロジェクトで「V」を扱います。

とりあえず、この段階でViewController.swiftのリネームは必須じゃありません。そのまま続けてください。

メニューから「File」>「New」>「 File...」を選んで、UIViewのサブクラスを作るので「Cocoa Touch Class」を選びます。UIViewサブクラスの「FaceView」を作って保存します。

スマイルフェースを独自に描出させるので、FaceViewでコメントアウトされているdrawRectを使えるようにします。

import UIKit

class FaceView: UIView {

    override func draw(_ rect: CGRect) {
        // Drawing code
    }

}

2017年のiOS 10開発講座では、ここからMain.storyboardに戻ります。オブジェクト・ライブラリからジェネリックな「View(UIView)」をsuperviewにドラッグ&ドロップします...

しかし、iOS 9の講義では、直ちにFaceViewクラスのコーディングの説明に入ります。2017年の講座で教え方に変更が加わった理由は後で分かるのでこのまま続けます。

FaceViewクラスのdrawRectメソッド内で円を描画をさせます。UIBezierPathクラスを使って円を描くので、必要な変数は2つあります。「半径(Radius)」と「中心点(Center)」です。

  1. まず半径を求めるステップですが、「frame」と「bounds」の意味の違いを理解して自分自身の座標システムからX軸、もしくはY軸の短い方を直径にして半分の値を得ます。
  2. 次は中心点ですが、プロパティ「center」はsuperViewからみたviewの位置をみているものなので使えません*2。boundsの属性を使って指定します。
    let skullRadius = min(bounds.size.width, bounds.size.height) / 2
    let = skullCenter = CGPoint(x: bounds.midX, y: bounds.midY)

次はUIBezierPath()クラスを使って円を作ります(描画じゃないです)。UIBezierPathは色々なパラメータを使ってインスタンス化できます。

override func draw(_ rect: CGRect) {
        
    let skullRadius = min(bounds.size.width, bounds.size.height) / 2
    let skullCenter = CGPoint(x: bounds.midX, y: bounds.midY)
        
    let path = UIBezierPath(arcCenter: skullCenter, 
       radius: skullRadius, startAngle: 0.0, 
          endAngle: 2 * CGFloat.pi, clockwise: false)
    path.lineWidth = 5.0
    UIColor.blueColor().set()
    path.stroke()
}

ここでランしても描画されません。

これがiOS 10講座で教え方に変更があった部分です。というのもこのFaceViewはどこにも関連付けられていないからです。ここからのステップも今まで何度も繰り返してきたステップです。オブジェクト・ライブラリからジェネリックナView(UIView)をドラッグしてMain.storyboardにあるsuperviewの上に設定します。この時スクリーン一杯に広げたいのでブルーラインを上手に使って隙間無しに広げて青いラインが出たままにします。このとき「Ctrl + ドラッグ」でコンストレイントの設定はできないので、「Resolve Auto Layout Issuesボタン」から「Reset to Suggested Constraints」を選ぶとコンストレイントが設定されます。

次にジェネリックナサブViewが設定されたので、アイデェンティティ・インスペクタかのCustom Classの「CLass」に「FaceView」を選択してランすればちゃんと円が描画されます。
f:id:yataiblue:20160516181850j:plain
しかしランドスケープに回転すると円が歪みます。
f:id:yataiblue:20160516182001j:plain
これを修正するためにアトリビュート・インスペクタからコンテントモード「Mode」を「Scale to Fit」から「Redraw」に変更すれば問題は修正されます。

顔の輪郭が画面いっぱいなので、90%のサイズにするために1つプロパティを用意します。

var scale: CGFloat = 0.9

そしてこれをskullRadiusに加えます。

次に顔面のサイズから相対的に目と口の大きさを決定したいので利用しやすいように次の定数をdrawRect()メソッド内から外に移動させます。

override func draw(_ rect: CGRect) {
        
    let skullRadius = 
        min(bounds.size.width, bounds.size.height) / 2 * scale
    let skullCenter = CGPoint(x: bounds.midX, y: bounds.midY)
...

これをクラス宣言下に移動させると、ブブー! エラーが出ます。

理由は簡単です。「初期化ステップの途中に自分自身のプロパティ(bounds)を使って初期化はできない」という明確な理由のためです。これを回避するために計算型プロパティを使います*3

private var skullRadius: CGFloat {
    return min(bounds.size.width, bounds.size.height) / 2 * scale
}
private var skullCenter: CGPoint {
    return CGPoint(x: bounds.midX, y: bounds.midY)
}

計算型プロパティの書き方を覚えていますか? 「get」と「set」を書きます。しかし、getだけ記述するときに省略できます。これを使うと初期化ステップが終了後にこのプロパティが呼ばれた時に値を参照できます。

顔を作ったり、目を作ったり、口を作るためのメソッドを用意します。

まず顔の輪郭を作るメソッドです。

private func pathForSkull() -> UIBezierPath {
    let path = UIBezierPath(arcCenter: skullCenter, 
                               radius: skullRadius, 
                           startAngle: 0, 
                             endAngle: 2 * CGFloat.pi, 
                            clockwise: false)
    path.lineWidth = 5.0
    return path
}

このskullの中心点と半径から相対的な位置とサイズ決めをするのですが、その比率を定数として用意します。ここでstruct型でstaticな定数を持たせます。

private struct Ratios {
    static let SkullRadiusToEyeOffSet: CGFloat = 3
    static let SkullRadiusToEyeRadius: CGFloat = 10
    static let SkullRadiusToMouthWidth: CGFloat = 1
    static let SkullRadiusToMouthHeight: CGFloat = 3
    static let SkullRadiusToMouthOffset: CGFloat = 3
}

目の位置は、skullRadiusをSkullRadiusToOffSet値で割った値(1/3)で位置を動かして、SkullRasiusをSkullRadiusToEyeRadiusで割った値(1/10)の半径の円を目にします。

目は左と右があるのでenumで区別します。

private enum Eye {
    case Left
    case Right
}

さて、ここからiOS 9開発講座とiOS 10開発講座でアプローチが異なります。明らかにiOS 10講座のコーディングの方がわかりやすいですね。古い記述も残しますが、興味が無ければ飛ばしましょう。

iOS 9講座の頃は顔の輪郭と目を作るメソッドを同じにしていました。

let path = UIBezierPath(arcCenter: midPoint,
                           radius: radius,
                       startAngle: 0.0,
                         endAngle: 2 * CGFloat.pi,
                        clockwise: false )

このメソッドをskullとeyeの両方に共通して使えるようにします。

まず新しいカスタムメソッドを用意します。

private func pathForCircleCenteredAtPoint(
                          midPoint: CGPoint, 
                        withRadius: CGFloat) -> UIBezierPath
{

}

2つ目のパラメーターの名前の付け方に注目しています。このカスタムメソッドを使う時は次のように自然な英語の読み方ができます。

pathForCircleCenteredAtPoint(skullCenter, 
                             withRadius: skullRadius)

withRadius(半径で)の名前の付け方がナチュラルで分かりやすいと説明しているんですが、このメソッドを実装するときに自然さが損なわれてしまいます。最初次のように記述されました。

private func pathForCircleCenteredAtPoint(midPoint: CGPoint,
                   withRadius: CGFloat) -> UIBezierPath
{
    let path = UIBezierPath(
        arcCenter: midPoint,
        radius: withRadius,
        startAngle: 0.0,
        endAngle: 2 * CGFloat.pi,
        clockwise: false
    )
    path.lineWidth = 5.0
    return path
}

しかし、私のようなコテコテの日本人には気にならない「radius: withRadius」の不自然さに違和感を感じるようで、パラメーター名を変えています。こういう細かな気配りが必要なんです。

private func pathForCircleCenteredAtPoint(midPoint: CGPoint,
                   withRadius radius: CGFloat) -> UIBezierPath
{
    let path = UIBezierPath(
        arcCenter: midPoint,
        radius: radius,
        startAngle: 0.0,
        endAngle: 2 * CGFloat.pi,
        clockwise: false
    )
    path.lineWidth = 5.0
    return path
}

このメソッドを使って、顔の輪郭を作るのと、目を作る時のヘルパーメソッドにします。

private func pathForEye(eye: Eye) -> UIBezierPath
{
    let eyeRadius = skullRadius / 
             Ratios.SkullRadiusToEyeRadius
    let eyeCenter = getEyeCenter(eye)
    return pathForCircleCenteredAtPoint(eyeCenter, 
                               withRadius: eyeRadius)
}

この中にもヘルパーメソッドがあります。「getEye()」メソッドを用意していません。これを次の様に用意します。

private func getEyeCenter(eye: Eye) -> UIBezierPath {
    let eyeOffSet = skullRadius / 
             Ratios.SkullRadiusToEyeOffSet
    var eyeCenter = skullCenter
    eyeCenter.y -= eyeOffSet
    switch eye {
    case .Left: 
        eyeCenter.x -= eyeOffset
    cater .Right:
        eyeCenter.x += eyeOffSet
    }
    return eyeCenter
}

iOS 10開発講座の続きはここからです。目の輪郭を描出するカスタムメソッドを用意します。

private func pathForEye(_ eye: Eye) -> UIBezierPath {
        
}

まず目の位置決めをするためのメソッドを用意します。このメソッドはpathForEye()メソッド内でしか必要ないので中に持たせます。

private func pathForEye(_ eye: Eye) -> UIBezierPath {
    
    func centerOFEye(_ eye: Eye) -> CGPoint {
        let eyeOffset = skullRadius / 
                Ratios.SkullRadiusToEyeOffSet
        var eyeCenter = skullCenter
        eyeCenter.y -= eyeOffset
        eyeCenter.x += ((eye == .left) ? -1 : 1) * eyeOffset
        return eyeCenter
    }
     
}

そして、左右の目をつくるメソッドを作り上げます。

private func pathForEye(_ eye: Eye) -> UIBezierPath {
        
    func centerOfEye(_ eye: Eye) -> CGPoint {
        let eyeOffset = skullRadius / 
                Ratios.SkullRadiusToEyeOffSet
        var eyeCenter = skullCenter
        eyeCenter.y -= eyeOffset
        eyeCenter.x += ((eye == .left) ? -1 : 1) * eyeOffset
        return eyeCenter
    }
        
    let eyeRadius = skullRadius / 
            Ratios.SkullRadiusToEyeRadius
    let eyeCenter = centerOfEye(eye)
        
    let path = UIBezierPath(arcCenter: eyeCenter, 
                               radius: eyeRadius, 
                           startAngle: 0, endAngle: 2 * CGFloat.pi, 
                            clockwise: true)
    path.lineWidth = 5.0
    return path
        
}

これで顔の輪郭と両目を描出するメソッドが用意できたので、draw(_ rect: CGRect)メソッドに加えます。

override func draw(_ rect: CGRect) {
    UIColor.blue.set()
    pathForSkull().stroke()
    pathForEye(.left).stroke()
    pathForEye(.right).stroke()
}

これで次のように目ができました。

f:id:yataiblue:20170315085036j:plain

ここで目をブリンクさせる機能を加えるために目を閉じるためのパブリックプロパティを用意します。

var eyesOpen: Bool = true

このプロパティを使ってブリンクさせる条件をpathForEyeメソッドに加えます。

private func pathForEye(_ eye: Eye) -> UIBezierPath {
        
    func centerOfEye(_ eye: Eye) -> CGPoint {
        let eyeOffset = skullRadius / 
                Ratios.SkullRadiusToEyeOffSet
        var eyeCenter = skullCenter
        eyeCenter.y -= eyeOffset
        eyeCenter.x += ((eye == .left) ? -1 : 1) * eyeOffset
        return eyeCenter
    }
        
    let eyeRadius = skullRadius / 
            Ratios.SkullRadiusToEyeRadius
    let eyeCenter = centerOfEye(eye)
        
    let path: UIBezierPath
        
    if eyesOpen {
        path = UIBezierPath(arcCenter: eyeCenter, 
                               radius: eyeRadius, 
                           startAngle: 0, 
                             endAngle: 2 * CGFloat.pi, 
                            clockwise: true)
    } else {
        path = UIBezierPath()
        path.move(to: CGPoint(x: eyeCenter.x - eyeRadius, 
                              y: eyeCenter.y))
        path.addLine(to: CGPoint(x: eyeCenter.x + eyeRadius, 
                                 y: eyeCenter.y))
    }
    path.lineWidth = 5.0
    return path
        
}

次は口を描出するステップです。

UIBezierCurveを使うのですが、日本語で「ベジェ曲線」のことです。私も深く理解していませんが2点を結んだ線にコントロールポイントを2つ用意して曲線を描画します。次のようなイメージです。

f:id:yataiblue:20170314181050j:plain

ということで、全ての点を長方形の中でコントロールするため、顔の半径を基準として長方形のサイズを決めます。口をつくるメソッドなので次のように書きます。

private func pathForMouth() -> UIBezierPath {
        
    let mouthWidth = skullRadius / 
        Ratios.SkullRadiusToMouthWidth
    let mouthHeight = skullRadius / 
            Ratios.SkullRadiusToMouthHeight
    let mouthOffSet = skullRadius / 
            Ratios.SkullRadiusToMouthOffset
        
    let mouthRect = CGRect(x: skullCenter.x - mouthWidth / 2, 
                           y: skullCenter.y + mouthOffSet, 
                       width: mouthWidth, 
                      height: mouthHeight)
        
    let path = UIBezierPath(rect: mouthRect)
    return path
}

まずスマイル曲線を描く領域の長方形を書いてみます。

override func draw(_ rect: CGRect) {
    UIColor.blue.set()
    pathForSkull().stroke()
    pathForEye(.left).stroke()
    pathForEye(.right).stroke()
    pathForMouth().stroke()
}

そしてランさせると次のようになります。

f:id:yataiblue:20170315085106j:plain

ここにコントロールポイント1,2のY軸方向に(-1.0から1.0)まで動かすことで笑い顔から凹み顔に変化させる変数を用意します。

var mouthCurvature: Double = 1.0

mouthCurvatureの「1.0」はフルスマイルです。

let smileOffSet = 
   CGFloat(max(-1, min(mouthCurvature, 1))) * mouthRect.height

// mouthCurvatureに長方形の高さをかけること高さの範囲内でカーブが動きます。


let mouthRect = CGRect(x: skullCenter.x - mouthWidth / 2,
                       y: skullCenter.y + mouthOffSet, 
                   width: mouthWidth, 
                  height: mouthHeight)

let start = CGPoint(x: mouthRect.minX, y: mouthRect.midY)
let end = CGPoint(x: mouthRect.maxX, y: mouthRect.midY)
let cp1 = 
   CGPoint(x: start.x + mouthRect.width / 3, 
           y: start.y + smileOffSet)
let cp2 = 
   CGPoint(x: end.x - mouthRect.width / 3, 
           y: start.y + smileOffSet)
        
let path = UIBezierPath(rect: mouthRect)
path.move(to: start)
path.addCurve(to: end, controlPoint1: cp1, controlPoint2: cp2)
return path

2年前に取り組んだ時は、min()やmax()の意味も良くわからなかったので、考え込みましたが今やこのコードの意味は簡単に分かります。難しいことをしていないので解説はしません。

これでランすると

f:id:yataiblue:20170315085145j:plain

ベジェ曲線と口の領域がはっきりしたところで、最終的な調整をするのでコードのラスト部分を次のように変更

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

じゃーん、ちゃんとスマイルフェイスを描画できました。

f:id:yataiblue:20170315085548j:plain

最後に@IBDesignable@IBInspectableの説明が入ります。その説明に併せて線の太さや色を変数に変更しています。

*1:2017年3月13日:スタンフォード大学iOS開発講座「Developing iOS 10 Apps with Swift」が公開されたので古い記事をアップデイトします。ここの記事は講義4「Views」の内容です。

*2:実は使えない訳ではありません。「convert」というメソッドを使えば、superviewのcenterを使用することができます。iOS 10講座で「convert(point: CGPoint, from: UIView?)」を使う説明を加えています。

*3:当然「オプショナル型」を使用するという方法もあります。