iOS: Apple Pencilから情報を取得する

iOSエンジニアのyoshi.kです。

Apple Pencil、非常に使い心地良いですよね!
私はiPadのマルチタスク機能で、電子書籍アプリやSafariと、メモアプリを同時に開いてノート代わりとしてよく使っています。

ところで、iOSのメモアプリやお絵かきアプリには、よく「Apple Pencil対応」と記載があります。
Apple Pencilを使った際の質圧が検知できる機能など、なんとなく機能的な想像はできますが、
Apple Pencilと指のタッチ操作では取得できる情報にどのような違いがあるのでしょうか?
今回、Apple Pencilで出来ることについて調べてみました。

動作環境

本記事内に記載されているのサンプルコードの動作環境は以下となっています。

  • Xcodeバージョン: 10.1
  • 言語: Swift 4.2
  • 端末:
    • iPad (第6世代)
    • Apple Pencil (第1世代)

1. Apple Pencilからのタッチ情報取得

Apple Pencilからの情報はUITouchとして、アプリに報告されます。
情報の取得に関してもUITouchと同じ方法で取得可能です。

UITouchTypeにUITouchTypePencilが追加されており、ペンかそれ以外のタッチイベントの判別が可能です。

サンプルコード

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    if let touch = touches.first {
        if touch.type == .pencil {
            textLabel.text = "ペン"
        } else {
            textLabel.text = "ペンじゃない"
        }
    }
}

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    textLabel.text = ""
}

override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
    textLabel.text = ""
}

2. Apple Pencilの独自プロパティ

取得したUITouchが
UITouchType = UITouchTypePencil
の場合、UITouchから以下の情報が取得可能です。

高度角: altitudeAngle

端末とペンの角度をラジアン値(CGFloat)で返します。

  • 端末とペンが平行: 0
  • 端末とペンが垂直 : M_PI/2

となります。
ちなみに指でタッチした場合には、1.57固定となりました。

方位角: azimuthAngle(in:), azimuthUnitVector(in:)

ペンの方位角(倒れてる方向)になります。
CGFoatで取得する方法、ベクトルで取得する方法があります。

  • azimuthAngle(in:): ペンの方位角をラジアン値(CGFloat)で返します。

ペンを中心からX軸方向に真横に傾けると 0 となり、
時計周りに回転させると増加し、逆時計回りに回転させると減少します。
X方向から180度回転させた位置で正と負が反転します。

  • azimuthUnitVector(in:): ペンの方位角をベクトルで返します。

力量: force

平均的な力量を1を基準とした、力量を返します。
因みにこのプロパティはApple Pencilの他に3DTouchでもサポートされてます。

サンプル

各値を表示してみました。

サンプルコード

override func viewDidLoad() {
    super.viewDidLoad()
    resetLabel()
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    if let touch = touches.first {
        exploreTouchState(touch: touch)
    }
}

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

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

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

private func exploreTouchState(touch: UITouch) {
    if touch.type == .pencil {
        touchTypeLabel.text = "ペン"
    } else {
        touchTypeLabel.text = "たぶん指(ペン以外)"
    }

    altitudeAngleLabel.text = String(format: "%.2f", touch.altitudeAngle)
    azimuthAngleLabel.text = String(format: "%.2f", touch.azimuthAngle(in: self.view))
    let vector = touch.azimuthUnitVector(in: self.view)
    let x = String(format: "%.2f",  vector.dx)
    let y = String(format: "%.2f", vector.dy)
    azimuthAngleVectorLabel.text = "(\(x), \(y))"
    forceLabel.text = String(format: "%.2f", touch.force)
}

private func resetLabel() {
    touchTypeLabel.text = ""
    altitudeAngleLabel.text = ""
    azimuthAngleLabel.text = ""
    azimuthAngleVectorLabel.text = ""
    forceLabel.text = ""
}

3. 推定値について

記事内で、

情報の取得に関してもUITouchと同じ方法で取得可能です。

と記載していますが、上記の方法で取得した値には、
実値ではなく推定値がUITouchのプロパティに含まれることがあります。

というのも、Apple PencilとiPadは別端末であり、情報の収集、送信に遅延が発生してしまうからです。
正確なUITouch情報を利用する場合には、あとで実値で上書きする必要があります。

estimatedPropertiesExpectingUpdates

直ちに報告できないプロパティ、後で更新が予想されるプロパティの定数のビットマスクが入っています。

例えば力量が推定プロパティかどうか調べる場合には、下記のような形でBool値として判定可能です。

touch.estimatedPropertiesExpectingUpdates.contains(.force)

forcealtitudeazimuthlocationが判定可能です。

estimationUpdateIndex

推定値がある場合に、更新時に同じUITouchか判別するためのキーをします。
推定値がない場合はnilです。

touchesEstimatedPropertiesUpdated(_ touches: Set<UITouch>)

プロパティが推定値から実値にアップデートされた際に呼ばれるメソッドです。
オーバーライドして使いましょう。

サンプル

推定値と実値を表示するサンプルになります。
端末上に表示する方法がパッと思いつかなかったので、ログとして出力してます。

ぱっと見で力量は差が見られますが、他の値は殆ど差が見られませんね。
サンプルではタッチ情報をキャッシュする際に推定値が存在するかしか見てないので、力量以外はリアルタイムで値を取得できているのかもしれません。

サンプルコード

struct EstimateTouch {
    let location: CGPoint
    let force: CGFloat
    let azimuth: CGFloat
    let altitude: CGFloat
}

var estimates : [NSNumber : EstimateTouch] = [:]

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    if let touch = touches.first {
        addEstimateTouch(touch: touch)
    }
}

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

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    if let touch = touches.first {
        addEstimateTouch(touch: touch)
    }
}

override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
    if let touch = touches.first {
        addEstimateTouch(touch: touch)
    }
}

override func touchesEstimatedPropertiesUpdated(_ touches: Set<UITouch>) {
    for touch in touches {
        if touch.estimatedPropertiesExpectingUpdates.contains(.force) ||
            touch.estimatedPropertiesExpectingUpdates.contains(.altitude) ||
            touch.estimatedPropertiesExpectingUpdates.contains(.azimuth) ||
            touch.estimatedPropertiesExpectingUpdates.contains(.location) {
            return
        }

        guard let index = touch.estimationUpdateIndex,
            let estimateTouch = estimates[index]  else {
                return
        }

        print("******* index: \(String(index.intValue)) *******")

        do {
            let estimateValue = String(format: "%f", estimateTouch.force)
            let actualValue = String(format: "%f", touch.force)
            print("force estimate: \(estimateValue) actual:\(actualValue)")
        }

        do {
            let estimateValue = String(format: "%f", estimateTouch.azimuth)
            let actualValue = String(format: "%f", touch.azimuthAngle(in: self.view))
            print("azimuth estimate: \(estimateValue) actual:\(actualValue)")
        }

        do {
            let estimateValue = String(format: "%f", estimateTouch.altitude)
            let actualValue = String(format: "%f", touch.altitudeAngle)
            print("azimuth estimate: \(estimateValue) actual:\(actualValue)")
        }

        do {
            let estimateLocation = estimateTouch.location
            let estimateValueX = String(format: "%f", estimateLocation.x)
            let estimateValueY = String(format: "%f", estimateLocation.y)

            let location = touch.location(in: self.view)
            let actualValueX = String(format: "%f", location.x)
            let actualValueY = String(format: "%f", location.y)

            print("azimuth estimate: (\(estimateValueX), \(estimateValueY)) actual:(\(actualValueX), \(actualValueY))")
        }

        estimates.removeValue(forKey: index)
    }
}

private func addEstimateTouch(touch: UITouch) {
    if let index = touch.estimationUpdateIndex {
        estimates[index] = EstimateTouch(location: touch.location(in: self.view),
                                         force: touch.force,
                                         azimuth: touch.azimuthAngle(in: self.view),
                                         altitude: touch.altitudeAngle)
    }
}

4. お絵かきして遊んでみる

さて、実際にApple Pencilで何が出来るか分かったところで、実際にお絵かきできるアプリを実装してみましょう。
今回は、力加減によって線の太さが変わるお絵かきアプリを実装しました。

描いていく過程でペンの太さが変化していくだけ、でそれっぽいアプリにみえますね!

サンプルコード

class CanvasView: UIImageView {

    private let baseSize: CGFloat = 5.0
    private var color: UIColor = UIColor.black

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

    private func drawLine(touch: UITouch) {
        UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0.0)
        guard let context = UIGraphicsGetCurrentContext() else {
            return
        }

        image?.draw(in: bounds)
        updateContext(context: context, touch: touch)

        image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
    }

    private func updateContext(context: CGContext, touch: UITouch) {
        let previousLocation = touch.previousLocation(in: self)
        let location = touch.location(in: self)
        let width = getLineWidth(touch: touch)

        color.setStroke()
        context.setLineWidth(width)
        context.setLineCap(.round)
        context.setLineJoin(.round)
        context.move(to: previousLocation)
        context.addLine(to: location)
        context.strokePath()
    }

    private func getLineWidth(touch: UITouch) -> CGFloat {
        var width = baseSize
        if touch.force > 0 {
            width = touch.force * baseSize
        }

        return width
    }
}

以下のApple Pencilのチュートリアルが非常に参考になるため、ご興味のある方は一読して見てください。
※12/16時点で記事内のサンプルコードがSwift2で書かれている為、そのままでは現時点での最新環境では動作しません。

余談: 他社製品のスタイラスペンについて

軽くApple社製以外のスタイラスペンを調べて見ましたが、
各社それぞれSDKを出しているようですね。
今の所、Apple公式のAPIから情報を取得できるペンは、Apple Pencilのみのようです。

参考資料:

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

yoshi.k

yoshi.kの書いた記事

ライフ
BPSな休日の活動記録

2017年12月22日

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ

BPSアドベントカレンダー