Edited at
SwiftDay 14

iOS標準機能の良いお絵描きアプリを目指して・・・

More than 1 year has passed since last update.


はじめに

こんにちは:leaves:

iOS標準UIViewのdraw(_ rect: CGRect)等を利用したお絵描きアプリについて書いてみたいと思います。

昔からお絵描きアプリはストアにも多く出ていますし、実際に機能などを頑張って開発してリリースしていても

なかなかDL数は伸びないもので、悲しくなったりします。

そんなお涙頂戴なスタートですが、私の好きなQiitaに記事を書いて、私なりの頑張りを挙げてみたいと思います。見ていただけたら本当に嬉しい限りです。


iOSのお絵描きアプリには素晴らしいものがたくさん

Pixiv様の「Pixiv Sketch



urecy様の「Let's Draw お絵描きアプリ無料版 - お絵かき&写真に落書き



CELSYS様の「kakooyo! – 楽しく描ける無料お絵かきアプリ



MediBang様の「メディバンペイント 漫画イラスト・簡単お絵かきアプリ



iOS標準の機能でどれだけ作られてるかは分かりませんが、

どれも機能が強力で、自分の出る幕が無いなと思ってしまいます(;ω;)


iOSのDrawアプリの基本

検索するといろいろ出てくるのですが、私の知っている中では以下のものがあります。


  • UIViewのdraw(_ rect: CGRect)でUIBezierPathを描画する方法

  • UIImageViewのimageプロパティを入れ替えて描画する方法

  • UIViewのlayerの下にCALayerを追加して描画する方法

描画速度などについてmarty-suzuki様がQiita記事でまとめてありましたので

とても参考になります。


【iOS】drawRect、CALayer、UIImageViewで描画速度を検証してみた



UIViewのdraw(_ rect: CGRect)でUIBezierPathを描画する方法

import UIKit

final class DrawView: UIView {

var penColor = UIColor.black
var penSize: CGFloat = 6.0
private var path: UIBezierPath!
private var lastDrawImage: UIImage!

override func draw(_ rect: CGRect) {
lastDrawImage?.draw(at: CGPoint.zero)
penColor.setStroke()
path?.stroke()
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let currentPoint = touches.first!.location(in: self)
path = UIBezierPath()
path?.lineWidth = penSize
path?.lineCapStyle = CGLineCap.round
path?.lineJoinStyle = CGLineJoin.round
path?.move(to: currentPoint)
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
let currentPoint = touches.first!.location(in: self)
path?.addLine(to: currentPoint)
setNeedsDisplay()
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
let currentPoint = touches.first!.location(in: self)
path.addLine(to: currentPoint)
lastDrawImage = snapShot()
setNeedsDisplay()
}

func snapShot() -> UIImage {
UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0.0)
lastDrawImage?.draw(at: CGPoint.zero)
penColor.setStroke()
path?.stroke()
let image = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext()
return image
}
}


UIImageViewのimageプロパティを利用した方法

import UIKit

final class DrawView: UIImageView {

var penColor = UIColor.black
var penSize: CGFloat = 6.0
private var path: UIBezierPath!
private var lastDrawImage: UIImage!

override init(frame: CGRect) {
super.init(frame: frame)
self.isUserInteractionEnabled = true
}

required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.isUserInteractionEnabled = true
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let currentPoint = touches.first!.location(in: self)
path = UIBezierPath()
path?.lineWidth = penSize
path?.lineCapStyle = CGLineCap.round
path?.lineJoinStyle = CGLineJoin.round
path?.move(to: currentPoint)
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
let currentPoint = touches.first!.location(in: self)
path?.addLine(to: currentPoint)
drawLine()
}

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
let currentPoint = touches.first!.location(in: self)
path.addLine(to: currentPoint)
drawLine()
lastDrawImage = self.image
}

func drawLine() {
UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0.0)
lastDrawImage?.draw(at: CGPoint.zero)
penColor.setStroke()
path?.stroke()
self.image = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext()
}
}


UIViewのlayerの下にCALayerを追加して描画する方法

import UIKit

final class DrawView: UIView {

var penColor = UIColor.black
var penSize: CGFloat = 6.0
private var path = UIBezierPath()
private var drawLayer: CAShapeLayer!

required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
drawLayer = CAShapeLayer()
drawLayer.frame = bounds
drawLayer.strokeColor = penColor.cgColor
drawLayer.fillColor = UIColor.clear.cgColor
drawLayer.lineWidth = penSize
drawLayer.lineCap = kCALineCapRound
drawLayer.lineJoin = kCALineCapRound
layer.addSublayer(drawLayer)
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let currentPoint = touches.first!.location(in: self)
path.move(to: currentPoint)
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
let currentPoint = touches.first!.location(in: self)
path.addLine(to: currentPoint)
drawLayer.path = path.cgPath
}

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
let currentPoint = touches.first!.location(in: self)
path.addLine(to: currentPoint)
drawLayer.path = path.cgPath
}
}


次々に浮上する描画に関する問題たち

私が作成したアプリではUIImageViewを利用しました。

実装をしていくにあたって様々な問題と出会いました。それを以下に書いてみたいと思います。

以下では、UIImageViewの方法のコードを書き直します。


1.線がカクカクする問題

UIBezierPathをタッチイベントのまま追加して描画をすると、画面更新のフレームレートの影響によって線がカクついてしまいます。(ゆっくり描けば大丈夫ですが...)

このカクカク問題は2次ベジエ曲線を利用することにより解決できそうです。


参考(クラスメソッド様)

[iOSアプリ開発] ちょっとなめらかな線でお絵かきしてみる


override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {

let currentPoint = touches.first!.location(in: self)
let previousPoint = touches.first!.previousLocation(in: self)
let middlePoint = CGPoint(x: (currentPoint.x + previousPoint.x) * 0.5, y: (currentPoint.y + previousPoint.y) * 0.5)
path.addQuadCurve(to: middlePoint, controlPoint: previousPoint)
drawLine()
}


2.線が欠けてしまう問題

上の記事は、もちろん素晴らしい実装ですが、線を垂直に向けて同じ場所に戻るように線を引いた時に、線の末端が欠けてしまう現象があります。

この問題を解決するためにaddCurve(to endPoint: CGPoint, controlPoint1: CGPoint, controlPoint2: CGPoint)を利用した3次ベジェ曲線について私は調べました。


参考(stackoverflow)

http://stackoverflow.com/questions/33538962/differences-between-addcurvetopoint-and-addquadcurvetopoint-drawing-smooth-curve?rq=1


import UIKit

class DrawView: UIImageView {

var penColor = UIColor.black
var penSize: CGFloat = 6
private var path: UIBezierPath?
private var lastDrawImage: UIImage?

private var points: [CGPoint] = []

required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.isUserInteractionEnabled = true
}

override init(frame: CGRect) {
super.init(frame: frame)
self.isUserInteractionEnabled = true
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let currentPoint = touches.first!.location(in: self)
path = UIBezierPath()
path?.lineWidth = penSize
path?.lineCapStyle = .round
path?.lineJoinStyle = .round
points = [currentPoint]
path?.move(to: points[0])
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
let currentPoint = touches.first!.location(in: self)
points.append(currentPoint)
if points.count == 5 {
points[3] = CGPoint(x: (points[2].x + points[4].x) * 0.5, y: (points[2].y + points[4].y) * 0.5)
path?.move(to: points[0])
path?.addCurve(to: points[3], controlPoint1: points[1], controlPoint2: points[2])
points = [points[3], points[4]]
}
drawLine()
}

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
drawLine()
lastDrawImage = self.image
path?.removeAllPoints()
}

func drawLine() {
UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0.0)
lastDrawImage?.draw(at: CGPoint.zero)
penColor.setStroke()
path?.stroke()
self.image = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext()
}
}

touchMovedで、入ってきたポイントを数えて3次ベジエ曲線を描いています。

しかし、タッチイベントで得たポイントをコントロールポイントとして利用しているので、かなり描画に遅れがあり、指を素早く動かすとモッサリ線が追いついて行ったりと描画が安定しません。(※3に続く)


3.描画が遅れてしまう問題

左側が3次ベジエ曲線右側が2次ベジエ曲線です。

同じくらいの指の速さで描いていますが、分かりづらくてごめんなさい(>人<)

※実際は左側の3次ベジエ曲線はもっと描きづらい感じがします

描きづらい問題を解決するために、2つのUIBezierPathを合成して描画するという方法を見つけました。


参考(stackoverflow)

https://stackoverflow.com/questions/34995799/removing-lagging-latency-in-drawing-uibezierpath-smooth-lines-in-swift?noredirect=1&lq=1


import UIKit

final class DrawView: UIImageView {

var penColor = UIColor.black
var penSize: CGFloat = 6
private var path: UIBezierPath!
private var lastDrawImage: UIImage?

private var points = [CGPoint]()
private var temporaryPath: UIBezierPath!

override init(frame: CGRect) {
super.init(frame: frame)
isUserInteractionEnabled = true
}

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

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let currentPoint = touches.first!.location(in: self)
path = UIBezierPath()
path?.lineWidth = penSize
path?.lineCapStyle = CGLineCap.round
path?.lineJoinStyle = CGLineJoin.round
path?.move(to: currentPoint)
points = [currentPoint]
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
let currentPoint = touches.first!.location(in: self)
points.append(currentPoint)
if points.count == 2 {
temporaryPath = UIBezierPath()
temporaryPath?.lineWidth = penSize
temporaryPath?.lineCapStyle = .round
temporaryPath?.lineJoinStyle = .round
temporaryPath?.move(to: points[0])
temporaryPath?.addLine(to: points[1])
image = drawLine()
}else if points.count == 3 {
temporaryPath = UIBezierPath()
temporaryPath?.lineWidth = penSize
temporaryPath?.lineCapStyle = .round
temporaryPath?.lineJoinStyle = .round
temporaryPath?.move(to: points[0])
temporaryPath?.addQuadCurve(to: points[2], controlPoint: points[1])
image = drawLine()
}else if points.count == 4 {
temporaryPath = UIBezierPath()
temporaryPath?.lineWidth = penSize
temporaryPath?.lineCapStyle = .round
temporaryPath?.lineJoinStyle = .round
temporaryPath?.move(to: points[0])
temporaryPath?.addCurve(to: points[3], controlPoint1: points[1], controlPoint2: points[2])
image = drawLine()
}else if points.count == 5 {
points[3] = CGPoint(x: (points[2].x + points[4].x) * 0.5, y: (points[2].y + points[4].y) * 0.5)
path?.move(to: points[0])
path?.addCurve(to: points[3], controlPoint1: points[1], controlPoint2: points[2])
points = [points[3], points[4]]
image = drawLine()
}
}

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
image = drawLine()
lastDrawImage = image
temporaryPath = nil
}

func drawLine() -> UIImage? {
UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0.0)
lastDrawImage?.draw(at: CGPoint.zero)
penColor.setStroke()
path?.stroke()
temporaryPath?.stroke()
let capturedImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return capturedImage
}
}


4.重くなる問題

指でなぞっている間、1つのPathに何度も移動する座標の情報や直線を引く情報が追加されていくので、描いている間に処理が重くなってしまいます。

そこで、描いている一定の間隔でPathを画像化すれば解決できそうと考えてみました。

※以下6にまとめて記述


5.タッチからすぐ指を離して点が描画されない問題

Pathを画像化して軽くするためにtouchMovedでmove(to:)をしているので、

touchEndedで容易にaddLine(to:)できない(無条件に行うと線が2重に書かれてしまう)

のでフラグで調節することにします。

※以下6にまとめて記述


6.線が2重に描かれてしまう問題

3の問題の解決をしたら今度はこの問題が浮上してしまいました。

ここら辺になってくると、レベルが高く、私自身も挙動を完全に理解しているとは言えないのですが、

重くなってしまう問題や点が描画されない問題は解決してみました。


参考(stackoverflow)

https://stackoverflow.com/questions/35067811/removing-lagging-latency-during-continuous-period-of-drawing-uibezierpath-in-swi


import UIKit

final class DrawView: UIImageView {

var penColor = UIColor.black
var penSize: CGFloat = 6
private var path: UIBezierPath!
private var lastDrawImage: UIImage?

private var temporaryPath: UIBezierPath!
private var points = [CGPoint]()

private var pointCount = 0
private var snapshotImage: UIImage?

private var isCallTouchMoved = false

override init(frame: CGRect) {
super.init(frame: frame)
isUserInteractionEnabled = true
}

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

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let currentPoint = touches.first!.location(in: self)
path = UIBezierPath()
path?.lineWidth = penSize
path?.lineCapStyle = CGLineCap.round
path?.lineJoinStyle = CGLineJoin.round
path?.move(to: currentPoint)
points = [currentPoint]
pointCount = 0
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
isCallTouchMoved = true
pointCount += 1
let currentPoint = touches.first!.location(in: self)
points.append(currentPoint)
if points.count == 2 {
temporaryPath = UIBezierPath()
temporaryPath?.lineWidth = penSize
temporaryPath?.lineCapStyle = .round
temporaryPath?.lineJoinStyle = .round
temporaryPath?.move(to: points[0])
temporaryPath?.addLine(to: points[1])
image = drawLine()
}else if points.count == 3 {
temporaryPath = UIBezierPath()
temporaryPath?.lineWidth = penSize
temporaryPath?.lineCapStyle = .round
temporaryPath?.lineJoinStyle = .round
temporaryPath?.move(to: points[0])
temporaryPath?.addQuadCurve(to: points[2], controlPoint: points[1])
image = drawLine()
}else if points.count == 4 {
temporaryPath = UIBezierPath()
temporaryPath?.lineWidth = penSize
temporaryPath?.lineCapStyle = .round
temporaryPath?.lineJoinStyle = .round
temporaryPath?.move(to: points[0])
temporaryPath?.addCurve(to: points[3], controlPoint1: points[1], controlPoint2: points[2])
image = drawLine()
}else if points.count == 5 {
points[3] = CGPoint(x: (points[2].x + points[4].x) * 0.5, y: (points[2].y + points[4].y) * 0.5)
if points[4] != points[3] {
let length = hypot(points[4].x - points[3].x, points[4].y - points[3].y) / 2.0
let angle = atan2(points[3].y - points[2].y, points[4].x - points[3].x)
let controlPoint = CGPoint(x: points[3].x + cos(angle) * length, y: points[3].y + sin(angle) * length)
temporaryPath = UIBezierPath()
temporaryPath?.move(to: points[3])
temporaryPath?.lineWidth = penSize
temporaryPath?.lineCapStyle = .round
temporaryPath?.lineJoinStyle = .round
temporaryPath?.addQuadCurve(to: points[4], controlPoint: controlPoint)
} else {
temporaryPath = nil
}
path?.move(to: points[0])
path?.addCurve(to: points[3], controlPoint1: points[1], controlPoint2: points[2])
points = [points[3], points[4]]
image = drawLine()
}
if pointCount > 50 {
temporaryPath = nil
snapshotImage = drawLine()
path.removeAllPoints()
pointCount = 0
}
}

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
let currentPoint = touches.first!.location(in: self)
if !isCallTouchMoved { path?.addLine(to: currentPoint) }
image = drawLine()
lastDrawImage = image
temporaryPath = nil
snapshotImage = nil
isCallTouchMoved = false
}

func drawLine() -> UIImage? {
UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0.0)
if snapshotImage != nil {
snapshotImage?.draw(at: CGPoint.zero)
}else {
lastDrawImage?.draw(at: CGPoint.zero)
}
penColor.setStroke()
path?.stroke()
temporaryPath?.stroke()
let capturedImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return capturedImage
}
}


まとめと感想

線を描く機能はいくつかの問題を乗り越えてなんとか実装できました...

しかし、これよりも描きやすいお絵描きアプリはやっぱりたくさんあって

アプリの差別化を図るのは難しい事だなと感じました。

ただ、もし何らかの線を引いたりするようなアプリの実装について少しでもこの記事が参考になれば嬉しく思います。

また、このカレンダーに参加できてとても良かったです。ありがとうございます。