0
3

【SwiftUI】Face ID対応のロック画面を実装してみた(iPhoneのロック画面風)

Last updated at Posted at 2024-05-31

1. はじめに

初めまして、たなぼうと申します。
記事を書いている現在は大学生であり、独学でiosアプリを開発をしています。
(※独学のためコードが美しくない可能性高)

今回のテーマは
・iPhoneのロック画面解除のような機能をアプリに搭載したい!
どうやれば良いのか少し悩んだので、共有する記事になります。

全コードは以下に載せてます。
https://github.com/RyotaLab/faceID.git

目次

1.完成図
2.手順紹介
3.実装(コード付き)
4.おわりに

1. 完成図

gif-faceid.gif

解除方法は

  • 4つのボタンで解除
  • FaceIDで解除

toggleボタンをtrueでパスコード作成、falseでパスコード解除になります。
デザインは私の趣味なので、自分で好きな色や形に変更してね!

2. 手順紹介

  1. faceIDを行うクラスや関数を作成
  2. 初期画面の分岐作成
  3. パスコード作成画面を実装
  4. ロック画面を実装

faceIDに関しては他の有識者の記事の方が分かりやすいため、解説は省きます。
正直、classをコピペすれば問題なく動くので、「より理解したい方」のみ以下の記事がおすすめです。

LocalAuthenticationを使用して生体認証を試してみた

今回はロック画面をどうやって作成するかをメインに解説します。

3. 実装

それでは始めていきましょう!
(紹介するコードをコピペしていけば、完成図のようなアプリが作れます)

3-1. faceIDのクラス作成

生体認証を行うために、info.plistに「Privacy - Face ID Usage Description」を追加する必要があります。Valueには生体認証を使う理由を書きましょう。

スクリーンショット 2024-05-31 14.12.52.png
次にファイルを作成して、faceIDを行うclassを記述します。
以下をコピペでOK!

━━━━━━━━━━━━━━━━━━┓
┃    ソースコードを表示(折りたたみ)   ┃
┗━━━━━━━━━━━━━━━━━━┛
FaceAuth
import Foundation
import LocalAuthentication

class FaceAuth {
    
    //認証のAPIを提供するLocalAuthenticationContext
    var context: LAContext = LAContext()
    
    //認証ポップアップに表示するメッセージ
    let reason = "FaceID"
    
    //FaceIDの処理
    func auth(complation:@escaping(Bool) -> Void) {
        
        //認証できるかのチェック
        if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: nil){
            
            //認証始め
            context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason ){ success, error in
                
                //成功
                if success{
                    //successが分かってからクロージャを呼び出す
                    DispatchQueue.main.async {
                        complation(true)
                    }
                    
                //失敗
                }else if let laError = error as? LAError{
                    
                    switch laError.code {
                    //認証失敗
                    case .authenticationFailed:
                        complation(false)
                        break
                    //ユーザーがキャンセルボタンを押した時
                    case .userCancel:
                        complation(false)
                        break
                    //アプリを閉じて失敗
                    case .systemCancel:
                        complation(false)
                        break
                    //パスコードがセットされてない
                    case .passcodeNotSet:
                        complation(false)
                        break
                    //パスコード認証がpolicyで許可されてない場合
                    case .userFallback:
                        complation(false)
                        break
                    //指紋認証の失敗上限
                    case .touchIDNotAvailable:
                        complation(false)
                        break
                    //指紋認証が許可されてない
                    case .touchIDNotEnrolled:
                        complation(false)
                        break
                    //指紋認証が登録されてない
                    case .touchIDLockout:
                        complation(false)
                        break
                    //アプリの内部によるキャンセル
                    case .appCancel:
                        complation(false)
                        break
                    //不明なエラー
                    case .invalidContext:
                        complation(false)
                        break
                    //よくわからん
                    case .notInteractive:
                        complation(false)
                        break
                        
                    @unknown default:
                        break
                    }
                }
            }
        }else {
            //生体認証ができない場合
        }
    }
}

今作成したclassを参照すれば、いつでもfaceIDは使えます!

おまけ:使い方の例を表示(折りたたみ)
let face:FaceAuth = FaceAuth()

//この関数を実行すれば生体認証が行われる
func exec() {
    face.auth{ result in
        if result == true {
            //成功した時の処理をここに記述

        }
    }
}

3-2. 初期画面の分岐作成

もしパスコードが設定されている場合、ロック画面に移動します。

passcheck.png

@ mainが記述されているファイルを次のように変更します。

━━━━━━━━━━━━━━━━━━┓
┃    ソースコードを表示(折りたたみ)      ┃
┗━━━━━━━━━━━━━━━━━━┛
faceIDApp
import SwiftUI

@main
struct faceIDApp: App {
    //何もない時はfalse
    let SetPass = UserDefaults.standard.bool(forKey: "SetPass")
    
    var body: some Scene {
        WindowGroup {
            if SetPass{
                //パスコードあり
                LockView()
            }else{
                //パスコードなし
                ContentView()
                    .environmentObject(passCheck())
            }
        }
    }
}

UserDefaultsの"SetPass"にて、ユーザーがパスコードを作成したかを判断し、最初に開く画面を変えます。
それだけです。

また、今後使う「1回目と2回目の入力が一致しているかを確認するためのclass」も用意しておきます。

━━━━━━━━━━━━━━━━━━┓
┃    classを表示(折りたたみ)        ┃
┗━━━━━━━━━━━━━━━━━━┛
passCheck

import Foundation

class passCheck: ObservableObject {
    @Published var firstCheck:[String?] = [nil, nil, nil, nil]
    @Published var secondCheck:[String?] = [nil, nil, nil, nil]
    
    @Published var passText:String = "パスワードを忘れると復元できません\n忘れないようご注意ください"
}

3-3. パスコード作成画面を実装

passcheck.png

機能:パスコードを2回入力し、一致した時のみパスコード作成
画面の流れ:最初の画面(ContentView) → 1回目の入力画面(SetLockView1) → 2回目の入力画面(SetLockView2)

3つの画面のコードは以下の通りです。

━━━━━━━━━━━━━━━━━━┓
┃    ContentViewを表示(折りたたみ)    ┃
┗━━━━━━━━━━━━━━━━━━┛
ContentView
import SwiftUI

enum LockPath {
    case pathSub1
    case pathSub2
}

struct ContentView: View {
    @State private var navigatePath: [LockPath] = []
    //何もない時はfalse
    @State var toggle = UserDefaults.standard.bool(forKey: "SetPass")
    
    var body: some View {
        NavigationStack(path: $navigatePath) {
            List{
                //下のNavigationLinkは関係ない
                NavigationLink(destination: Text("機能なし")) {
                    Text("サンプル")
                }
                //パスコード作成 off -> onの時のみ画面遷移
                Toggle("パスコード設定",isOn: $toggle)
                    .onAppear{
                        toggle = UserDefaults.standard.bool(forKey: "SetPass")
                    }
                    .onChange(of: toggle) {
                        if toggle {
                            //パスワード設定画面へ
                            navigatePath.append(.pathSub1)
                        }else{
                            //pass解除
                            UserDefaults.standard.set(false, forKey: "SetPass")
                            UserDefaults.standard.set(false, forKey: "UseFaceID")
                        }
                    }
            }
            .navigationDestination(for: LockPath.self){ value in
                switch value {
                    
                case .pathSub1:
                    SetLockView1(path: $navigatePath)
                case .pathSub2:
                    SetLockView2(path: $navigatePath)
                }
            }
        }//navigationStack
        
    }
}
━━━━━━━━━━━━━━━━━━┓
┃    SetLockView1を表示(折りたたみ)    ┃
┗━━━━━━━━━━━━━━━━━━┛
SetLockView1
import SwiftUI

struct SetLockView1: View {
    @Binding var path: [LockPath]
    
    @Environment(\.dismiss) var dismiss
    @EnvironmentObject var passcheck: passCheck
    @State var count = 0
    
    var body: some View {
        VStack{
            Spacer()
            Text("パスコードの入力")
                .font(.title3)
                .fontWeight(.bold)

            HStack{
                //黒丸
                ForEach(0..<4) { index in
                    if passcheck.firstCheck[index] == nil {
                        Image(systemName: "circle")
                            .padding()
                    }else {
                        Image(systemName: "circle.fill")
                            .padding()
                    }
                }
            }
            
            //注意事項
            Text("\(passcheck.passText)")
                .font(.footnote)
                .multilineTextAlignment(.center)
                .foregroundColor(Color.pink)
            Spacer()
            
            //入力ボタン
            HStack{
                ForEach(1..<4){ i in
                    Button{
                        inputText(number: String(i))
                    }label:{
                        Text("\(i)")
                            .font(.title)
                            .frame(width: 90, height: 45)
                            .foregroundColor(Color(.orange))
                            .cornerRadius(5)
                            .overlay(
                                RoundedRectangle(cornerRadius: 5)
                                    .stroke(Color(.orange), lineWidth: 1.0)
                            )
                    }
                }
            }
            HStack{
                ForEach(4..<7){ i in
                    Button{
                        inputText(number: String(i))
                    }label:{
                        Text("\(i)")
                            .font(.title)
                            .frame(width: 90, height: 45)
                            .foregroundColor(Color(.orange))
                            .cornerRadius(5)
                            .overlay(
                                RoundedRectangle(cornerRadius: 5)
                                    .stroke(Color(.orange), lineWidth: 1.0)
                            )
                    }
                }
            }
            HStack{
                ForEach(7..<10){ i in
                    Button{
                        inputText(number: String(i))
                    }label:{
                        Text("\(i)")
                            .font(.title)
                            .frame(width: 90, height: 45)
                            .foregroundColor(Color(.orange))
                            .cornerRadius(5)
                            .overlay(
                                RoundedRectangle(cornerRadius: 5)
                                    .stroke(Color(.orange), lineWidth: 1.0)
                            )
                    }
                }
            }
            Button{
                inputText(number: "0")
            }label:{
                Text("0")
                    .font(.title)
                    .frame(width: 90, height: 45)
                    .foregroundColor(Color(.orange))
                    .cornerRadius(5)
                    .overlay(
                        RoundedRectangle(cornerRadius: 5)
                            .stroke(Color(.orange), lineWidth: 1.0)
                    )
            }
            
            //入力ボタン終
            Spacer()
        }
        .navigationTitle("入力")
        .navigationBarBackButtonHidden()
        .toolbar {
            ToolbarItem(placement: .topBarLeading){
                Button{
                    //リストに戻り、初期化
                    passcheck.firstCheck = [nil, nil, nil, nil]
                    passcheck.passText = "パスワードを忘れると復元できません\n忘れないようご注意ください"
                    path.removeLast()
                }label:{
                    Image(systemName: "arrowshape.turn.up.backward.fill")
                }
            }
        }
    }
    //ボタンが押された時の関数
    private func inputText(number : String) {
        for (index, getText) in passcheck.firstCheck.enumerated() {
            //nilかチェック → 入力済みならスキップ
            //入力したらfor文を抜け出す
            if getText == nil {
                
                passcheck.firstCheck[index] = number
                //もしi.index = 3なら 画面遷移
                if index == 3 {
                    DispatchQueue.main.asyncAfter(deadline: .now()+0.1) {
                        path.append(.pathSub2)
                    }
                }
                
                break
            }
        }
    }
}
━━━━━━━━━━━━━━━━━━┓
┃    SetLockView2を表示(折りたたみ)    ┃
┗━━━━━━━━━━━━━━━━━━┛
SetLockView2
import SwiftUI

struct SetLockView2: View{
    @Binding var path: [LockPath]
    
    @Environment(\.dismiss) var dismiss
    @EnvironmentObject var passcheck: passCheck
    
    @State var isShowAlert = false
    @State var passCode = ""
    
    var body: some View{
        VStack{
            Spacer()
            Text("パスコードの再入力")
                .font(.title3)
                .fontWeight(.bold)
            
            //黒丸or白丸
            HStack{
                ForEach(0..<4) { index in
                    if passcheck.secondCheck[index] == nil {
                        Image(systemName: "circle")
                            .padding()
                    }else {
                        Image(systemName: "circle.fill")
                            .padding()
                    }
                }
            }
            
            //注意事項
            Text("\(passcheck.passText)")
                .font(.footnote)
                .multilineTextAlignment(.center)
                .foregroundColor(Color.clear)
            Spacer()
            //入力ボタン
            HStack{
                ForEach(1..<4){ i in
                    Button{
                        inputText(number: String(i))
                    }label:{
                        Text("\(i)")
                            .font(.title)
                            .frame(width: 90, height: 45)
                            .foregroundColor(Color(.orange))
                            .cornerRadius(5)
                            .overlay(
                                RoundedRectangle(cornerRadius: 5)
                                    .stroke(Color(.orange), lineWidth: 1.0)
                            )
                    }
                }
            }
            HStack{
                ForEach(4..<7){ i in
                    Button{
                        inputText(number: String(i))
                    }label:{
                        Text("\(i)")
                            .font(.title)
                            .frame(width: 90, height: 45)
                            .foregroundColor(Color(.orange))
                            .cornerRadius(5)
                            .overlay(
                                RoundedRectangle(cornerRadius: 5)
                                    .stroke(Color(.orange), lineWidth: 1.0)
                            )
                    }
                }
            }
            HStack{
                ForEach(7..<10){ i in
                    Button{
                        inputText(number: String(i))
                    }label:{
                        Text("\(i)")
                            .font(.title)
                            .frame(width: 90, height: 45)
                            .foregroundColor(Color(.orange))
                            .cornerRadius(5)
                            .overlay(
                                RoundedRectangle(cornerRadius: 5)
                                    .stroke(Color(.orange), lineWidth: 1.0)
                            )
                    }
                }
            }
            Button{
                inputText(number: "0")
            }label:{
                Text("0")
                    .font(.title)
                    .frame(width: 90, height: 45)
                    .foregroundColor(Color(.orange))
                    .cornerRadius(5)
                    .overlay(
                        RoundedRectangle(cornerRadius: 5)
                            .stroke(Color(.orange), lineWidth: 1.0)
                    )
            }
            //入力ボタン終
            Spacer()
        }//VStack
        .navigationTitle("確認入力")
        .navigationBarBackButtonHidden()
        .toolbar {
            ToolbarItem(placement: .topBarLeading){
                Button{
                    //リストに戻り、初期化
                    passcheck.firstCheck = [nil, nil, nil, nil]
                    path.removeLast()
                }label:{
                    Image(systemName: "arrowshape.turn.up.backward.fill")
                }
            }
        }
        //パスコードが一致した時のアラート
        .alert(Text("FaceIdを使いますか?"), isPresented: $isShowAlert){
            //ルートへ
            Button("はい"){
                UserDefaults.standard.set(true, forKey: "UseFaceID")
                path.removeLast(path.count)
                passcheck.firstCheck = [nil, nil, nil, nil]
                passcheck.secondCheck = [nil, nil, nil, nil]
            }
            Button("いいえ"){
                UserDefaults.standard.set(false, forKey: "UseFaceID")
                path.removeLast(path.count)
                passcheck.firstCheck = [nil, nil, nil, nil]
                passcheck.secondCheck = [nil, nil, nil, nil]
            }
        }
        
    }//body
    //ボタンが押された時の関数
    private func inputText(number : String) {
        for (index, getText) in passcheck.secondCheck.enumerated() {
            //nilかチェック → 入力済みならスキップ
            //入力したらfor文を抜け出す
            if getText == nil {
                
                passcheck.secondCheck[index] = number
                //全入力された時
                if index == 3 {
                    if passcheck.firstCheck == passcheck.secondCheck {
                        //同じなら保存, alert(faceID)、
                        for i in 0...3 {
                            passCode = passCode + (passcheck.firstCheck[i] ?? "")

                        }
                        DispatchQueue.main.asyncAfter(deadline: .now()+0.1) {
                            UserDefaults.standard.set(true, forKey: "SetPass")
                            UserDefaults.standard.set(passCode, forKey: "password")
                            print(passCode)
                            isShowAlert = true
                        }
                        
                    }else{
                        //違うなら初期化して1つ前へ
                        DispatchQueue.main.asyncAfter(deadline: .now()+0.1) {
                            passcheck.passText = "パスワードが1回目と異なります\nもう一度行ってください"
                            passcheck.firstCheck = [nil, nil, nil, nil]
                            passcheck.secondCheck = [nil, nil, nil, nil]
                            path.removeLast()
                        }
                    }
                }
                break
            }
        }
    }
}

長々とコードがありますが、ほとんどデザイン関連なので気負わないでね!
数字ボタンを押したときに呼ばれるinputText(number : String)が機能の9割です。
急ぎの方はコピペでOKです。
一様ポイントを3つ解説しておきますね。

3-3-1. NavigationStack(path: $~){}を使用すること

SetLockView2からContentViewに戻るとき、2画面を一気に移動する必要があります。
これをスムーズに行うのがNavigationStack(path: $~){}です。
画面遷移の記録がストックされていきます(なんて便利!)

//画面遷移
navigatePath.append(~)
//1つ前に戻る
path.removeLast()
//全部戻る
path.removeLast(path.count)

3-3-2. DispatchQueue.main.asyncAfter(deadline: .now()+0.1){}について

画面遷移をする際に、0.1秒だけ遅らせています。
理由は入力を反映した上で、画面遷移を行いたいから
0.1秒遅らせないと、ユーザー視点では4つ目を入力した瞬間に遷移してしまい、4つ目を入力した感覚がありません(自分で実験すればわかります笑)

//画面遷移
 DispatchQueue.main.asyncAfter(deadline: .now()+0.1) {
    path.append(.~)
}

3-3-3. パスコード作成時にUserDefaultsに3つ保存する

  • key = "password":パスコードに設定した数字4つ
  • key = "SetPass":パスコードが設定されているか判断
  • key = "UseFaceID":faceIDを使うか

kya名はお好みで!

3-4. パスコード解除画面を実装

最後にロック解除画面を作成します。
ほとんどパスコード設定画面と同じですね。

━━━━━━━━━━━━━━━━━━┓
┃    LockViewを表示(折りたたみ )       ┃
┗━━━━━━━━━━━━━━━━━━┛
LockView
import SwiftUI

struct LockView: View {
    
    @State var passCheck: [String?] = [nil, nil, nil, nil]

    //->trueでcontentViewを表示
    @State var isShow = false

    //設定したパスワード
    let answer = UserDefaults.standard.string(forKey: "password")

    //ficeID関連
    let face:FaceAuth = FaceAuth()
    let useFaceID = UserDefaults.standard.bool(forKey: "UseFaceID")
    
    
    var body: some View {
        ZStack{
            VStack{
                Spacer()
                Text("パスコードの入力")
                    .font(.title3)
                    .fontWeight(.bold)
                //ボタンやらもろもろ
                HStack{
                    //黒丸
                    ForEach(0..<4) { index in
                        if passCheck[index] == nil {
                            Image(systemName: "circle")
                                .padding()
                        }else {
                            Image(systemName: "circle.fill")
                                .padding()
                        }
                    }
                }
                    Spacer()
                //入力ボタン
                HStack{
                    ForEach(1..<4){ i in
                        Button{
                            inputText(number: String(i))
                        }label:{
                            Text("\(i)")
                                .font(.title)
                                .frame(width: 90, height: 45)
                                .foregroundColor(Color(.orange))
                                .cornerRadius(5)
                                .overlay(
                                    RoundedRectangle(cornerRadius: 5)
                                        .stroke(Color(.orange), lineWidth: 1.0)
                                )
                        }
                    }
                }
                HStack{
                    ForEach(4..<7){ i in
                        Button{
                            inputText(number: String(i))
                        }label:{
                            Text("\(i)")
                                .font(.title)
                                .frame(width: 90, height: 45)
                                .foregroundColor(Color(.orange))
                                .cornerRadius(5)
                                .overlay(
                                    RoundedRectangle(cornerRadius: 5)
                                        .stroke(Color(.orange), lineWidth: 1.0)
                                )
                        }
                    }
                }
                HStack{
                    ForEach(7..<10){ i in
                        Button{
                            inputText(number: String(i))
                        }label:{
                            Text("\(i)")
                                .font(.title)
                                .frame(width: 90, height: 45)
                                .foregroundColor(Color(.orange))
                                .cornerRadius(5)
                                .overlay(
                                    RoundedRectangle(cornerRadius: 5)
                                        .stroke(Color(.orange), lineWidth: 1.0)
                                )
                        }
                    }
                }
                Button{
                    inputText(number: "0")
                }label:{
                    Text("0")
                        .font(.title)
                        .frame(width: 90, height: 45)
                        .foregroundColor(Color(.orange))
                        .cornerRadius(5)
                        .overlay(
                            RoundedRectangle(cornerRadius: 5)
                                .stroke(Color(.orange), lineWidth: 1.0)
                        )
                }
                Spacer()
                //入力ボタン終
                .onAppear{
                    //faceidをするかどうか
                    if useFaceID {
                        exec()
                    }
                }
            }
            
            if isShow {
                ContentView()
                    .environmentObject(faceID.passCheck())
                    .transition(.opacity)
            }
        }
    }
    //入力関数
    private func inputText(number : String) {
        var checkAnswer = ""
        
        for (index, getText) in passCheck.enumerated() {
            //nilかチェック → 入力済みならスキップ
            //入力したらfor文を抜け出す
            if getText == nil {
                
                passCheck[index] = number
                if index == 3 {
                    for i in 0...3 {
                        
                        checkAnswer = checkAnswer + (passCheck[i] ?? "")
                    }
                    if checkAnswer == answer {
                        //一致
                        DispatchQueue.main.asyncAfter(deadline: .now()+0.3) {
                            withAnimation{
                                isShow.toggle()
                            }
                        }
                        
                    }else{
                        //初期化
                        passCheck = [nil, nil, nil, nil]
                    }
                }
                
                break
            }
        }
    }
    //顔認証の関数
    func exec() {
        face.auth{ result in
            //認証が成功した時の記述
            if result == true {
                passCheck = ["a", "a", "a", "a"]
                DispatchQueue.main.asyncAfter(deadline: .now()+0.1) {
                    withAnimation{
                        isShow.toggle()
                    }
                }
            }
        }
    }
}

機能としては、画面を開いたときにfaceID認証。もしfaceIDが「ダメor設定なし」ならボタン入力という形です。
入力後は、ZStackでContentViewを表示する形します。
2つのポイントを解説します。

3-4-1. faceID関連の説明

以下のexec()を使用すれば、好きなタイミングでfaceID認証が行えます。
私の場合はonAppearの中に記述し、画面表示した際に認証を行なっています。

let face:FaceAuth = FaceAuth()

func exec() {
    face.auth{ result in
        if result == true {
            //認証を成功した時の記述
        }
    }
}

3-4-2. withAnimation{}の必要性

ZStackで画面遷移を行なっているため、withAnimation{}がないと、画面遷移がオシャレではありません(個人の感想)。パッ!パッ!で感じで遷移してしまうんですよね。
私は.transition(.opacity)を使用しています。←ゆっくり透明化
(withAnimationはお好みで色々試してみてね)

4. おわりに

今までのコードをコピペすれば、完成図のように動きます。プライバシーを守りたいアプリを作りたい方は参考にしてみてください!

全コードは以下に載せてます。
https://github.com/RyotaLab/faceID.git

最後に宣伝です。

このような誕生日を通知するiosアプリをリリースしています。
誕生日book-通知とメモをリストで管理
スクリーンショット 2024-05-31 17.00.47.png
今回紹介したパスコード機能もあります。是非使ってみてください!

0
3
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
0
3