MILLEN BOX

音楽好きの元組み込みソフトエンジニアによるプログラミング(主にiOSアプリ開発)の勉強の記録

Swiftでお絵描きアプリを作成する(第1回お絵描きの実装) [UIScrollView][UIGestureRecognizer][UIBezierPath][swift2.2]

現在作成中のアプリでお絵描き部分の実装しておりまして、その記録を記事として残しておこうと思います。
計3回くらいでまとめようと思っています。
少し高機能な部分はこんな感じ!

  • 拡大が可能!
  • ペンの色、太さが変更可能!(次回以降)
  • Redo/Undoが可能!(次回以降)
  • 描いた絵の保存が可能!(次回以降)
     

Githubは以下です。今回はお絵描き部分のみとしています。下記の解説と合わせてご確認下さい。

github.com

f:id:anthrgrnwrld:20160714230449g:plain

(続き書きました!)
anthrgrnwrld.hatenablog.com

anthrgrnwrld.hatenablog.com

お絵描きアプリの仕組み

お絵描きアプリの仕組みですが、そんなに難しいものではありません。
以下のような流れです。(色太さ変更とRedo/Undoについては次回説明します。)

  1. お絵描きを表示するViewを準備する (UIImageView)
  2. 絵を書くスペース(=キャンバス)を準備する (キャンバスはUIImage)
  3. 画法(=鉛筆を使う?それとも筆?みたいなイメージ...)を決定し準備する (今回はUIBezierPath)
  4. タッチした座標を保存する
  5. (保存した座標を使って)キャンバスに線を描画する
  6. 線が描画されたキャンバスをUIImageとして保存し、それを手順1で準備したUIImageView.imageと置き換える
  7. 手順4から手順7を繰り返す

上記の流れが頭に入っているといないのとでは、以降の説明の理解に差が出てくると思います。
説明を見ながら、見直したりしながら理解を深めて下さい。

1. お絵描きを表示するViewを準備する

お絵描きを表示するためのUIImageViewを準備するわけですがその前に説明しておかなければいけないことがあります。
今回は 拡大可能なお絵描きアプリを作成しようと考えていますが、実現の方法としてUIScrollViewを使う必要があります。
 
普通UIImageViewを置く場合だとViewController → View → UIImageViewというLayerの順番で置くと思います。
しかし拡大を可能にするためにはどのうようにすれば良いか。
それはViewとUIImageViewの間にScrollViewを挟むのです。
つまりViewController → View → ScrollView → UIImageViewという順番でViewを配置します。
配置にはStoryboardを使用するのがやっぱり便利です。
ScrollView、UIImageView共に画面一杯に広げましょう。
多デバイスと表示の互換性を守るのであれば、AutoLayoutの制約の設定を忘れずに。
 
Viewの配置が済んだならば、次にいつものようにStoryboard上のViewからControlを押しながらViewをビューっと伸ばして下さい。
私は以下のような感じで名前をつけました。

@IBOutlet weak var scrollView: UIScrollView!
@IBOutlet weak var canvasView: UIImageView!

次に継承するクラスとしてUIScrollViewDelegateを追加します。

class ViewController: UIViewController, UIScrollViewDelegate {  //UIScrollViewDelegateを追加

次にviewDidLoad内に拡大に関する条件等を追記します。

override func viewDidLoad() {
        super.viewDidLoad()
        
    scrollView.delegate = self
    scrollView.minimumZoomScale = 1.0                   // 最小拡大率
    scrollView.maximumZoomScale = 4.0                   // 最大拡大率
    scrollView.zoomScale = 1.0                          // 表示時の拡大率(初期値)
}

次にviewForZoomingInScrollViewメソッドをOverrideし、Returnにて拡大させるViewを返します。
今回の場合にはcanvasViewを返します。
(因みにviewForZoomingInScrollViewを追加しないと拡大出来ませんのでご注意下さい。)

/**
 拡大縮小に対応
 */
func viewForZoomingInScrollView(scrollView: UIScrollView) -> UIView? {
    return self.canvasView
}

2. 絵を書くスペース(=キャンバス)を準備する

絵を書く為にはキャンバスを用意する必要があります。
今回の場合、それはUIImageになります。
今回は以下のようなキャンバス作成用関数を作成してみました。

/**
 キャンバスの準備 (何も描かれていないUIImageの作成)
 */
func prepareCanvas() {
    let canvasSize = CGSizeMake(view.frame.width * 2, view.frame.width * 2)     //キャンバスのサイズの決定
    let canvasRect = CGRectMake(0, 0, canvasSize.width, canvasSize.height)      //キャンバスのRectの決定
    UIGraphicsBeginImageContextWithOptions(canvasSize, false, 0.0)              //コンテキスト作成(キャンバスのUIImageを作成する為)
    var firstCanvasImage = UIImage()                                            //キャンバス用UIImage(まだ空っぽ)
    UIColor.whiteColor().setFill()                                              //白色塗りつぶし作業1
    UIRectFill(canvasRect)                                                      //白色塗りつぶし作業2
    firstCanvasImage.drawInRect(canvasRect)                                     //firstCanvasImageの内容を描く(真っ白)
    firstCanvasImage = UIGraphicsGetImageFromCurrentImageContext()              //何も描かれてないUIImageを取得
    canvasView.contentMode = .ScaleAspectFit                                    //contentModeの設定
    canvasView.image = firstCanvasImage                                         //画面の表示を更新
    UIGraphicsEndImageContext()                                                 //コンテキストを閉じる
}

(少し解説)
行っていることとしては真っ白に塗りつぶしたUIImageを作成し、そのUIImageをcanvasView.imageに入れるということ。
UIImageの塗りつぶしについては以下も見てみてください。

anthrgrnwrld.hatenablog.com

サイズを (view.frame.width * 2, view.frame.width * 2) としている理由を2つの観点から説明します。
まず一つ目。
viewのwidthをx2している理由ですが、その方が滑らかな線が描けるからです。
現実に置き換えたらわかりやすいです。
大きな画用紙に絵を書いた方が、小さなチラシの裏に書いたものより綺麗な線が描けますよね?
ただし注意点があります。
x2していることにより、描画の処理がおも〜くなります。
実用を考えるとシンプルにx1とする方がいいですが、viewと異なるサイズにすることで少しややこしくなり勉強になる為、今回はx2としています。
そして二つ目。
サイズを正方形にしている理由ですが、特に意味はありません。
...ということは無くて、viewと異なるアスペクトにする方が少しややこしくなり勉強になる為、このようにしています。
 
またUIGraphicsBeginImageContextWithOptionsの部分はUIGraphicsBeginImageContextに置き換えることが出来ます。
UIGraphicsBeginImageContextWithOptionsを使った方が滑らかな線を描くことが出来ます。
UIGraphicsBeginImageContextを使った場合には、少々ジャギーになりますが 〜WithOptions と比べると処理が軽いです。

3. 画法を決定し準備する

画法みたいなものを決定する必要があります。
ここで言う画法とは「鉛筆を使う?それとも筆?」みたいなイメージです...(分かりにくい説明だ...)。
Drawの方法ですが、今回はUIPanGestureRecognizerとUIBezierPathを使用して行いたいと思います。
異なる方法としてtouchesBegan, touchesMoved, CGContextMoveToPoint, CGContextAddLineToPointを使用する方法もあります。
UIPanGestureRecognizerとUIBezierPathを使用した方法を選択した理由としては、こちらの方が高品位(=滑らか)な描画が出来るからです。(その分少し難しいですが...)
またCGContextMoveToPoint, CGContextAddLineToPointを使用した場合、次回以降で投稿予定の 部分的な色変更や線の太さの変更 を行うことが非常に面倒になります。
 
手順2で作成した関数prepareCanvas()と合わせて以下のような関数prepareDrawing()を作成しました。
let myDraw = UIPanGestureRecognizer(target: self, action: #selector(ViewController.drawGesture(_:))) はタッチ動作をした時に呼び出されるselectorとしてdrawGesture()という関数を指定します。
drawGesture()については次の手順で作成しますので少々お待ちを。

/**
 UIGestureRecognizerでお絵描き対応。1本指でなぞった時のみの対応とする。
 */
private func prepareDrawing() {
        
    //実際のお絵描きで言う描く手段(色えんぴつ?クレヨン?絵の具?など)の準備
    let myDraw = UIPanGestureRecognizer(target: self, action: #selector(ViewController.drawGesture(_:)))
    myDraw.maximumNumberOfTouches = 1
    self.scrollView.addGestureRecognizer(myDraw)
        
    //実際のお絵描きで言うキャンバスの準備 (=何も描かれていないUIImageの作成)
    prepareCanvas()
        
}

このprepareDrawing()をviewDidLoad()内で呼び出してあげれば、お絵描き準備は一先ず完了です。

override func viewDidLoad() {
        super.viewDidLoad()
        
    scrollView.delegate = self
    scrollView.minimumZoomScale = 1.0                   // 最小拡大率
    scrollView.maximumZoomScale = 4.0                   // 最大拡大率
    scrollView.zoomScale = 1.0                          // 表示時の拡大率(初期値)

    prepareDrawing()                                    //お絵描き準備
}

4. タッチした座標を保存する

タッチ動作をした際に呼び出される関数drawGesture()を作成します。
呼び出された際タッチ座標を保存するようにします。
保存用としてグローバルな変数として lastPoint という変数と用意しておきます。

var lastPoint: CGPoint?                 //直前のタッチ座標の保存用

また引数のsenderから「タッチの状態」を取得できます。
取得出来る状態とはタッチされた直後(.Began) or パン動作している最中(.Changed) or 画面から指を離した(.Ended) の3つとして今回は進めます。
その各状態で座標を取得しておきます。

/**
 draw動作
 */
func drawGesture(sender: AnyObject) {
        
    guard let drawGesture = sender as? UIPanGestureRecognizer else {
        print("drawGesture Error happened.")
        return
    }

    let touchPoint = drawGesture.locationInView(canvasView)         //タッチ座標を取得
        
    switch drawGesture.state {
    case .Began:
        lastPoint = touchPoint                                      //タッチ座標をlastTouchPointとして保存する

    case .Changed:
            
        let newPoint = touchPoint                                   //タッチポイントを最新として保存
        lastPoint = newPoint                                        //Point保存
            
    case .Ended:
        print("Finish dragging")
            
    default:
        ()
    }
        
}

5. キャンバスに線を描画する

線を描画します。
線をどのように描画するか。
簡単に言うとタッチのstateが.Changedの時に直前のタッチ座標から現在のタッチ座標まで線を引き、それをstateが.Changedである限り連続して行うのです。
.Changedの時に実行する処理を関数drawGestureAtChanged()で纏めました。
線についてはtouchPointとlastPointからmiddlePointを算出し、これからaddQuadCurveToPointを使って曲線として線を描いています。(この方が高品位になるからです。)
(またこの際にタッチ座標に対し、convertPointForCanvasSize()という関数を実行することで座標を変換しています(後述)。)

そしてキャンバスと同じサイズのコンテキストを準備し、元画像(直前のキャンバス画像)を写します。
そして[UIColor].setStroke()とbezierPath.stroke()を実行することで元のコンテキストに線が描画されます。

/**
 UIGestureRecognizerのStatusが.Changedの時に実行するDraw動作
     
 - parameter canvas : キャンバス
 - parameter lastPoint : 最新のタッチから直前に保存した座標
 - parameter newPoint : 最新のタッチの座標座標
 - parameter bezierPath : 線の設定などが保管されたインスタンス
 */
func drawGestureAtChanged(canvas: UIImage, lastPoint: CGPoint, newPoint: CGPoint, bezierPath: UIBezierPath) {
        
    //最新のtouchPointとlastPointからmiddlePointを算出
    let middlePoint = CGPointMake((lastPoint.x + newPoint.x) / 2, (lastPoint.y + newPoint.y) / 2)
        
    //各ポイントの座標はscrollView基準なのでキャンバスの大きさに合わせた座標に変換しなければいけない
    //各ポイントをキャンバスサイズ基準にConvert
    let middlePointForCanvas = convertPointForCanvasSize(originalPoint: middlePoint, canvasSize: canvas.size)
    let lastPointForCanvas   = convertPointForCanvasSize(originalPoint: lastPoint, canvasSize: canvas.size)
        
    bezierPath.addQuadCurveToPoint(middlePointForCanvas, controlPoint: lastPointForCanvas)  //曲線を描く
    UIGraphicsBeginImageContextWithOptions(canvas.size, false, 0.0)                 //コンテキストを作成
    let canvasRect = CGRectMake(0, 0, canvas.size.width, canvas.size.height)        //コンテキストのRect
    self.canvasView.image?.drawInRect(canvasRect)                                   //既存のCanvasを準備
    drawColor.setStroke()                                                           //drawをセット
    bezierPath.stroke()                                                             //draw実行
    UIGraphicsEndImageContext()                                                     //コンテキストを閉じる
}

drawGestureAtChanged()をdrawGesture()内のstateが.Changedの時に実行させます。 実行に当たってどのような色・太さで線を記載するのか保存するグローバルな変数を準備しておきます。
また今回はUIBezierPathというクラスを使用して線を描画しますが、それについてもグローバルな形で準備しておきます。

var lineWidth: CGFloat?                 //描画用の線の太さの保存用
var drawColor = UIColor()               //描画色の保存用
var bezierPath = UIBezierPath()         //お絵描きに使用

今回の線の太さですが、10.0として予め以下のように設定しております。

let defaultLineWidth: CGFloat = 10.0    //デフォルトの線の太さ
/**
 draw動作
 */
func drawGesture(sender: AnyObject) {
        
    guard let drawGesture = sender as? UIPanGestureRecognizer else {
        print("drawGesture Error happened.")
        return
    }
        
    guard let canvas = self.canvasView.image else {
        fatalError("self.pictureView.image not found")
    }


    let touchPoint = drawGesture.locationInView(canvasView)         //タッチ座標を取得
        
    switch drawGesture.state {
    case .Began:
        lastPoint = touchPoint                                      //タッチ座標をlastTouchPointとして保存する

        //touchPointの座標はscrollView基準なのでキャンバスの大きさに合わせた座標に変換しなければいけない
        //LastPointをキャンバスサイズ基準にConvert
        let lastPointForCanvasSize = convertPointForCanvasSize(originalPoint: lastPoint!, canvasSize: canvas.size)
            
        bezierPath.lineCapStyle = .Round                            //描画線の設定 端を丸くする
        bezierPath.lineWidth = defaultLineWidth                     //描画線の太さ
        bezierPath.moveToPoint(lastPointForCanvasSize)

    case .Changed:
            
        let newPoint = touchPoint                                   //タッチポイントを最新として保存

        //Draw実行
        drawGestureAtChanged(canvas, lastPoint: lastPoint!, newPoint: newPoint, bezierPath: bezierPath)

        lastPoint = newPoint                                        //Point保存
            
    case .Ended:
        print("Finish dragging")
            
    default:
        ()
    }
        
}

6. 線が描画されたキャンバスをUIImageとして保存し

手順5まで適応しプロジェクトを実行してみましょう。
...何も描画されませんよね?
それは 手順6で描画したコンテキストをUIImageに変換し、そのUIImageをcanvasView.imageに対し上書きしていないから です。
私たちの目に見える形にしなければならないということです。
上記を考慮するとdrawGestureAtChanged()とdrawGesture()は以下のようになります。

/**
 UIGestureRecognizerのStatusが.Changedの時に実行するDraw動作
     
 - parameter canvas : キャンバス
 - parameter lastPoint : 最新のタッチから直前に保存した座標
 - parameter newPoint : 最新のタッチの座標座標
 - parameter bezierPath : 線の設定などが保管されたインスタンス
 - returns : 描画後の画像
 */
func drawGestureAtChanged(canvas: UIImage, lastPoint: CGPoint, newPoint: CGPoint, bezierPath: UIBezierPath) -> UIImage {
        
    //最新のtouchPointとlastPointからmiddlePointを算出
    let middlePoint = CGPointMake((lastPoint.x + newPoint.x) / 2, (lastPoint.y + newPoint.y) / 2)
        
    //各ポイントの座標はscrollView基準なのでキャンバスの大きさに合わせた座標に変換しなければいけない
    //各ポイントをキャンバスサイズ基準にConvert
    let middlePointForCanvas = convertPointForCanvasSize(originalPoint: middlePoint, canvasSize: canvas.size)
    let lastPointForCanvas   = convertPointForCanvasSize(originalPoint: lastPoint, canvasSize: canvas.size)
        
    bezierPath.addQuadCurveToPoint(middlePointForCanvas, controlPoint: lastPointForCanvas)  //曲線を描く
    UIGraphicsBeginImageContextWithOptions(canvas.size, false, 0.0)                 //コンテキストを作成
    let canvasRect = CGRectMake(0, 0, canvas.size.width, canvas.size.height)        //コンテキストのRect
    self.canvasView.image?.drawInRect(canvasRect)                                   //既存のCanvasを準備
    drawColor.setStroke()                                                           //drawをセット
    bezierPath.stroke()                                                             //draw実行
    let imageAfterDraw = UIGraphicsGetImageFromCurrentImageContext()                //Draw後の画像
    UIGraphicsEndImageContext()                                                     //コンテキストを閉じる

    return imageAfterDraw
}
/**
 draw動作
 */
func drawGesture(sender: AnyObject) {
                
    guard let drawGesture = sender as? UIPanGestureRecognizer else {
        print("drawGesture Error happened.")
        return
    }
        
    guard let canvas = self.canvasView.image else {
        fatalError("self.pictureView.image not found")
    }


    let touchPoint = drawGesture.locationInView(canvasView)         //タッチ座標を取得
        
    switch drawGesture.state {
    case .Began:
        lastPoint = touchPoint                                      //タッチ座標をlastTouchPointとして保存する

        //touchPointの座標はscrollView基準なのでキャンバスの大きさに合わせた座標に変換しなければいけない
        //LastPointをキャンバスサイズ基準にConvert
        let lastPointForCanvasSize = convertPointForCanvasSize(originalPoint: lastPoint!, canvasSize: canvas.size)
            
        bezierPath.lineCapStyle = .Round                            //描画線の設定 端を丸くする
        bezierPath.lineWidth = defaultLineWidth                     //描画線の太さ
        bezierPath.moveToPoint(lastPointForCanvasSize)

    case .Changed:
            
        let newPoint = touchPoint                                   //タッチポイントを最新として保存

        //Draw実行しDraw後のimage取得
        let imageAfterDraw = drawGestureAtChanged(canvas, lastPoint: lastPoint!, newPoint: newPoint, bezierPath: bezierPath)

        self.canvasView.image = imageAfterDraw                      //Draw画像をCanvasに上書き
        lastPoint = newPoint                                        //Point保存
            
    case .Ended:
        print("Finish dragging")
            
    default:
        ()
    }
        
}

理解が深くなるように、わざと少しややこしい設計にしています。
次回は今回作成したお絵描きアプリにペンの色や太さを変更する機能を追加しようと思います。

 
(続き書きました!)
anthrgrnwrld.hatenablog.com

anthrgrnwrld.hatenablog.com

anthrgrnwrld.hatenablog.com