LoginSignup
8
6

More than 3 years have passed since last update.

お絵描きアプリで良い感じの線を引く今時の方法を調べた

Posted at

はじめに

昨年、自作のお絵かきゲームアプリをリリースしました。
アナリティクスを見ると、残念ながら当初私が考えていたようなゲーム用途としては全く手応えなしだったんですが、
デッサン練習用途としては、少しだけ需要が感じられる状態でございます。
そこで、使っていただいているユーザーの方からapple pencilで線が途切れるとの情報を寄せていただいたので、この機会に、線の引き方について再度調べてみました。

これまで線の引き方については、

KikurageChan様の記事
iOS標準機能の良いお絵描きアプリを目指して・・・
hollymoto様の外部記事
https://anthrgrnwrld.hatenablog.com/entry/2016/07/14/230929

を参考にさせていただき、UIBezierPathを使いタッチ開始/終了の中点を取ってベジェ曲線を描いていたのですが、(紹介されている中では手軽なやり方)
傾けても使えるようになっているapple pencilでは、タッチを拾えないときがあると分かりました。

合体タッチ

調査の結果、Swift 9.0からは合体タッチ(coalescedTouches)と呼ばれる機能が存在し、この機能を使えばapple pencilの傾きを検知し、かつ、滑らかに線が引けることが分かりました。
従来の方法ではタッチイベントのストロークごとに始点と終点が取れるので、間に直線を引くか、中点を自前で計算して、ベジェ曲線を引いていました。
合体タッチではストロークごとのタッチを配列に持ち、複数の制御点を使って線を引ける仕組みであるようです。

1401233.png▶️1401233.png

合体タッチを使うと、特別なことをしなくても複数の制御点が手に入るので、滑らかな線が引けそうですね。
ただ、iOSデバイスのタッチスキャン性能に影響されます。またやはり、指よりもapple pencilの方が滑らかです。
一応、iPhone 5sで指で描くことを試してみても、そこまで角張った線にはならなかったので、
iOS9.0以上が使えるデバイスであれば、採用を検討できるのではないかと思います。

サンプルコード


    private func drawStroke(context: CGContext, touch: UITouch) {

        let previousLocation = touch.previousLocation(in: self)
        let location = touch.location(in: self)

        drawColor.setStroke()
        context.setLineWidth(lineWidth)
        context.setLineCap(.round)
        context.move(to: CGPoint(x: previousLocation.x, y: previousLocation.y))
        context.addLine(to: CGPoint(x: location.x, y: location.y))
        context.strokePath()

    }

    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first else { return }

        guard let canvas = self.imageView.image else {
            return
        }
        UIGraphicsBeginImageContextWithOptions(canvas.size, false, 0.0)
        guard let context = UIGraphicsGetCurrentContext() else {
            return
        }

        let imgCanvasRect = CGRect(x: 0, y: 0, width: canvas.size.width, height: canvas.size.height)

        tmpImage?.draw(in: imgCanvasRect)

        var touches = [UITouch]()
        //合体タッチ取得
        if let coalescedTouches = event?.coalescedTouches(for: touch) {
            touches = coalescedTouches
        } else {
            touches.append(touch)
        }
        for touch in touches {
            drawStroke(context: context, touch: touch)
        }

        //合体タッチで描いた画像の取得
        tmpImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return tmpImage
    }

本来、タッチイベントはUIImageView上で取得したかったのですが、
iOS13.0からのデフォルトであるSwiftUIを雛形にするプロジェクトでは
AppDelegateからViewControllerを起動したとき、
なぜかUIImageView上でのtouchesMovedイベントが発生しない状態になりました。。
UIView上でならば、上記で動くことを確認しました。
しかし毎回大きく変わるなぁ・・・。:sweat:

予測タッチ

正直、自分ではドキュメントを見ても仕組みがわからないのですが、
iOS9.0以降、予測タッチという機能が使えるようです。
タッチに対する画面レンダリングの遅延をカバーするため、
デバイスに蓄積された予測データに基づき、まだタッチされていない箇所に、あらかじめ線を引いているらしいです。
描く速度が早すぎて描画できないという事態を防ぐ手段と理解しました。

予測タッチを採用する場合、タッチのレンダリングが正しく行われた場合は
予測分の描画を破棄する必要が出てきます。
次のサンプルコードでは、合体タッチの線 + 予測タッチの線を画像として保存しておき、
タッチイベントが完了した時は合体タッチの線のみの画像で上書きすることで予測の破棄を行っています。


  override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first else { return }

        guard let canvas = self.imageView.image else {
            return
        }
        UIGraphicsBeginImageContextWithOptions(canvas.size, false, 0.0)
        guard let context = UIGraphicsGetCurrentContext() else {
            return
        }

        let imgCanvasRect = CGRect(x: 0, y: 0, width: canvas.size.width, height: canvas.size.height)

        tmpImage?.draw(in: imgCanvasRect)

        var touches = [UITouch]()
        if let coalescedTouches = event?.coalescedTouches(for: touch) {
            touches = coalescedTouches
        } else {
            touches.append(touch)
        }
        for touch in touches {
            drawStroke(context: context, touch: touch)
        }
        //合体タッチで描いた画像の取得
        tmpImage = UIGraphicsGetImageFromCurrentImageContext()
        //予測タッチ取得
        if let predictedTouches = event?.predictedTouches(for: touch) {
          for touch in predictedTouches {
            drawStroke(context: context, touch: touch)
          }
        }
        //合体タッチ + 予測タッチで描いた画像の取得
        self.imageView.image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        //タッチイベント完了時、合体タッチのみの画像で上書きする
        self.imageView.image = self.tmpImage
    }

    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
        //タッチイベントキャンセル時、合体タッチのみの画像で上書きする
        self.imageView.image = self.tmpImage
    }

サンプルコード・解説ともにほとんど
https://www.raywenderlich.com/121834/apple-pencil-tutorial
の翻訳なので、さらに詳しく知りたい方はこちらをご確認ください。

UIBezierPathも使ってみる

合体タッチ・予測タッチとも、制御点同士の間の線を直線で描いています。
制御点が多く取れる場合は良いですが、少ないと角ばる可能性が出てきます。
そこで、制御点同士の間の線をcontext.addLineの代わりに、UIBezierPathで描いてみるのを試してみました。コードは後述のGitHubにあります。
動かしてiPhone5s上で幾つも丸を描きましたが、私の絵心がないせいか、違いはほとんど分かりませんでした。
ただ理屈上はわずかでも滑らかになっているはずで、今後自分のアプリでも使ってみたいと思います。

PencilKitだ…と?

ここまで書いて何ですが、iOS13.0からはPencilKitというものが導入されていて、
iOSメモ帳で使えるペンのパレットツールが、簡単に実装できるようです。

niwasawa様の記事
たった3行のコードで PencilKit を導入して Apple Pencil 対応

これから作るアプリとかこれで良さそう。。
まだ自分で使っていないので分からないのですが、もしカスタイマイズが難しいのであれば
本記事の内容も場面によっては役に立てるでしょうか。

おわりに(宣伝)

お絵かきに興味のある方、宜しければ冒頭のアプリ、日々のデッサン練習に使ってみてください。
絵が上手くなって第二の鬼滅の刃描いちゃってください!
私としても、まだもう少し使い勝手を向上させて行きたいと思います。

記事内のサンプルコードをGitHubにあげました。
参考にして頂けましたら幸いです。

8
6
0

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
8
6