ACEDrawingViewで手書きサインを5分で実装する

はじめに

みなさん、あけましておめでとうございます。
走れるシステムエンジニア、溝口です。

正月はいかがお過ごしだったでしょうか?
私はニューイヤー駅伝、箱根駅伝と現地に応援に行って、非常に充実した時間を過ごしていました。

にしても、青山学院は強いですね〜。原監督の自主性を重んじる指導の元、のびのびと競技をしている選手たちを見るとこちらも元気になってきます。個人的には東京国際大学が初出場、初シードを取ったら面白いのになぁと思っていましたが、やはり箱根路はそんなに甘くはなかったようです。来年以降に期待ですね。

私の母校は今回は本戦出場はなりませんでしたが、関東学連の10区で良い走りを見せてくれたので、この経験を持ち帰って来年の箱根駅伝予選会は是非突破して欲しいと思います。

iPadで手書きサイン機能を実装したい

失礼しました。前置きが長くなりました(まだまだ書き足りないですが)。

さて、iOSのネイティブアプリを作る時の要件として、まず挙げられるのが「オフライン対応」だと思います。では次点としては何でしょうか?格好良いUI?いやいや、GPSやカメラ機能でしょうか?

意外と多いのが、「手書きサイン機能」ではないでしょうか。

今回は、「手書きサイン機能」を「5分で」実装し、更にSalesforceにアップロードする所までご紹介しようと思います。

ACEDrawingViewとは

なぜiOSでは手書きサインの実装が簡単なのか、それはiOSにはこのライブラリがあるからです。

https://github.com/acerbetti/ACEDrawingView

ACEDrawingViewというライブラリで、UIViewの派生クラスとしてこのビューを実装するだけで、簡単に手書きサインが作れてしまうというなんとも素敵なライブラリになります。(ライセンスはApache License 2.0になります)

ACEDrawingViewで手書きサインを実装する

では、早速実装の方を進めていきましょう。

今回は最終的にSalesforceへ手書きサインデータをアップロードする部分まで実装しますので、以前に使用したアプリケーションと同様に、一旦SalesforceMobileSDKを使用したプレーンなプロジェクトを作成します。

AppDelegate.swiftはこんな感じになっていると思います。

//
//  AppDelegate.swift
//  SFSampleAppSwift
//
//  Created by Daichi Mizoguchi on 2014/12/08.
//  Copyright (c) 2014年 Daichi Mizoguchi. All rights reserved.
//

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    
    var window: UIWindow?
    
    let RemoteAccessConsumerKey: String = "3MVG9Iu66FKeHhINkB1l7xt7kR8czFcCTUhgoA8Ol2Ltf1eYHOU4SqQRSEitYFDUpqRWcoQ2.dBv_a1Dyu5xa"
    let OAuthRedirectURI: String = "testsfdc:///mobilesdk/detect/oauth/done"
    
    
    override init() {
        super.init()
        SFLogger.setLogLevel(SFLogLevel.Debug)
        
        SalesforceSDKManager.sharedManager().connectedAppId = RemoteAccessConsumerKey
        SalesforceSDKManager.sharedManager().connectedAppCallbackUri = OAuthRedirectURI
        SalesforceSDKManager.sharedManager().authScopes = ["web", "api"]
        weak var weakSelf: AppDelegate! = self
        
        
        SalesforceSDKManager.sharedManager().postLaunchAction = {(launchActionList :SFSDKLaunchAction) -> () in
            let acListString:String = SalesforceSDKManager.launchActionsStringRepresentation(launchActionList)
            weakSelf?.log(SFLogLevel.Info, msg:"Post-launch: launch actions taken:" + acListString)
            self.setupRootViewController()
        }
        
        SalesforceSDKManager.sharedManager().launchErrorAction = {(error :NSError!, launchActionList :SFSDKLaunchAction) -> () in
            let errorString:String = error.localizedDescription
            weakSelf?.log(SFLogLevel.Error, msg:"Error during SDK launch:" + errorString)
            self.initializeAppViewState()
            SalesforceSDKManager.sharedManager().launch()
        }
        
        SalesforceSDKManager.sharedManager().postLogoutAction = ({
            weakSelf?.handleSdkManagerLogout()
            return
        })
        
        SalesforceSDKManager.sharedManager().switchUserAction = {(fromUser: SFUserAccount!, toUser: SFUserAccount!) -> () in
            weakSelf?.handleUserSwitch(fromUser, toUser: toUser)
            return
        }
        
        return
    }
    
    
    func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
        self.window = UIWindow(frame: UIScreen.mainScreen().bounds)
        self.initializeAppViewState()
        SalesforceSDKManager.sharedManager().launch()
        return true
    }
    
    func applicationWillResignActive(application: UIApplication) {}
    
    func applicationDidEnterBackground(application: UIApplication) {}
    
    func applicationWillEnterForeground(application: UIApplication) {}
    
    func applicationDidBecomeActive(application: UIApplication) {}
    
    func applicationWillTerminate(application: UIApplication) {}
    
    
    // MARK: Private methods
    
    func initializeAppViewState() {
        self.window?.rootViewController = InitialViewController(nibName: "InitialViewController", bundle: nil)
        self.window?.makeKeyAndVisible()
    }
    
    
    func setupRootViewController() {
        let mainVC: MainViewController = MainViewController(nibName: "MainViewController", bundle: nil)
        let navVC: UINavigationController = UINavigationController(rootViewController: mainVC)
        self.window?.rootViewController = navVC
    }
    
    
    func resetViewState(postResetBlock: ()->Void) {
        if ((self.window?.rootViewController?.presentedViewController) != nil) {
            self.window?.rootViewController?.dismissViewControllerAnimated(false, completion: postResetBlock)
        } else {
            postResetBlock()
        }
    }
    
    
    func handleSdkManagerLogout() {
        self.log(SFLogLevel.Debug, msg:"SFAuthenticationManager logged out.  Resetting app.")
        self.resetViewState({
            self.initializeAppViewState()
            let allAccounts: Array? = SFUserAccountManager.sharedInstance().allUserAccounts
            
            if (allAccounts?.count > 1) {
                let userSwitchVc: SFDefaultUserManagementViewController
                = SFDefaultUserManagementViewController(completionBlock: {(action: SFUserManagementAction) -> () in
                    self.window?.rootViewController?.dismissViewControllerAnimated(false, completion: nil)
                    return
                })
                self.window?.rootViewController?.presentViewController(userSwitchVc, animated: true, completion: nil)
            } else {
                if (allAccounts?.count == 1) {
                    SFUserAccountManager.sharedInstance().currentUser = SFUserAccountManager.sharedInstance().allUserAccounts[0] as! SFUserAccount
                }
                SalesforceSDKManager.sharedManager().launch()
            }
        })
    }
    
    
    func handleUserSwitch(fromUser: SFUserAccount, toUser: SFUserAccount) {
        let switchMsg: String = "SFUserAccountManager changed from user " + fromUser.userName + "to " +  toUser.userName + " Resetting app."
        self.log(SFLogLevel.Debug, msg:switchMsg)
        self.resetViewState({
            self.initializeAppViewState()
            SalesforceSDKManager.sharedManager().launch()
        })
    }
}

Swift2.0とSalesforceMobileSDKが4.0になった影響で若干変わっている部分もありますが、概ね違いは無いと思います。


次に、下準備としてACEDrawingViewをインストールし、プロジェクトにインポートします。

platform :ios,'8.0'
pod 'SalesforceMobileSDK-iOS', :subspecs => [
    'SmartSync'
]
pod 'ACEDrawingView'

Podfileをこの様に記載し、pod installコマンドを実装します。


次に、Swiftのブリッジファイルに以下の様に記載します

//
//  Use this file to import your target's public headers that you would like to expose to Swift.
//

#import <SalesforceSDKCore/SalesforceSDKManager.h>
#import <SalesforceSDKCore/SFDefaultUserManagementViewController.h>
#import <SalesforceSDKCore/SFAuthenticationManager.h>
#import <SalesforceSDKCore/SFUserAccountManager.h>
#import <SalesforceSDKCore/SFLogger.h>
#import "SFRestAPI+Blocks.h"
#import "ACEDrawingView.h"

これでACEDrawingViewを使用する準備が整いました。


では、このプロジェクトでMainViewControllerとなっているViewのxibファイルの方を見ていきましょう。



今回は取引先オブジェクトにレコードをInsertし、そのレコードの添付ファイルとして手書きサインをアップロードしますので、取引先のNameを入力する項目と、手書きサインを入力するUIViewを配置します。

そして画面下部のUIViewのクラスをUIViewからACEDrawingViewに変更します。xibでの実装はたったこれだけです。


次に、MainViewController.swiftのコードの実装側を見ていきましょう

//
//  MainViewController.swift
//  DrawingTestSwift
//
//  Created by Daichi Mizoguchi on 2015/12/27.
//  Copyright © 2015年 Daichi Mizoguchi. All rights reserved.
//

import UIKit

class MainViewController: UIViewController {

    @IBOutlet weak var accountDrawingView: ACEDrawingView!
    @IBOutlet weak var accountNameField: UITextField!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // ACEDrawingViewの文字の太さの設定
        accountDrawingView.lineWidth = 3.0
        // ACEDrawingViewの枠の線を設定
        accountDrawingView.layer.borderWidth = 1.0
        
        let saveButton = UIBarButtonItem(title: "Save", style: .Plain, target: self, action: "tapSaveButton:")
        self.navigationItem.rightBarButtonItem = saveButton
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
    
    // 保存ボタンタップ
    internal func tapSaveButton(sender: UIButton) {
        insertAccount()
    }
    
    // 取引先Insert
    internal func insertAccount() {
        // 取引先の対象カラムを設定
        let accountFields: Dictionary = ["Name":accountNameField.text!]
        SFRestAPI.sharedInstance().performCreateWithObjectType("Account", fields: accountFields,
            failBlock: {(error : NSError!) -> Void in
                self.queryFailed(error)
            },
            completeBlock: { (responseData : [NSObject:AnyObject]!) -> Void in
                self.insertAccontSucceeded(responseData)
            }
        )
    }
    
    // 取引先Insert完了
    internal func insertAccontSucceeded(responseData : [NSObject:AnyObject]) {
        let accountId: String = responseData["id"] as! String
        print("Insert AccountId:" + accountId)
        self.insertAttachment(accountId)
    }

    // 取引先の添付ファイルInsert
    internal func insertAttachment(accountId: String) {
        // 画像データをBase64に変換
        let signData: NSData = UIImageJPEGRepresentation(accountDrawingView.image, 1.0)!
        let sign64Str:String = signData.base64EncodedStringWithOptions(NSDataBase64EncodingOptions.Encoding64CharacterLineLength)
        
        // 添付ファイルの対象カラムを設定
        let attachmenFields: Dictionary = ["ParentId":accountId, "Name":"Sign.jpg", "ContentType":"image/jpeg", "Body":sign64Str]
        SFRestAPI.sharedInstance().performCreateWithObjectType("Attachment", fields: attachmenFields,
            failBlock: {(error : NSError!) -> Void in
                self.queryFailed(error)
            },
            completeBlock: { (responseData : [NSObject:AnyObject]!) -> Void in
                self.insertAttachmentSucceeded(responseData)
            }
        )
    }
    
    // 取引先の添付ファイルInsert完了
    internal func insertAttachmentSucceeded(responseData : [NSObject:AnyObject]) {
        let AttachmentId: String = responseData["id"] as! String
        print("Insert AttachmentId:" + AttachmentId)
    }
    
    // クエリが失敗
    internal func queryFailed(error : NSError) {
        print("Error:" + error.description)
    }
    
    
}

全体の実装としてはこんな感じになっています。では、一つずつ見ていきましょう。


    @IBOutlet weak var accountDrawingView: ACEDrawingView!
    @IBOutlet weak var accountNameField: UITextField!

まずプロパティとして先ほど設定したACEDrawingViewをswift側に記載します。


    override func viewDidLoad() {
        super.viewDidLoad()
        // ACEDrawingViewの文字の太さの設定
        accountDrawingView.lineWidth = 3.0
        // ACEDrawingViewの枠の線を設定
        accountDrawingView.layer.borderWidth = 1.0
        
        let saveButton = UIBarButtonItem(title: "Save", style: .Plain, target: self, action: "tapSaveButton:")
        self.navigationItem.rightBarButtonItem = saveButton
    }

viewDidLoadメソッドの中でACEDrawingViewの各設定をします。

お絵かきアプリ等を作る場合はここでACEDrawingViewの多種多様なプロパティを設定していくのですが、今回は手書きサインなので文字の太さだけを調整します。

ACEDrawingViewの実装はこれで完了です!あとは「image」プロパティにアクセスすることで描いた画像を取り出すことが出来ます。

手書きサインをSalesforceにアップロードしてみよう

では、アップロードの処理を

  • 取引先にレコードをInsert
  • Insertされた取引先のIdを添付ファイルの参照先に設定して、画像ファイルをInsert

という流れで実装していきたいと思います。

    // 取引先Insert
    internal func insertAccount() {
        // 取引先の対象カラムを設定
        let accountFields: Dictionary = ["Name":accountNameField.text!]
        SFRestAPI.sharedInstance().performCreateWithObjectType("Account", fields: accountFields,
            failBlock: {(error : NSError!) -> Void in
                self.queryFailed(error)
            },
            completeBlock: { (responseData : [NSObject:AnyObject]!) -> Void in
                self.insertAccontSucceeded(responseData)
            }
        )
    }

保存ボタンタップ時に呼び出される処理です。
これ自体は普通のInsert処理ですね。


    // 取引先Insert完了
    internal func insertAccontSucceeded(responseData : [NSObject:AnyObject]) {
        let accountId: String = responseData["id"] as! String
        print("Insert AccountId:" + accountId)
        self.insertAttachment(accountId)
    }

取引先のInsert完了時にコールバックとして呼ばれるメソッドです。
ここでInsertされた取引先のIdを引き渡します。


    // 取引先の添付ファイルInsert
    internal func insertAttachment(accountId: String) {
        // 画像データをBase64に変換
        let signData: NSData = UIImageJPEGRepresentation(accountDrawingView.image, 1.0)!
        let sign64Str:String = signData.base64EncodedStringWithOptions(NSDataBase64EncodingOptions.Encoding64CharacterLineLength)
        
        // 添付ファイルの対象カラムを設定
        let attachmenFields: Dictionary = ["ParentId":accountId, "Name":"Sign.jpg", "ContentType":"image/jpeg", "Body":sign64Str]
        SFRestAPI.sharedInstance().performCreateWithObjectType("Attachment", fields: attachmenFields,
            failBlock: {(error : NSError!) -> Void in
                self.queryFailed(error)
            },
            completeBlock: { (responseData : [NSObject:AnyObject]!) -> Void in
                self.insertAttachmentSucceeded(responseData)
            }
        )
    }

ここではaccountDrawingView.imageとして手書きサインの画像データを取り出し、バイナリデータに変換した後、添付ファイルのBodyに設定してSalesforceのAttachmentにInsert処理を行っています。

画像データの取り出しも、バイナリデータへの変換も特に難しい部分は無いですね。


では、このアプリケーションを動かしてみましょう。



こんな感じで取引先名と、手書きサインを入力しました。(字が汚い・・・)
入力が終わったら、保存ボタンをタップします。


SalesforceにInsertされているか見てみましょう。



まず取引先のデータが正しくInsertされていますね。


次に、添付ファイルが保存されているか見てみましょう。



おー、取引先の関連リストとして添付ファイルが設定されています!


では、中身もちゃんとアップロードされているか確かめてみましょう。



ちゃんと画像データもアップロードされていますね!


今回の実装で一点だけ気になる部分があるとすれば、標準のREST APIを使用した実装では取引先のInsert処理と、添付ファイルへのInsert処理のトランザクションが分かれてしまうことでしょうか。

もしこの2つのトランザクションを1つにまとめたい場合、別途Salesforce側にカスタムのREST APIを作成して、画像のバイナリデータと対象のレコードデータを一度に受けられるように作る必要がありますね。
(SalesforceMobileSDKにはカスタムのREST APIを呼ぶメソッドも予め用意されています!)

まとめ

iOSネイティブアプリの手書きサイン機能の実装と聞くだけでなんだか難しそうな気はしますが、こんなにも簡単に実装出来てしまいました。

この他にもiOSにはネイティブの機能を活用した様々なライブラリが公開されていますので、活用してみてはいかがでしょうか!