LoginSignup
85
85

More than 5 years have passed since last update.

Swiftで手書きお絵かきアプリを作る

Last updated at Posted at 2015-10-27

New Version(Swift3)

偉そうにこんな記事書いていましたが、snakayama さんがSNDrawという素晴らしいライブラリを作っていたので、それに乗っけてライブラリ化して、Carthageで公開しました。

リポジトリ:ha1f/SNDrawableView

サンプルを実行するとこんな感じになります。
screenshot

SNDrawについて

iOS Developers JAPANというFacebookグループで、以前snakayama さんご本人が紹介されていたライブラリです。
この記事で僕の書いていた拙作と違い、曲線を補間してくれます。すごい。

上のスクショを見ても、曲線がなめらかなのがわかると思います(下にある旧verのスクショに比べて)

SNDrawableView

SNDrawをラップして、Viewにしました。そのついでに、undoなどを追加しました。
ただお絵かき機能を入れたいぐらいなら十分だと思います。

ほぼSatoshi Nakajimaさんのライブラリで、それをラップしただけなので、イニシャルになっているprefixをそのまま残しました。

Carthageでインストールすることができます

github "ha1fha1f/SNDrawableView"

使い方は、

  1. Carthage周りを準備(フレームワークの追加など)
  2. storyboardでUIViewを配置
  3. 配置したUIViewのクラスをDrawableViewにする

だけで描けるようになります。その他プロパティはリポジトリha1fha1f/SNDrawableViewのREADMEに記載してあります

// 設定項目
.lineColor: CGColor
.lineWidth: CGFloat
.numberOfQueuesToUndo: Int

// メソッド
.clear()
.redo()
.undo()
.getImage() -> UIImage!

だいたい名前の通りだと思います。 numberOfQueuesToUndo はundoで戻れる上限を示します。

PRもお待ちしております。

********以下は旧記事です********

概要

iOSで手書き入力を受付け、そのまま表示するプログラムです。
最近の記事で書いてあるのを見たことがなかったので。
CoreGraphicsを用いています。
(2015/11/2 undo機能などを追加し、大幅にコードを修正しました)

完成品 → https://github.com/ha1fha1f/paintPallet

Simulator Screen Shot 2015.10.28 0.44.05.png

この実装では、今後の使い回しのために、メインのViewControllerの上に、自分で描くことのできるビューを全画面にして載せている実装にしています。

まずcontextに描く

CoreGraphics

CG〜〜で始まるものはCoreGraphicsの略で、C言語で実装されているらしく、呼び出しはやや煩雑ですが高速に動作するようです。

最近は更に高速なMetalというのが出たらしい?ので今度使ってみます

サンプルコード

ViewController.swift

import UIKit

class ViewController: UIViewController {

    var drawableView: DrawableView! = nil

    override func viewDidLoad() {
        super.viewDidLoad()

        self.view.backgroundColor = UIColor.blackColor()

        if drawableView == nil {
            drawableView = DrawableView(frame: CGRectMake(0, 100, self.view.bounds.width, self.view.bounds.width))
            drawableView.backgroundColor = UIColor.whiteColor()
            self.view.addSubview(drawableView)
        }
    }
}

mainのviewの上に、DrawableViewを乗せているだけです。

DrawableView.swift

import UIKit

class DrawableView: UIView {

    class Line {
        var points: [CGPoint]
        var color :CGColorRef
        var width: CGFloat

        init(color: CGColorRef, width: CGFloat){
            self.color = color
            self.width = width
            self.points = []
        }

        func drawOnContext(context: CGContextRef){
            UIGraphicsPushContext(context)

            CGContextSetStrokeColorWithColor(context, self.color)
            CGContextSetLineWidth(context, self.width)
            CGContextSetLineCap(context, CGLineCap.Round)

            // 2点以上ないと線描画する必要なし
            if self.points.count > 1 {
                for (index, point) in self.points.enumerate() {
                    if index == 0 {
                        CGContextMoveToPoint(context, point.x, point.y)
                    } else {
                        CGContextAddLineToPoint(context, point.x, point.y)
                    }
                }
            }
            CGContextStrokePath(context)

            UIGraphicsPopContext()
        }
    }

    var lines: [Line] = []
    var currentLine: Line? = nil

    // タッチされた
    override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
        let point = touches.first!.locationInView(self)
        currentLine = Line(color: UIColor.redColor().CGColor, width: 5)
        currentLine?.points.append(point)
    }

    // タッチが動いた
    override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent?) {
        let point = touches.first!.locationInView(self)
        currentLine?.points.append(point)
        self.setNeedsDisplay()
    }

    // タッチが終わった
    override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {
        // 2点以上のlineしか保存する必要なし
        if currentLine?.points.count > 1 {
            lines.append(currentLine!)
        }

        currentLine = nil
        self.setNeedsDisplay()
    }

    func resetContext(context: CGContextRef) {
        CGContextClearRect(context, self.bounds)
        if let color = self.backgroundColor {
            color.setFill()
        } else {
            UIColor.whiteColor().setFill()
        }
        CGContextFillRect(context, self.bounds)
    }

    //描画設定
    override func drawRect(rect: CGRect) {

        let context = UIGraphicsGetCurrentContext()

        //画面を一旦初期化
        resetContext(context!)

        // 描き終わったline
        for line in lines {
            line.drawOnContext(context!)
        }

        // 描いてる途中のline
        if let line = currentLine {
            line.drawOnContext(context!)
        }
    }
}

Line

短い直線の組み合わせで、長い曲線を表現しています。
Lineは各タッチ開始〜タッチ終了までのパス情報を保持する構造体です。pointsに適宜座標が入れられます。innerクラスとして定義しています。
drawOnContext()で、pointを順次読み出しながら、lineを描画しています。初期座標からタッチがごくたびにどんどん伸ばしていく感じです。

setNeedsDisplay()、DrawRect()

self.setNeedsDisplay()が呼ばれると、そこからからできるだけ早く描画できるところで描画処理を行います(drawRectメソッドが呼ばれる)
drawRectは標準ではなにもしない関数なので、super.drawRect()を呼ぶ必要はありません。

context

描画対象となるバッファのようなものです。contextは標準ではnilですが、drawRectメソッドが呼ばれる前には、OSが自動的に用意してくれるので、UIGraphicsGetCurrentContext()メソッドで取得するだけで利用できます。

描画処理のたびに全画面のクリア(CGContextClearRect(context, self.bounds))を行っているのは、更新のあった時のみcontextをclearしても、lineを一度描画するたび、交互に今までの線が復活したりまた消えたりしたためです。塗りつぶしても同じでした。今回は毎回の初期化で対処しましたが、原因がわかる方いらっしゃれば教えていただきたいです。

→原因わかりました。backgroundColorを設定していると、自動的に画面が背景色でクリアされるようです。なので、処理ごとに線描画するこの方法であまり間違ってはいなさそうですが、適宜キャッシュした方がいいかもしれません。

undoとかsaveとかloadとかパフォーマンスとか

ViewController.swift
import UIKit

class ViewController: UIViewController, DrawableViewDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate {

    var drawableView: DrawableView! = nil

    var undoButton: UIButton! = nil
    var saveButton: UIButton! = nil
    var clearButton: UIButton! = nil
    var loadButton: UIButton! = nil

    override func viewDidLoad() {
        super.viewDidLoad()

        self.view.backgroundColor = UIColor.blackColor()

        let buttonWidth = self.view.bounds.width/4

        if drawableView == nil {
            drawableView = DrawableView(frame: CGRectMake(0, 100, self.view.bounds.width, self.view.bounds.width))
            drawableView.backgroundColor = UIColor.whiteColor()
            drawableView.delegate = self
            self.view.addSubview(drawableView)
        }

        if undoButton == nil {
            undoButton = UIButton(frame: CGRectMake(0, 0, buttonWidth, 100))
            undoButton.backgroundColor = UIColor.yellowColor()
            undoButton.setTitle("undo", forState: .Normal)
            undoButton.setTitleColor(UIColor.brownColor(), forState: .Normal)
            undoButton.addTarget(drawableView, action: "undo", forControlEvents: .TouchUpInside)
            self.view.addSubview(undoButton)
        }

        if loadButton == nil {
            loadButton = UIButton(frame: CGRectMake(buttonWidth*1, 0, buttonWidth, 100))
            loadButton.backgroundColor = UIColor.orangeColor()
            loadButton.setTitle("load", forState: .Normal)
            loadButton.setTitleColor(UIColor.brownColor(), forState: .Normal)
            loadButton.addTarget(self, action: "load", forControlEvents: .TouchUpInside)
            self.view.addSubview(loadButton)
        }

        if clearButton == nil {
            clearButton = UIButton(frame: CGRectMake(buttonWidth*2, 0, buttonWidth, 100))
            clearButton.backgroundColor = UIColor.greenColor()
            clearButton.setTitle("clear", forState: .Normal)
            clearButton.setTitleColor(UIColor.brownColor(), forState: .Normal)
            clearButton.addTarget(drawableView, action: "clear", forControlEvents: .TouchUpInside)
            self.view.addSubview(clearButton)
        }

        if saveButton == nil {
            saveButton = UIButton(frame: CGRectMake(buttonWidth*3, 0, buttonWidth, 100))
            saveButton.backgroundColor = UIColor.cyanColor()
            saveButton.setTitle("save", forState: .Normal)
            saveButton.setTitleColor(UIColor.brownColor(), forState: .Normal)
            saveButton.addTarget(drawableView, action: "save", forControlEvents: .TouchUpInside)
            self.view.addSubview(saveButton)
        }

    }

    func load() {
        if UIImagePickerController.isSourceTypeAvailable(UIImagePickerControllerSourceType.PhotoLibrary) {
            let ipc: UIImagePickerController = UIImagePickerController()
            ipc.delegate = self
            ipc.allowsEditing = true

            ipc.sourceType = UIImagePickerControllerSourceType.PhotoLibrary

            self.presentViewController(ipc, animated:true, completion:nil)
        }
    }

    func imagePickerController(picker: UIImagePickerController, didFinishPickingImage image: UIImage, editingInfo: [String : AnyObject]?) {
        picker.dismissViewControllerAnimated(true, completion: nil)
        drawableView.setBackgroundImage(image)
    }

    func onUpdateDrawableView() {

    }

    func onFinishSave() {
        let alertController = UIAlertController(title: "Saved!", message: "saved to camera roll.", preferredStyle: .Alert)
        alertController.addAction(UIAlertAction(title: "OK", style: .Default, handler: nil))
        self.presentViewController(alertController, animated: true, completion: nil)
    }
}

先ほどとの違いは、ボタンを配置していることぐらいです。

DrawableView.swift
import UIKit

protocol DrawableViewDelegate {
    func onUpdateDrawableView()
    func onFinishSave()
}

protocol DrawableViewPart {
    func drawOnContext(context: CGContextRef)
}

class DrawableView: UIView {

    class Line: DrawableViewPart {
        var points: [CGPoint]
        var color :CGColorRef
        var width: CGFloat

        init(color: CGColorRef, width: CGFloat){
            self.color = color
            self.width = width
            self.points = []
        }

        func drawOnContext(context: CGContextRef){
            UIGraphicsPushContext(context)

            CGContextSetStrokeColorWithColor(context, self.color)
            CGContextSetLineWidth(context, self.width)
            CGContextSetLineCap(context, CGLineCap.Round)

            // 2点以上ないと線描画する必要なし
            if self.points.count > 1 {
                CGContextMoveToPoint(context, points.first!.x, points.first!.y)
                for (index, point) in self.points.dropFirst().enumerate() {
                    CGContextAddLineToPoint(context, point.x, point.y)
                }
            } else {
                Dot(line: self).drawOnContext(context)
            }
            CGContextStrokePath(context)

            UIGraphicsPopContext()
        }

        // 更新分だけ描画したい時用
        func drawLastlineOnContext(context: CGContextRef) {
            if self.points.count > 1 {
                UIGraphicsPushContext(context)
                CGContextSetStrokeColorWithColor(context, self.color)
                CGContextSetLineWidth(context, self.width)
                CGContextSetLineCap(context, CGLineCap.Round)

                let startPoint = self.points[self.points.endIndex-2]
                let endPoint = self.points.last!
                CGContextMoveToPoint(context, startPoint.x, startPoint.y)
                CGContextAddLineToPoint(context, endPoint.x, endPoint.y)

                CGContextStrokePath(context)
                UIGraphicsPopContext()
            } else if !self.points.isEmpty() {
                Dot(line: self).drawOnContext(context)
            }
        }
    }

    class Dot: DrawableViewPart {
        var pos: CGPoint
        var radius: CGFloat
        var color: CGColorRef

        init(pos: CGPoint, radius: CGFloat, color: CGColorRef) {
            self.radius = radius
            self.pos = pos
            self.color = color
        }

        init(line: Line) {
            self.pos = line.points.first!
            self.radius = line.width
            self.color = line.color
        }

        func drawOnContext(context: CGContextRef){
            UIGraphicsPushContext(context)
            CGContextSetFillColorWithColor(context, self.color)
            CGContextAddEllipseInRect(context, CGRectMake(pos.x-(radius/2), pos.y-(radius/2), radius, radius));
            CGContextFillPath(context);
            UIGraphicsPopContext()
        }
    }

    class Image: DrawableViewPart {
        var image: UIImage
        init(image: UIImage) {
            self.image = image
        }
        func drawOnContext(context: CGContextRef){
            UIGraphicsPushContext(context)
            image.drawInRect(CGRect(origin: CGPointZero, size: image.size))
            UIGraphicsPopContext()
        }
    }

    struct DrawableViewSetting {
        var lineColor: CGColorRef = UIColor.redColor().CGColor
        var lineWidth: CGFloat = 5
    }

    // DrawableViewParts
    var parts: [DrawableViewPart] = []
    // 描画中のLine
    var currentLine: Line? = nil
    // これまでに描画したimage
    private var currentImage: UIImage? = nil

    var delegate:DrawableViewDelegate? = nil

    private var currentSetting = DrawableViewSetting()

    func setLineColor(color: CGColorRef) {
        currentSetting.lineColor = color
    }

    func setLineWidth(width: CGFloat) {
        currentSetting.lineWidth = width
    }

    //初期化
    required init(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)!

        self.parts = [Image(image: UIImage())]
        currentSetting.lineColor = UIColor.redColor().CGColor
        currentSetting.lineWidth = 5
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
    }

    func undo() {
        if !self.parts.isEmpty {
            self.parts.removeLast()
            requireRedraw()
        }
    }

    // タッチされた
    override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
        let point = touches.first!.locationInView(self)
        currentLine = Line(color: self.currentSetting.lineColor, width: self.currentSetting.lineWidth)
        currentLine?.points.append(point)
        self.setNeedsDisplay()
    }

    // タッチが動いた
    override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent?) {
        let point = touches.first!.locationInView(self)
        currentLine?.points.append(point)
        self.setNeedsDisplay()
    }

    // タッチが終わった
    override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {
        if currentLine?.points.count > 1 {
            parts.append(currentLine!)
        } else {
            self.parts.append(Dot(line: currentLine!))
        }

        currentLine = nil
    }

    private func requireRedraw() {
        self.currentImage = nil
        self.setNeedsDisplay()
    }

    // UIImageとして取得
    func getCurrentImage() -> UIImage {
        // nilだったら再度描画させる
        if self.currentImage == nil {
            UIGraphicsBeginImageContextWithOptions(self.bounds.size, false, 0)
            let context = UIGraphicsGetCurrentContext()
            parts.forEach{$0.drawOnContext(context!)}
            currentLine?.drawOnContext(context!)
            self.currentImage = UIGraphicsGetImageFromCurrentImageContext()
            UIGraphicsEndImageContext()
        }
        return self.currentImage!
    }

    func updateCurrentImage() {
        UIGraphicsBeginImageContextWithOptions(self.bounds.size, false, 0)
        let imageContext = UIGraphicsGetCurrentContext()
        // 今までのimageを取得して描画
        self.getCurrentImage().drawInRect(self.bounds)
        // 追加分を描画
        if let line = currentLine {
            line.drawLastlineOnContext(imageContext!)
        }
        // 更新
        self.currentImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
    }

    func clear() {
        self.parts = []
        self.requireRedraw()
    }

    private func getResizedImage(image: UIImage, size: CGSize) -> UIImage {
        UIGraphicsBeginImageContext(size)
        image.drawInRect(CGRect(origin: CGPointZero, size: size))
        let resizedImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return resizedImage
    }

    func setBackgroundImage(image: UIImage) {
        let resizedImage = getResizedImage(image, size: CGSizeMake(self.bounds.width, self.bounds.height))

        let backgroundImage = Image(image: resizedImage)

        if let part = self.parts.first where part is Image {
            self.parts[0] = backgroundImage
        } else {
            self.parts.insert(backgroundImage, atIndex: 0)
        }

        self.requireRedraw()
    }

    func save() {
        // 念のため再描画
        updateCurrentImage()
        UIImageWriteToSavedPhotosAlbum(self.currentImage!, self, "image:didFinishSavingWithError:contextInfo:", nil)

    }

    func image(image: UIImage, didFinishSavingWithError error: NSError!, contextInfo: UnsafeMutablePointer<Void>) {
        if error != nil {
            //プライバシー設定不許可など書き込み失敗時は -3310 (ALAssetsLibraryDataUnavailableError)
            print("DrawableView:Error -> " + String(error.code))
        } else {
            delegate?.onFinishSave()
        }
    }

    //描画設定
    override func drawRect(rect: CGRect) {
        delegate?.onUpdateDrawableView()

        let _ = UIGraphicsGetImageFromCurrentImageContext()

        updateCurrentImage()
        self.currentImage?.drawInRect(self.bounds)
    }
}

Dot

線描画だけでは、タップした時に点が表示されないのは不自然なため、dotを新しく定義しました

DrawableViewPart

Line, Dot以外のパーツも追加する可能性があるため、抽象化したプロパティDrawableViewPartを用意しました。これをimplementしていれば、何でもパーツとして描画できます。
スタンプとかもいいかもしれませんね!

必要最小限の描画

重複した描画した処理を減らすため、適宜UIImageとして保存しながら、新たなcontextを作成し、今までの分と追加分を描画する処理を繰り返しています。

もしかしたらsetNeedsDisplay()が呼ばれてからdrawRectが実行される前に再度setNeedsDisplay()が呼ばれると、表示漏れが発生する可能性がありますので、save時などには再度描画するようにしています。undoでも更新できます。

getImage

現在のDrawableViewの状態をUIImageとして取得できます。
画像用のcontextを別で生成して、描画処理を行っています。これだと保存などもできるはずです。
self.currentImageがnilなら再描画を行っています。

undo

リストから削除した後、再描画を行うことで実現しています。
requireRedraw()はcurrentImageをnilにすることで、次の描画時に再描画を強制しています。

以上で完成です!

85
85
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
85
85