[Swift] 利用CAShapeLayer完成iOS圖形密碼驗證

!!!文末含有當初遇到的大坑!!!

利用collectionView完成此功能

先建立一個collectionView的類別以及該類別的delegate
delegate含有移動時、選到cell後、結束滑動時動作,這三個function

protocol GestureCollectionViewDelegate: AnyObject {
    func move(point: CGPoint) /// 手指滑動時的反應
    func selectedItem(indexPath: IndexPath) //選取到cell時動作
    func moveEnded() //結束滑動時動作
}

class GestureCollectionView: UICollectionView {
    weak var gestureCollectionViewDelegate:GestureCollectionViewDelegate?
    
    open override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touchPoint = touches.first?.location(in: self) else { return }
        if let indexPath = self.indexPathForItem(at: touchPoint) {
            gestureCollectionViewDelegate?.selectedItem(indexPath: indexPath); return
        }
        gestureCollectionViewDelegate?.move(point: touchPoint)
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        gestureCollectionViewDelegate?.moveEnded()
    }
    
    
}

//
//  GesturePassword_ViewController.swift
//  BOT_iOS
//
//  Created by allenhung on 2021/7/28.
//

import UIKit

enum GesturePasswordType: Int {
    case setting = 0
    case unlock = 1
}


class GesturePassword_ViewController: UIViewController {
    
    @IBOutlet weak var gestureCollectionView: GestureCollectionView!
    private var selectedColor = #colorLiteral(red: 0.7843137255, green: 0.1450980392, blue: 0.3294117647, alpha: 1)
    private var unSelectedColor = #colorLiteral(red: 0.6677469611, green: 0.6677629352, blue: 0.6677542925, alpha: 1)
    
    //目前模式
    private var gesturePasswordType = GesturePasswordType.setting
    /// 目前滑到的最後一個點
    private var currentPoint: CGPoint?
    //當前畫出的密碼
    private var selectedPassword = [Int]()
    // 用來管理畫在 View 上的 Layer
    private var lineLayers = [CAShapeLayer]() {
        didSet {
            print(lineLayers.count)
        }
    }
    //設定一行一列有幾個解鎖點
    private var rowCount = 3
    //跟著手指移動的layer
    private var moveLayer: CAShapeLayer?
    
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupFakeGesturePassword()
        setupUI()
    }
    @IBAction func changeType(_ sender: UISegmentedControl) {
        guard let type = GesturePasswordType(rawValue: sender.selectedSegmentIndex) else { return }
        gesturePasswordType = type
    }
    //手勢事件
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        gestureCollectionView.touchesMoved(touches, with: event)
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        gestureCollectionView.touchesEnded(touches, with: event)
    }
}
extension GesturePassword_ViewController{
    private func setupUI(){
        setupTitle()
        setupGestureCollection()
    }
    private func setupTitle(){
        self.title = "圖形登入"
    }
    private func setupGestureCollection(){
        gestureCollectionView.dataSource = self
        gestureCollectionView.delegate = self
        gestureCollectionView.gestureCollectionViewDelegate = self
        gestureCollectionView.isUserInteractionEnabled = false
    }
    private func setupFakeGesturePassword(){
        UserDefaults.standard.set([0,1,2,3,4,5,6,7,8], forKey:"gesturePassword")
    }
}

extension GesturePassword_ViewController{
    /// 圖形鎖的外框顏色 (選到的 / 沒選到)
    private func gestureCollectionViewCellBorderColor(for indexPath: IndexPath) -> UIColor {
        let cellBorderColor = selectedPassword.contains(indexPath.row) ? selectedColor : unSelectedColor
        return cellBorderColor
    }
    /// 畫線的路徑
    private func shapeLayerPath(from point1: CGPoint, to point2: CGPoint) -> UIBezierPath {
        
        let bezierPath = UIBezierPath()
        
        bezierPath.move(to: point1)
        bezierPath.addLine(to: point2)
        
        return bezierPath
    }
    /// 清除所有資料 (Layer / Password)
    private func clearAllData() {
        
        lineLayers.forEach { (layer) in
            layer.removeFromSuperlayer()
        }
        
        moveLayer?.removeFromSuperlayer()
        
        lineLayers.removeAll()
        selectedPassword.removeAll()
        
        moveLayer = nil
        currentPoint = nil
        
        gestureCollectionView.reloadSections(IndexSet(integer: 0))
    }
    /// 設定畫線的Layer (有舊的就用舊的,不然就產生新的)
    private func lockLayerSetting(_ layer: CAShapeLayer? = nil, for path: UIBezierPath, color: UIColor) -> CAShapeLayer {
        
        let _layer = (layer != nil) ? layer! : CAShapeLayer()
        
        _layer.frame = gestureCollectionView.bounds
        _layer.position = gestureCollectionView.center
        _layer.fillColor = nil
        _layer.lineWidth = 10
        _layer.opacity = 0.5
        _layer.strokeColor = color.cgColor
        _layer.lineCap = .round
        _layer.path = path.cgPath
        
        return _layer
    }
}
//MARK:-主要畫線的方法
extension GesturePassword_ViewController{
    /// 畫圖形鎖的線 (完成時 )
    private func drawLockLayerForSelected(to point: CGPoint) {
        
        if let _currentPoint = currentPoint {
            
            let layerPath = shapeLayerPath(from: _currentPoint, to: point)
            let lockShapeLayer = lockShapeLayerMaker(for: layerPath, color: selectedColor)
            lineLayers.append(lockShapeLayer)
            view.layer.addSublayer(lockShapeLayer)
        }
        currentPoint = point
    }
    
    /// 畫圖形鎖的線 (移動時 )
    private func drawLockLayerForMove(to point: CGPoint) {
        
        if let _currentPoint = currentPoint {
            
            let layerPath = shapeLayerPath(from: _currentPoint, to: point)
            
            if (moveLayer == nil) {
                moveLayer = lockShapeLayerMaker(for: layerPath, color: selectedColor)
                view.layer.addSublayer(moveLayer!)
                return
            }
            
            moveLayerSetting(for: layerPath, color: selectedColor)
        }
    }
    
    /// 產生畫線的Layer
    private func lockShapeLayerMaker(for path: UIBezierPath, color: UIColor) -> CAShapeLayer {
        return lockLayerSetting(for: path, color: color)
    }
    
    /// 設定moveLayer
    private func moveLayerSetting(for path: UIBezierPath, color: UIColor) {
        _ = lockLayerSetting(moveLayer, for: path, color: color)
    }
    
    /// 記錄選到的Password的值 (畫線)
    private func appendPassword(at indexPath: IndexPath) {
        
        guard !selectedPassword.contains(indexPath.row),
              let gestureCollectionViewCell = gestureCollectionView.cellForItem(at: indexPath)
        else {
            return
        }
        selectedPassword.append(indexPath.row)
        drawLockLayerForSelected(to: gestureCollectionViewCell.center)
        moveLayer?.removeFromSuperlayer()
        moveLayer = nil
        
        gestureCollectionView.reloadItems(at: [indexPath])
    }
}
//設定每個解鎖鈕的間隔距離
extension GesturePassword_ViewController:UICollectionViewDelegateFlowLayout{
    //設定cell間隔
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        let width = collectionView.bounds.width / CGFloat(rowCount * 2 - 1)
        return CGSize(width: width, height: width)
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        let width = collectionView.bounds.width / CGFloat(rowCount * 2 - 1)
        return width
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
        let width = collectionView.bounds.width / CGFloat(rowCount * 2 - 1)
        return width
    }
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
        return UIEdgeInsets.zero
    }
}

extension GesturePassword_ViewController:UICollectionViewDataSource {
    //設定幾乘幾的cell
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return rowCount * rowCount
    }
    //設定cell樣式
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "GestureCollectionViewCell", for: indexPath) as? GestureCollectionViewCell{
            cell.tag = indexPath.row
            cell.imageView.tintColor = gestureCollectionViewCellBorderColor(for: indexPath)
            return cell
        }
        return UICollectionViewCell()
    }
}
//實現GestureCollectionViewDelegater
extension GesturePassword_ViewController:GestureCollectionViewDelegate{
    func move(point: CGPoint) {
        drawLockLayerForMove(to: point)
    }
    
    func selectedItem(indexPath: IndexPath) {
        appendPassword(at: indexPath)
    }
    
    func moveEnded() {
        switch gesturePasswordType {
        case .setting:
            if selectedPassword.count >= 4 {
                UserDefaults.standard.set(self.selectedPassword, forKey:"gesturePassword")
                self.showCustomAlertWithIcon(message: "設定圖形密碼成功", buttonTitle: "確認"
                     , tapButtonAcction: {
                        self.dismiss(animated: true, completion: nil)
                     })
            } else {
                self.showCustomAlertWithIcon(message: "圖形密碼需至少連線4個\n請重新設定", buttonTitle: "確認"
                     , tapButtonAcction: {
                        self.dismiss(animated: true, completion: nil)
                     })
            }
        case .unlock:
            if selectedPassword == UserDefaults.standard.object(forKey: "gesturePassword") as! [Int]{
                self.showCustomAlertWithIcon(message: "解鎖成功", buttonTitle: "確認",icon: "checkmark.circle"
                     , tapButtonAcction: {
                        self.dismiss(animated: true, completion: nil)
                     })
            } else {
                lineLayers.forEach { (layer) in
                    layer.strokeColor = UIColor.red.cgColor
                }
                gestureCollectionView.visibleCells.forEach { (cell) in
                    cell.layer.borderColor = UIColor.red.cgColor
                }
                moveLayer?.strokeColor = UIColor.red.cgColor
                self.showCustomAlertWithIcon(message: "解鎖失敗", buttonTitle: "確認",icon: "multiply.circle"
                     , tapButtonAcction: {
                        self.dismiss(animated: true, completion: nil)
                        
                     })
            }
        }
        clearAllData()
    }
}

*需要注意
如果將collectionView用view包起來的話,會造成collectionView的中心點座標偏移,畫出來的線就不會在cell的中心,當初花了很久時間才發現這個問題