LoginSignup
43
40

More than 5 years have passed since last update.

画面のパスコードロック機能を構築する際における実装例とポイントまとめ

Last updated at Posted at 2018-12-24

1. はじめに

皆様お疲れ様です。「iOS Advent Calendar」の24日目を担当させて頂きます、fumiyasac(Fumiya Sakai)と申します。何卒よろしくお願い致します。

今回はUI実装に関するトピックスの中でも、簡単な画面のパスコードロック機能を実装したサンプルを作ってみましたので、その際にポイントになる実装部分やUI面で配慮をした部分についても紹介できればと思います。ファイナンス系のアプリをはじめとしたアプリ内でお金のやりとりが発生するものや、ヘルスケアアプリ等でもあまり他人に見られたくないセンシティブな情報を持つようなアプリにおいてはよく見かける機能の1つですが、AppDelegate.swift部分のライフサイクルを利用する点やユーザーの使いやすさを実現するために画面に関する処理にも工夫が必要な部分でもあるので、実際のアプリに導入する際にはこのサンプルだけでは十分ではない部分もあるかと思いますが、実装の参考に少しでもなれば幸いに思います。

Githubでのサンプルコード:

※ こちらのサンプルはPullRequestやIssue等はお気軽にどうぞ!

サンプルの全体的な動きの動画:

※ こちらはiPhoneXでのFaceIDでのパスコードロック解除処理を含む動きになります。

2. 今回の参考資料とサンプル概要について

今回のサンプルに関してはGlobalTabBarController.swift (GlobalTabBar.storyboardで表示される画面) でのコンテンツ表示がベースの画面構成とし「一覧表示画面」と「設定画面」の2つの画面をGlobalTabBarController.swiftの中で表示するような形にしています。※Xcodeでは下図のように一番最初に表示する画面をGlobalTabBar.storyboardを一番最初に表示するように設定しています。

global_tab_bar.png

このサンプル内で設定画面からパスコードロック用のパスコードを設定した後に、ユーザーが下記の動作を実行した場合に、アプリ内のコンテンツ表示前にパスコードロックの画面が表示されるようにします。

  1. アプリをバックグラウンドに持っていった状態から再度フォアグラウンドへアプリを復帰させる場合
  2. アプリを一旦閉じた状態から再度アプリを起動させる場合

TabBarでの表示切り替え時はもちろん、その他モーダル表示を行う画面やUIAlertControllerでのダイアログ表示時にパスコードロック画面をかける場合に、パスコードロックを戻った際に元々表示していた画面が崩れることがないようにする必要があります。

サンプルのキャプチャ画像1:

capture1.png

サンプルのキャプチャ画像2:

capture2.png

環境やバージョンについて:

  • Xcode10.1
  • Swift4.2
  • MacOS Mohave (Ver10.14)

利用しているライブラリ:

ライブラリ名 ライブラリの機能概要
FontAwesome.swift 「Font Awesome」アイコンを利用するためのライブラリ

(補足)画面の上部にくっついて表示されるUICollectionViewの動きについて:

今回紹介しているサンプルのMainViewController.swiftで表示している部分に関してはUICollectionViewFlowLayoutクラスを継承したクラスを別途作成した上で配置しているUICollectionViewへ適用することによって、下図のような形で表示内容をスクロールした際に表示されている情報が上に重なっていくような形の表現を実現しています。

passcode_flow_layout.png

StickyStyleFlowLayout.swift
import Foundation
import UIKit

class StickyStyleFlowLayout: UICollectionViewFlowLayout {

    // 拡大縮小比を変更するための変数(値を変更する必要がある場合のみ利用する)
    var firstItemTransform: CGFloat?

    // 引数で渡された範囲内に表示されているUICollectionViewLayoutAttributesを返す
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {

        // 該当のUICollectionViewLayoutAttributesを取得する
        let items = NSArray(array: super.layoutAttributesForElements(in: rect)!, copyItems: true)

        // 該当のUICollectionViewにへHeaderまたはFooterを
        var headerAttributes: UICollectionViewLayoutAttributes?
        var footerAttributes: UICollectionViewLayoutAttributes?

        // 該当のUICollectionViewLayoutAttributesに対してUICollectionViewLayoutAttributesの更新をする
        items.enumerateObjects(using: { (object, _, _) -> Void in

            let attributes = object as! UICollectionViewLayoutAttributes

            // Header・Footer・セルの場合で場合分けをする
            if attributes.representedElementKind == UICollectionView.elementKindSectionHeader {
                headerAttributes = attributes
            } else if attributes.representedElementKind == UICollectionView.elementKindSectionFooter {
                footerAttributes = attributes
            } else {
                self.updateCellAttributes(attributes, headerAttributes: headerAttributes, footerAttributes: footerAttributes)
            }
        })
        return items as? [UICollectionViewLayoutAttributes]
    }

    // 更新された位置情報からレイアウト処理を再実行するか
    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        return true
    }

    // MARK: - Private Function

    // 該当のセルにおけるUICollectionViewLayoutAttributesの値を更新する
    private func updateCellAttributes(_ attributes: UICollectionViewLayoutAttributes, headerAttributes: UICollectionViewLayoutAttributes?, footerAttributes: UICollectionViewLayoutAttributes?) {

        // 配置しているUICollectionViewにおける最大値・最小値を取得しておく
        let minY = collectionView!.bounds.minY + collectionView!.contentInset.top
        var maxY = attributes.frame.origin.y

        // Headerを利用している場合はその分の高さ減算する
        if let headerAttributes = headerAttributes {
            maxY -= headerAttributes.bounds.height
        }

        // Footerを利用している場合はその分の高さ減算する
        if let footerAttributes = footerAttributes {
            maxY -= footerAttributes.bounds.height
        }

        // 該当のUICollectionViewLayoutAttributesの拡大縮小比を調節して表示する
        var origin = attributes.frame.origin

        let finalY = max(minY, maxY)
        let deltaY = (finalY - origin.y) / attributes.frame.height

        if let itemTransform = firstItemTransform {
            let scale = 1 - deltaY * itemTransform
            attributes.transform = CGAffineTransform(scaleX: scale, y: scale)
        }

        origin.y = finalY
        attributes.frame = CGRect(origin: origin, size: attributes.frame.size)
        attributes.zIndex = attributes.indexPath.row
    }
}

このようなUICollectionViewの表現部分に関するカスタマイズ方法については、他に展開している記事の中にもご紹介しているものがありますが、今回の実装部分につきましては主に下記のサンプルや記事を参考にしました。

3. パスコードロック用の画面や細かな動きを実装する際のポイントまとめ

それではパスコードロック機能を実現するための画面実装や機能の実装部分に関するポイントとなりそうな部分に関する解説をしていきます。まずは画面を構成するために必要なView部品に関する処理やパスコードロック機能を実現するためにAppDelegate.swiftのライフサイクルを活用する処理との連動に関する部分等についてまとめています。

★3-1: 4桁のパスコードを入力するためのテンキー部分のViewの実装についてのポイント解説

まずはテンキー用のViewの構造から見てみましょう。下図のような形でUIStackViewを入れ子にして配置して必要なボタンを配置していきます。この際に0~9の数字のボタンについては、tagプロパティの部分に該当の数字を設定した上でOutlet Collectionを利用してViewクラスとの紐付けを行います。

passcode_ten_key.png

そして配置したViewControllerにおいてボタンをタップしたイベント処理との連動ができるように、下記の3つのプロトコルを定義しておきます。

  • 0~9の数字ボタンが押下された場合にその数字を文字列で送る: func inputPasscodeNumber(_ numberOfString: String)
  • 削除ボタンが押下された場合に値を削除する: func deletePasscodeNumber()
  • TouchID/FaceID搭載端末の場合に実行する: func executeLocalAuthentication()

またボタン押下した際には、「アニメーションを伴った表現であってもストレスなく入力できて、かつユーザーの操作した感じを出す」 という観点もUX向上のために気を配っておきたい部分ではあるかと思います。今回のボタンでは押下時にアルファ値が変更される様な形のアニメーションをつけていますが、アニメーションの実行中であったとしてもユーザーの入力を妨げないようにoptions:の値には.allowUserInteractionを忘れずに追加しています(この設定をしていないとアニメーションが終わるまでボタンタップが反応しない)。またボタンの押した感じをよりユーザーへ伝えるために、iOS10から利用可能なHaptic Feedbackをボタン押下時に加えて表現をしています。

Haptic Feedbackに関する参考資料:

以上の点をまとめるとこのViewに対応するクラスのコードは下記のような形となります。

InputPasscodeKeyboardView.swiftにおけるコード実装:

InputPasscodeKeyboardView.swift
import Foundation
import UIKit

// MEMO: このViewに配置しているボタンが押下された場合に値の変更を反映させるためのプロトコル
protocol InputPasscodeKeyboardDelegate: NSObjectProtocol {

    // 0~9の数字ボタンが押下された場合にその数字を文字列で送る
    func inputPasscodeNumber(_ numberOfString: String)

    // 削除ボタンが押下された場合に値を削除する
    func deletePasscodeNumber()

    // TouchID/FaceID搭載端末の場合に実行する
    func executeLocalAuthentication()
}

class InputPasscodeKeyboardView: CustomViewBase {

    weak var delegate: InputPasscodeKeyboardDelegate?

    // ボタン押下時の軽微な振動を追加する
    private let buttonFeedbackGenerator: UIImpactFeedbackGenerator = {
        let generator: UIImpactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
        generator.prepare()
        return generator
    }()

    // パスコードロック用の数値入力用ボタン
    // MEMO: 「Outlet Collection」を用いて接続しているのでweakはけつけていません
    @IBOutlet private var inputPasscodeNumberButtons: [UIButton]!

    // パスコードロック用のLocalAuthentication実行用ボタン
    @IBOutlet private weak var executeLocalAuthenticationButton: UIButton!

    // パスコードロック用の数値削除用ボタン
    @IBOutlet private weak var deletePasscodeNumberButton: UIButton!

    // MARK: - Initializer

    required init(frame: CGRect) {
        super.init(frame: frame)

        setupInputPasscodeKeyboardView()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)

        setupInputPasscodeKeyboardView()
    }

    // MARK: - Function

    func shouldEnabledLocalAuthenticationButton(_ result: Bool = true) {
        executeLocalAuthenticationButton.isEnabled = result
        executeLocalAuthenticationButton.superview?.alpha = (result) ? 1.0 : 0.3
    }

    // MARK: - Private Function

    @objc private func inputPasscodeNumberButtonTapped(sender: UIButton) {
        guard let superView = sender.superview else {
            return
        }
        executeButtonAnimation(for: superView)
        buttonFeedbackGenerator.impactOccurred()
        self.delegate?.inputPasscodeNumber(String(sender.tag))
    }

    @objc private func deletePasscodeNumberButtonTapped(sender: UIButton) {
        guard let superView = sender.superview else {
            return
        }
        executeButtonAnimation(for: superView)
        buttonFeedbackGenerator.impactOccurred()
        self.delegate?.deletePasscodeNumber()
    }

    @objc private func executeLocalAuthenticationButtonTapped(sender: UIButton) {
        guard let superView = sender.superview else {
            return
        }
        executeButtonAnimation(for: superView)
        buttonFeedbackGenerator.impactOccurred()
        self.delegate?.executeLocalAuthentication()
    }

    private func setupInputPasscodeKeyboardView() {
        inputPasscodeNumberButtons.enumerated().forEach {
            let button = $0.element
            button.addTarget(self, action: #selector(self.inputPasscodeNumberButtonTapped(sender:)), for: .touchDown)
        }
        deletePasscodeNumberButton.addTarget(self, action: #selector(self.deletePasscodeNumberButtonTapped(sender:)), for: .touchDown)
        executeLocalAuthenticationButton.addTarget(self, action: #selector(self.executeLocalAuthenticationButtonTapped(sender:)), for: .touchDown)
    }

    private func executeButtonAnimation(for targetView: UIView, completionHandler: (() -> ())? = nil) {

        // MEMO: ユーザーの入力レスポンスがアニメーションによって遅延しないような考慮をする
        UIView.animateKeyframes(withDuration: 0.16, delay: 0.0, options: [.allowUserInteraction, .autoreverse], animations: {
            UIView.addKeyframe(withRelativeStartTime: 0.2, relativeDuration: 1.0, animations: {
                targetView.alpha = 0.5
            })
            UIView.addKeyframe(withRelativeStartTime: 1.0, relativeDuration: 1.0, animations: {
                targetView.alpha = 1.0
            })
        }, completion: { finished in
            completionHandler?()
        })
    }
}

★3-2: ユーザーの入力状態を表すViewの実装についてのポイント解説

次にユーザーの入力状態を表すViewの構造から見てみましょう。こちらも下図のような形でUIStackViewの中に4つのUIImageViewを配置します(鍵の形をしたアイコンについては 「FontAwesome.swift」 を利用して表現しています)。この際に左から1~4の数値tagプロパティの部分に該当の数字を設定した上でOutlet Collectionを利用してViewクラスとの紐付けを行います。

passcode_input_display.png

そして配置したViewControllerにおいて現在ユーザーが入力しているパスコードの桁数との連動ができるように、下記の2つのインスタンスメソッドを定義しておきます。

  • 鍵マーク表示部分が増えていくような動きを実現する: func incrementDisplayImagesBy(passcodeStringCount: Int = 0)
  • 鍵マーク表示部分が減っていくような動きを実現する: func decrementDisplayImagesBy(passcodeStringCount: Int = 0)

以上の点をまとめるとこのViewに対応するクラスのコードは下記のような形となります。

InputPasscodeDisplayView.swiftにおけるコード実装:

InputPasscodeDisplayView.swift
import Foundation
import UIKit
import FontAwesome_swift

class InputPasscodeDisplayView: CustomViewBase {

    private let defaultKeyImageAlpha: CGFloat = 0.3
    private let selectedKeyImageAlpha: CGFloat = 1.0

    @IBOutlet private var keyImageViews: [UIImageView]!

    // MARK: - Initializer

    required init(frame: CGRect) {
        super.init(frame: frame)

        setupInputPasscodeDisplayView()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)

        setupInputPasscodeDisplayView()
    }

    // MARK: - Function

    // 鍵マーク表示部分が増えていくような動きを実現する
    func incrementDisplayImagesBy(passcodeStringCount: Int = 0) {

        keyImageViews.enumerated().forEach {
            let imageView = $0.element

            guard let superView = imageView.superview else {
                return
            }

            // MEMO: 引数で渡された値とタグ値が一致した場合にはアニメーションを実行する
            if imageView.tag == passcodeStringCount {
                imageView.alpha = selectedKeyImageAlpha
                executeKeyImageAnimation(for: superView)
            } else if imageView.tag < passcodeStringCount {
                imageView.alpha = selectedKeyImageAlpha
            } else {
                imageView.alpha = defaultKeyImageAlpha
            }
        }
    }

    // 鍵マーク表示部分が減っていくような動きを実現する
    func decrementDisplayImagesBy(passcodeStringCount: Int = 0) {

        keyImageViews.enumerated().forEach {
            let imageView = $0.element

            // MEMO: 入力した情報を消去する場合はアニメーションは実行しません
            if imageView.tag <= passcodeStringCount {
                imageView.alpha = selectedKeyImageAlpha
            } else {
                imageView.alpha = defaultKeyImageAlpha
            }
        }
    }

    // MARK: - Private Function

    private func setupInputPasscodeDisplayView() {
        keyImageViews.enumerated().forEach {
            let imageView = $0.element
            imageView.image = UIImage.fontAwesomeIcon(name: .key, style: .solid, textColor: .black, size: CGSize(width: 48.0, height: 48.0))
            imageView.alpha = defaultKeyImageAlpha
        }
    }

    // パスコード入力画面用の画像が弾む様なアニメーションをする
    private func executeKeyImageAnimation(for targetView: UIView, completionHandler: (() -> ())? = nil) {

        // アイコン画像用のViewが縮むようにバウンドするアニメーションを付与する
        UIView.animateKeyframes(withDuration: 0.06, delay: 0.0, options: [.autoreverse], animations: {
            UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 1.0, animations: {
                targetView.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
            })
            UIView.addKeyframe(withRelativeStartTime: 1.0, relativeDuration: 1.0, animations: {
                targetView.transform = CGAffineTransform.identity
            })
        }, completion: { finished in
            completionHandler?()
        })
    }
}

★3-3: ユーザーが入力したパスコードを管理するModel部分とPresenter部分での処理に関する実装のポイント解説

次にユーザーが設定したパスコードのデータを取り扱うためのModelクラス及びPresenterクラスに関する実装について見てみましょう。今回のサンプルにおいてパスコードロック画面を構成するPasscodeViewController.swiftに関しては 「MVP(Model - Presenter -View)パターン」 を利用した実装を行なっています。この画面はバックグラウンドから復帰する場合や初回起動時にパスコードロックを表示するための画面はもちろんのこと、設定画面からユーザーがパスコードを設定する画面にも利用できるような形としています。このViewControllerが利用される場合に応じた処理が必要になってくるので、下記のような場合分けを元にしてEnumの設定を行なっています。

  1. 「設定画面からのパスコードの新規登録」 → 「登録したいパスコードの入力画面:.inputForCreate」 → 「登録したいパスコードの入力画面:.retryForCreate」 → 「設定画面へ戻る」
  2. 「設定画面からのパスコードの変更」 → 「変更したいパスコードの入力画面:.inputForUpdate」 → 「変更したいパスコードの入力画面:.retryForUpdate」 → 「設定画面へ戻る」
  3. 「初回起動時またはフォアグラウンド復帰時」 → 「パスコードロック画面の表示:.displayPasscodeLock」 → 「初回起動時に表示したい画面またはバッググラウンドに移行する前に表示していた画面」

パスコードロック画面処理におけるView ⇄ Presenter ⇄ Modelの関連:

passcode_architecture.png

InputPasscodeType.swiftにおけるコード実装:

InputPasscodeType.swift
import Foundation

enum InputPasscodeType {
    case inputForCreate      // パスコードの新規作成
    case retryForCreate      // パスコードの新規作成時の確認
    case inputForUpdate      // パスコードの変更
    case retryForUpdate      // パスコードの変更時の確認
    case displayPasscodeLock // パスコードロック画面の表示時

    // MARK: - Function

    func getTitle() -> String {
        switch self {
        case .inputForCreate, .retryForCreate:
            return "パスコード登録"
        case .inputForUpdate, .retryForUpdate:
            return "パスコード変更"
        case .displayPasscodeLock:
            return "パスコードロック"
        }
    }

    func getMessage() -> String {
        switch self {
        case .inputForCreate:
            return "登録したいパスコードを入力して下さい"
        case .inputForUpdate:
            return "変更したいパスコードを入力して下さい"
        case .retryForCreate, .retryForUpdate:
            return "確認用に再度パスコードを入力して下さい"
        case .displayPasscodeLock:
            return "設定したパスコードを入力して下さい"
        }
    }

    func getNextInputPasscodeType() -> InputPasscodeType? {
        switch self {
        case .inputForCreate:
            return .retryForCreate
        case .inputForUpdate:
            return .retryForUpdate
        default:
            return nil
        }
    }
}

そして前述した場合分けに応じて定義をしたEnum値を踏まえた処理をViewController側で実行するためのPasscodePresenter.swiftクラスとユーザーが設定したパスコードを取り扱うためのPasscodeModel.swiftクラスのコードはそれぞれ下記のような形となります。

PasscodeModel.swiftにおけるコード実装:

PasscodeModel.swift
import Foundation

class PasscodeModel {

    private let userHashedPasscode = "PasscodeModel:userHashedPasscode"

    private var ud: UserDefaults {
        return UserDefaults.standard
    }

    // MARK: - Function

    // ユーザーが入力したパスコードを保存する
    func saveHashedPasscode(_ passcode: String) -> Bool {
        if isValid(passcode) {
            setHashedPasscode(passcode)
            return true
        } else {
            return false
        }
    }

    // ユーザーが入力したパスコードと現在保存されているパスコードを比較する
    func compareSavedPasscodeWith(inputPasscode: String) -> Bool {
        let hashedInputPasscode = getHashedPasscodeByHMAC(inputPasscode)
        let savedPasscode = getHashedPasscode()
        return hashedInputPasscode == savedPasscode
    }

    // ユーザーが入力したパスコードが存在するかを判定する
    func existsHashedPasscode() -> Bool {
        let savedPasscode = getHashedPasscode()
        return !savedPasscode.isEmpty
    }

    // HMAC形式でハッシュ化されたパスコード取得する
    func getHashedPasscode() -> String {
        return ud.string(forKey: userHashedPasscode) ?? ""
    }

    // 現在保存されているパスコードを削除する
    func deleteHashedPasscode() {
        ud.set("", forKey: userHashedPasscode)
    }

    // MARK: - Private Function

    // 引数で受け取ったパスコードをhmacで暗号化した上で保存する
    private func setHashedPasscode(_ passcode: String) {
        let hashedPasscode = getHashedPasscodeByHMAC(passcode)
        ud.set(hashedPasscode, forKey: userHashedPasscode)
    }

    // 引数で受け取った値をhmacで暗号化する
    private func getHashedPasscodeByHMAC(_ passcode: String) -> String {
        return passcode.hmac(algorithm: .SHA256)
    }

    // 引数で受け取った値の形式が正しいかどうかを判定する
    private func isValid(_ passcode: String) -> Bool {
        return isValidLength(passcode) && isValidFormat(passcode)
    }

    // 引数で受け取った値が4文字かを判定する
    private func isValidLength(_ passcode: String) -> Bool {
        return passcode.count == AppConstant.PASSCODE_LENGTH
    }

    // 引数で受け取った値が半角数字かを判定する
    private func isValidFormat(_ passcode: String) -> Bool {
        let regexp = try! NSRegularExpression.init(pattern: "^(?=.*?[0-9])[0-9]{4}$", options: [])
        let targetString = passcode as NSString
        let result = regexp.firstMatch(in: passcode, options: [], range: NSRange.init(location: 0, length: targetString.length))
        return result != nil
    }
}

PasscodePresenter.swiftにおけるコード実装:

PasscodePresenter.swift
import Foundation

protocol PasscodePresenterDelegate: NSObjectProtocol {
    func goNext()
    func dismissPasscodeLock()
    func savePasscode()
    func showError()
}

class PasscodePresenter {

    private let previousPasscode: String?

    weak var delegate: PasscodePresenterDelegate?

    // MARK: - Initializer

    // MEMO: 前の画面で入力したパスコードを利用したい場合は引数に設定する
    init(previousPasscode: String?) {
        self.previousPasscode = previousPasscode
    }

    // MARK: - Function

    // ViewController側でパスコードの入力が完了した場合に実行する処理
    func inputCompleted(_ passcode: String, inputPasscodeType: InputPasscodeType) {
        let passcodeModel = PasscodeModel()

        switch inputPasscodeType {

        case .inputForCreate, .inputForUpdate:

            // 再度パスコードを入力するための確認画面へ遷移する
            self.delegate?.goNext()
            break


        case .retryForCreate, .retryForUpdate:

            // 前画面で入力したパスコードと突き合わせて、同じだったらUserDefaultへ登録する
            if previousPasscode != passcode {
                self.delegate?.showError()
                return
            }
            if passcodeModel.saveHashedPasscode(passcode) {
                self.delegate?.savePasscode()
            } else {
                self.delegate?.showError()
            }
            break


        case .displayPasscodeLock:

            // 保存されているユーザーが設定したパスコードと突き合わせて、同じだったらパスコードロック画面を解除する
            if passcodeModel.compareSavedPasscodeWith(inputPasscode: passcode) {
                self.delegate?.dismissPasscodeLock()
            } else {
                self.delegate?.showError()
            }
            break
        }
    }
}

補足事項としましては、パスコードをUserDefaultへ保存する際にはそのまま4桁の数値の文字列として保存しておくのではなく、パスコードをHMAC-SHA256化して保存しています。HMAC-SHA256化をする際に追加をする必要がある処理やプロジェクト内で設定に関する手順については下記にご紹介するリンクをご参考にして頂ければ幸いです。

参考: HMAC-SHA256化をするための処理:

★3-4: TouchIDやFaceIDを利用してパスコードを解除する機能を実装する際のポイント解説

次にTouchIDやFaceIDによる認証を利用してパスコードを解除するための実装について見てみましょう。今回のサンプルにおいてはパスコードロック画面を表示している場合において、もしユーザーがTouchIDやFaceIDの利用を許可している場合においては前述したInputPasscodeKeyboardView.swiftで定義しているTouchID/FaceIDの認証を実行するためのボタンが有効になるようにしています。またFaceIDが導入されたのはiOS11以降になるので認証状態を判定するための.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)の結果に関してもiOSのバージョンによる場合分けが必要になる点には注意が必要です。今回のサンプルでは、ボタン押下時にTouchIDやFaceIDでの認証用ダイアログを表示するための処理をLocalAuthenticationManager.swiftというひとまとめにした形のクラスとして実装をしています。

LocalAuthenticationType.swiftにおけるコード実装:

LocalAuthenticationType.swift
import Foundation

enum LocalAuthenticationType {
    case authWithFaceID  // FaceIDでのパスコード解除
    case authWithTouchID // TouchIDでのパスコード解除
    case authWithManual  // 手動入力でのパスコード解除

    // MARK: - Function

    func getDescriptionTitle() -> String {
        switch self {
        case .authWithFaceID:
            return "FaceID"
        case .authWithTouchID:
            return "TouchID"
        default:
            return ""
        }
    }

    func getLocalizedReason() -> String {
        switch self {
        case .authWithFaceID, .authWithTouchID:
            return "\(self.getDescriptionTitle())を利用して画面ロックを解除します。"
        default:
            return ""
        }
    }
}

LocalAuthenticationManager.swiftにおけるコード実装:

LocalAuthenticationManager.swift
import Foundation
import LocalAuthentication

class LocalAuthenticationManager {

    // MARK: - Static Function

    static func getDeviceOwnerLocalAuthenticationType() -> LocalAuthenticationType {
        let localAuthenticationContext = LAContext()

        // iOS11以上の場合: FaceID/TouchID/パスコードの3種類
        if #available(iOS 11.0, *) {

            if localAuthenticationContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) {
                switch localAuthenticationContext.biometryType {
                case .faceID:
                    return .authWithFaceID
                case .touchID:
                    return .authWithTouchID
                default:
                    return .authWithManual
                }
            }

        // iOS10以下の場合: TouchID/パスコードの2種類
        } else {

            if localAuthenticationContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) {
                return .authWithTouchID
            } else {
                return .authWithManual
            }

        }
        return .authWithManual
    }

    static func evaluateDeviceOwnerLocalAuthentication(successHandler: (() -> ())? = nil, errorHandler: (() -> ())? = nil) {
        let type = self.getDeviceOwnerLocalAuthenticationType()

        // パスコードでの解除の場合は以降の処理は行わない
        if type == .authWithManual {
            return
        }

        // FaceID/TouchIDでの認証結果に応じて引数のクロージャーに設定した処理を実行する
        let localAuthenticationContext = LAContext()
        localAuthenticationContext.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: type.getLocalizedReason(), reply: { success, evaluateError in
            if success {
                // 認証成功時の処理を書く
                successHandler?()
                print("認証成功:", type.getDescriptionTitle())
            } else {
                // 認証失敗時の処理を書く
                errorHandler?()
                print("認証失敗:", evaluateError.debugDescription)
            }
        })
    }
}

FaceIDやTouchIDを利用した認証を利用する際に必要な基本的な処理やプロジェクト内で設定に関する手順については下記にご紹介するリンクをご参考にして頂ければ幸いです。

参考: TouchIDやFaceIDを利用するための処理:

また、TouchIDやFaceIDでの認証を実行時に表示されるダイアログを表示した場合にはAppDelegate.swiftapplicationWillResignActiveが実行され、またダイアログを閉じた場合にはAppDelegate.swiftapplicationDidBecomeActiveが実行されますので、開発しているアプリの中で該当のライフサイクルの中で既に実装をしている処理との兼ね合いに関しても配慮をする様にしておくと良いかもしれませんね。

※アプリを新しくインストールした際によく見かけるプッシュ通知の許可ダイアログ表示についても同様な事が起こります。

applicationWillResignActive: フォアグラウンドからバックグラウンドへ移行しようとした時
認証成功: FaceID
applicationDidBecomeActive: アプリの状態がアクティブになった時

★3-5: パスコード入力画面のViewControllerの実装と用途に応じた処理をするPresenterとつなげる実装のポイント解説

次にパスコード入力画面のViewControllerの実装と用途に応じた処理をするPresenterとつなげる実装について見てみましょう。画面全体のハンドリングに関しては前述したPasscodePresenter.swiftを利用し、配置したテンキー状のボタン押下時は前述したInputPasscodeKeyboardView.swiftで定義しているInputPasscodeKeyboardDelegateを利用してボタンの入力との連動ができるようにします。

各種定義したProtocolと連動する処理に関する図解:

passcode_viewcontroller.png

またTouchIDやFaceIDの認証状態に応じた画面の状態変更が必要な部分や認証ダイアログを表示するタイミングについてはLocalAuthenticationManager.swiftにて定義したクラスのメソッドを利用するようにします。

PasscodePresenter.swiftで定義した入力完了時に実行する.inputCompleted(userInputPasscode, inputPasscodeType: inputPasscodeType)メソッドを実行したタイミングで実行されるPasscodePresenterDelegateの処理に関しては、早すぎる入力を行なった際に意図しない画面遷移を実行される現象の対応策 として0.24秒の間は画面操作を受け付けない状態にして、その後に画面遷移等の処理実行する様な形としています。

PasscodeViewController.swiftにおけるコード実装:

PasscodeViewController.swift
import UIKit
import AudioToolbox

class PasscodeViewController: UIViewController {

    // 画面遷移前に引き渡す変数
    private var inputPasscodeType: InputPasscodeType!
    private var presenter: PasscodePresenter!

    private var userInputPasscode: String = ""

    @IBOutlet weak private var inputPasscodeDisplayView: InputPasscodeDisplayView!
    @IBOutlet weak private var inputPasscodeKeyboardView: InputPasscodeKeyboardView!
    @IBOutlet weak private var inputPasscodeMessageLabel: UILabel!

    override func viewDidLoad() {
        super.viewDidLoad()

        // MEMO: PasscodePresenterに定義したプロトコルの処理を実行するようにする
        presenter.delegate = self

        setupUserInterface()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        hideTabBarItems()
    }

    // MARK: - Function

    func setTargetPresenter(_ presenter: PasscodePresenter) {
        self.presenter = presenter
    }

    func setTargetInputPasscodeType(_ inputPasscodeType: InputPasscodeType) {
        self.inputPasscodeType = inputPasscodeType
    }

    // MARK: - Private Function

    private func setupUserInterface() {
        setupNavigationItems()
        setupInputPasscodeMessageLabel()
        setupPasscodeNumberKeyboardView()
    }

    private func setupNavigationItems() {
        setupNavigationBarTitle(inputPasscodeType.getTitle())
        removeBackButtonText()
    }

    private func setupInputPasscodeMessageLabel() {
        inputPasscodeMessageLabel.text = inputPasscodeType.getMessage()
    }

    private func setupPasscodeNumberKeyboardView() {
        inputPasscodeKeyboardView.delegate = self

        // MEMO: 利用している端末のFaceIDやTouchIDの状況やどの画面で利用しているか見てボタン状態を判断する
        var isEnabledLocalAuthenticationButton: Bool = false
        if inputPasscodeType == .displayPasscodeLock {
            isEnabledLocalAuthenticationButton = LocalAuthenticationManager.getDeviceOwnerLocalAuthenticationType() != .authWithManual
        }
        inputPasscodeKeyboardView.shouldEnabledLocalAuthenticationButton(isEnabledLocalAuthenticationButton)
    }

    private func hideTabBarItems() {
        if let tabBarVC = self.tabBarController {
            tabBarVC.tabBar.isHidden = true
        }
    }

    private func acceptUserInteraction() {
        self.view.isUserInteractionEnabled = true
    }

    private func refuseUserInteraction() {
        self.view.isUserInteractionEnabled = false
    }

    // 最初の処理Aを実行 → 指定秒数後に次の処理Bを実行するためのラッパー
    // MEMO: 早すぎる入力を行なった際に意図しない画面遷移を実行される現象の対応策として実行している
    private func executeSeriesAction(firstAction: (() -> ())? = nil, deleyedAction: @escaping (() -> ())) {
        // 最初は該当画面のUserInteractionを受け付けない
        self.refuseUserInteraction()
        firstAction?()

        DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) {
            // 指定秒数経過後は該当画面のUserInteractionを受け付ける
            self.acceptUserInteraction()
            deleyedAction()
        }
    }
}

// MARK: - PasscodeNumberKeyboardDelegate

extension PasscodeViewController: InputPasscodeKeyboardDelegate {

    func inputPasscodeNumber(_ numberOfString: String) {

        // パスコードが0から3文字の場合はキーボードの押下された数値の文字列を末尾に追加する
        if 0...3 ~= userInputPasscode.count {
            userInputPasscode = userInputPasscode + numberOfString
            inputPasscodeDisplayView.incrementDisplayImagesBy(passcodeStringCount: userInputPasscode.count)
        }

        // パスコードが4文字の場合はPasscodePresenter側に定義した入力完了処理を実行する
        if userInputPasscode.count == AppConstant.PASSCODE_LENGTH {
            presenter.inputCompleted(userInputPasscode, inputPasscodeType: inputPasscodeType)
        }
    }

    func deletePasscodeNumber() {

        // パスコードが1から3文字の場合は数値の文字列の末尾を削除する
        if 1...3 ~= userInputPasscode.count {
            userInputPasscode = String(userInputPasscode.prefix(userInputPasscode.count - 1))
            inputPasscodeDisplayView.decrementDisplayImagesBy(passcodeStringCount: userInputPasscode.count)
        }
    }

    func executeLocalAuthentication() {

        // パスコードロック画面以外では操作を許可しない
        guard inputPasscodeType == .displayPasscodeLock else {
            return
        }

        // TouchID/FaceIDによる認証を実行し、成功した場合にはパスコードロックを解除する
        LocalAuthenticationManager.evaluateDeviceOwnerLocalAuthentication(
            successHandler: {
                DispatchQueue.main.async {
                    self.dismiss(animated: true, completion: nil)
                }
            },
            errorHandler: {}
        )
    }
}

// MARK: - PasscodePresenterProtocol

extension PasscodeViewController: PasscodePresenterDelegate {

    // 次に表示するべき画面へ入力された値を引き継いだ状態で遷移する
    func goNext() {
        executeSeriesAction(
            firstAction: {},
            deleyedAction: {
                // Enum経由で次のアクションで設定すべきEnumの値を取得する
                guard let nextInputPasscodeType = self.inputPasscodeType.getNextInputPasscodeType() else {
                    return
                }
                // 遷移先のViewControllerに関する設定をする
                let sb = UIStoryboard(name: "Passcode", bundle: nil)
                let vc = sb.instantiateInitialViewController() as! PasscodeViewController
                vc.setTargetInputPasscodeType(nextInputPasscodeType)
                vc.setTargetPresenter(PasscodePresenter(previousPasscode: self.userInputPasscode))
                self.navigationController?.pushViewController(vc, animated: true)


                self.userInputPasscode.removeAll()
                self.inputPasscodeDisplayView.decrementDisplayImagesBy()
            }
        )
    }

    // パスコードロック画面を解除する
    func dismissPasscodeLock() {
        executeSeriesAction(
            firstAction: {},
            deleyedAction: {
                self.dismiss(animated: true, completion: nil)
            }
        )
    }

    // ユーザーが入力したパスコードを保存して設定画面へ戻る
    func savePasscode() {
        executeSeriesAction(
            firstAction: {},
            deleyedAction: {
                self.navigationController?.popToRootViewController(animated: true)
            }
        )
    }

    // ユーザーが入力した値が正しくないことをユーザーへ伝える
    func showError() {
        executeSeriesAction(
            // 実行直後はエラーメッセージを表示する & バイブレーションを適用する
            firstAction: {
                self.inputPasscodeMessageLabel.text = "パスコードが一致しませんでした"
                AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
            },
            // 秒数経過後にユーザーが入力したメッセージを空にする & パスコードのハート表示をリセットする
            deleyedAction: {
                self.userInputPasscode.removeAll()
                self.inputPasscodeDisplayView.decrementDisplayImagesBy()
            }
        )
    }
}

このような形で一見すると似た様な構造ではあるけれど、利用される用途が異なる様な画面を構築する場合においては画面用途に応じて振る舞いを切り替えられるような仕組みにしておくと便利ではあるかと思います。今回紹介したサンプルでは 「MVPパターンと使用用途に応じたEnum値を元に判定する」 アーキテクチャを使用した構成となっている点ができるだけ実装の見通しを良くするためのポイントになります。

※ もし自分はこんな形式やアーキテクチャで実装しているよ!という事例をご存知の方がいらっしゃいましたらお教え頂けると嬉しいですm(_ _)m

★3-6: AppDelegate.swiftや一番最初に表示する画面にてパスコードロック画面を表示する機能を実装する際のポイント解説

最後にAppDelegate.swiftや一番最初に表示する画面にてパスコードロック画面を表示する機能の実装について見てみましょう。パスコードロック画面を表示させるタイミングについてまとめると、

  1. アプリをバックグラウンドに持っていった状態から再度フォアグラウンドへアプリを復帰させる場合 → AppDelegate.swiftにおいてapplicationDidEnterBackgroundのタイミングでパスコードロック画面を表示
  2. アプリを一旦閉じた状態から再度アプリを起動させる場合 → GlobalTabBarController.swiftにおいてUIApplication.didFinishLaunchingNotificationを受け取ったタイミングでパスコードロック画面を表示

という2通りの処理が必要になります。

パスコードロック画面を表示する処理の概要図解:

passcode_lock_explain.png

AppDelegate.swiftにおけるコード実装:

AppDelegate.swift
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    ・・・(省略)・・・

    func applicationDidEnterBackground(_ application: UIApplication) {
        print("applicationDidEnterBackground: バックグラウンドへ移行完了した時")

        // パスコードロック画面を表示する
        displayPasscodeLockScreenIfNeeded()
    }

    ・・・(省略)・・・

    // MARK: - Private Function

    private func displayPasscodeLockScreenIfNeeded() {
        let passcodeModel = PasscodeModel()

        // パスコードロックを設定していない場合は何もしない
        if !passcodeModel.existsHashedPasscode() {
            return
        }

        if let rootViewController = UIApplication.shared.keyWindow?.rootViewController {

            // 現在のrootViewControllerにおいて一番上に表示されているViewControllerを取得する
            var topViewController: UIViewController = rootViewController
            while let presentedViewController = topViewController.presentedViewController {
                topViewController = presentedViewController
            }

            // すでにパスコードロック画面がかぶせてあるかを確認する
            let isDisplayedPasscodeLock: Bool = topViewController.children.map{
                return $0 is PasscodeViewController
            }.contains(true)

            // パスコードロック画面がかぶせてなければかぶせる
            if !isDisplayedPasscodeLock {
                let nav = UINavigationController(rootViewController: getPasscodeViewController())
                nav.modalPresentationStyle = .overFullScreen
                nav.modalTransitionStyle   = .crossDissolve
                topViewController.present(nav, animated: true, completion: nil)
            }
        }
    }

    private func getPasscodeViewController() -> PasscodeViewController {
        // 遷移先のViewControllerに関する設定をする
        let sb = UIStoryboard(name: "Passcode", bundle: nil)
        let vc = sb.instantiateInitialViewController() as! PasscodeViewController
        vc.setTargetInputPasscodeType(.displayPasscodeLock)
        vc.setTargetPresenter(PasscodePresenter(previousPasscode: nil))
        return vc
    }
}

この部分の処理においてポイントは、 「現在のrootViewControllerにおいて一番上に表示されているViewControllerの上にモーダルでパスコードロック画面をかぶせる」 ことによってその他の画面を表示している場合においても、パスコードロックを解除した際に以前に表示していた画面へ戻る事ができる様に配慮している点にあるかと思います。またこの様な処理を実装する際には下記でご紹介されているリンクの記事が参考になりました。

参考: 現在表示されている画面において最前面のViewControllerを取得する:

GlobalTabBarController.swiftにおけるコード実装:

GlobalTabBarController.swift
import UIKit
import FontAwesome_swift

class GlobalTabBarController: UITabBarController, UITabBarControllerDelegate {

    override func viewDidLoad() {
        super.viewDidLoad()

        self.delegate = self
        self.viewControllers = [UIViewController(), UIViewController()]

        setupUserInterface()

        // アプリ起動完了時のパスコード画面表示の通知監視
        NotificationCenter.default.addObserver(self, selector: #selector(self.displayPasscodeLockScreenIfNeeded), name: UIApplication.didFinishLaunchingNotification, object: nil)
    }

    // MARK: - Private Function

    @objc private func displayPasscodeLockScreenIfNeeded() {
        let passcodeModel = PasscodeModel()

        // パスコードロックを設定していない場合は何もしない
        if !passcodeModel.existsHashedPasscode() {
            return
        }

        let nav = UINavigationController(rootViewController: getPasscodeViewController())
        nav.modalPresentationStyle = .overFullScreen
        nav.modalTransitionStyle   = .crossDissolve
        self.present(nav, animated: false, completion: nil)
    }

    ・・・(省略)・・・

    private func getPasscodeViewController() -> PasscodeViewController {
        // 遷移先のViewControllerに関する設定をする
        let sb = UIStoryboard(name: "Passcode", bundle: nil)
        let vc = sb.instantiateInitialViewController() as! PasscodeViewController
        vc.setTargetInputPasscodeType(.displayPasscodeLock)
        vc.setTargetPresenter(PasscodePresenter(previousPasscode: nil))
        return vc
    }
}

4. 今回の機能を更によくするためのアイデアや実装の際に気をつけると良い点について

ここでは、今回のサンプルにおいては考慮していませんが、実際に開発しているアプリにこのような機能を追加していく際には更に考慮しておいた方が良さそうな点について簡単に紹介をしていきます。

特に、設定したパスコードをユーザーが忘れてしまった際にアプリのサーバーサイド側で登録されているメールアドレス等の情報等をうまく利用してパスコードロックを解除できる機能の様に、ユーザーに対して優しい配慮ができるような機能を予め追加しておくと更に良いものにできると考えています。

passcode_appendix.png

また実装面での考慮事項としては、今回の実装についてはAppDelegate.swiftのライフサイクルを利用した機能になりますので、この他にもURLスキーム等からのアプリ起動を実行した場合についての考慮を想定しておくと更に良い実装ができるように思います。

5. あとがき

今回のサンプルで実装している機能に関しては、あくまで必要最低限の部分だけの実装になりますので、実際にお使いのアプリの中で実現する場合にはAppDelegete.swift内のライフサイクルに既に実装している処理やアプリの起動経路の中で必要な処理との兼ね合いについての考慮や利用しているユーザーに安心して利用して頂けるようにするための配慮するための機能も追加する必要が出てくるかと思います。また、パスコードロック画面のUI要素の構成についてはシンプルなものではありますが、細かなUIに関する動きや使いやすく・わかりやすくするための細かな工夫を盛り込むことで、更に機能面やUX面に関してもより良いものにできる余地がある部分でもあるかとも感じています。

今後ともiOSアプリに関するUI実装に関連するサンプル開発を通じた知見を数多くご紹介できるように、平素からアンテナを張りながら、どのような局面やアプリの中で活用していくと良いかという視点から常に考察していく姿勢は崩すことの内容にしていきたいと感じている次第です。そして来年もまた引き続き何卒よろしくお願い致します。

43
40
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
43
40