Slide to unlickのUIを自作する

はじめに

昔、顔認証も指紋認証もない時代のiPhoneのロックを解除する際のSlide to unlockを作ってみました。

MTSlideToOpenというライブラリもあってカスタムする必要のない場合はこのライブラリを使っておけばOKだと思います。
個人的にレイアウト周りのライブラリはメンテされなくなったりした時にめんどくさいことになる経験が多く使いたくないので自作しました。

UIの構成を考える

まずは構成を考える必要がありますが、今回は以下の作りでUIを作成しました。

  • BackgroundView: 赤い背景でlabelを保持しているViewでドラックされると隠れていく
  • DragAreaView: ドラックされた領域の分だけ範囲を塗りつぶすView
  • ThumbnailImageView: ユーザによってスワイプされる対象のView

書き出してみるとだいぶシンプルになりました。あとはレイアウトとスワイプした時の挙動をコードに落とし込んで実装していくだけです。

実装

ユーザによってViewがドラックされた処理を行うにはUIPanGestureRecognizerを使うのが一般的だと思います。touchesMovedでも取ることは可能だともいますが処理が煩雑になってしまうのであまりおすすめしません。

UIPanGestureRecognizerのイベントを拾う部分は以下のように処理を行なっています、ドラッグされている時(.changed)とドラックが終了した時(.ended)で処理を行っています。

@objc private func handlePanGesture(_ sender: UIPanGestureRecognizer) {
    if isFinished {
        return
    }
    let xEndingPoint = (self.bounds.width - thumnailImageView.bounds.width)
    let translatedPoint = sender.translation(in: self).x
    switch sender.state {
    case .changed:
        if translatedPoint >= xEndingPoint {
            updateThumbnailPosition(xEndingPoint)
            return
        }
        if translatedPoint <= 0 {
            updateThumbnailPosition(0)
            return
        }
        updateThumbnailPosition(translatedPoint)
    case .ended:
        if translatedPoint >= xEndingPoint {
            updateThumbnailPosition(xEndingPoint)
            isFinished = true
            delegate?.didFinish(self)
            return
        }

        let animationVelocity: Double = 0.5
        UIView.animate(withDuration: animationVelocity) {
            self.leadingThumbnailViewConstraint?.constant = 0
            self.layoutIfNeeded()
        }
    default:
        break
    }
}

.changed

こちらはシンプルで変更を通知した際ドラッグしている範囲がインジケータ部分の中だったらサムネイル画像の座標を更新、して左右どちらかに飛び出している時は0かmaxの座標にサムネイルが表示されるようにしています。

.ended

slide to unlockのUIでは完了した時になんらかの処理を送る必要があると思うのでdelegateで処理を伝播するのと、中途半端な位置で手を離された時に元に戻す処理を入れています。

元に戻す処理ですが、アニメーションなしでパッと戻るとすごく不自然なのでアニメーションで指定した秒数で元の位置に戻るようにしています。

コード一覧

protocol SlideToUnlickDelegate: AnyObject {
    func didFinish(_ sender: SlideToUnlockView)
}

class SlideToUnlockView: UIView {
    let backgroundView: UIView = {
        let view = UIView()
        view.backgroundColor = .red
        return view
    }()

    let textLabel: UILabel = {
        let label = UILabel.init()
        label.text = "Slide to unlock"
        label.textColor = .white
        label.textAlignment = .center
        return label
    }()

    let thumnailImageView: UIImageView = {
        let view = UIImageView(image: .init(systemName: "star"))
        view.backgroundColor = .blue
        view.isUserInteractionEnabled = true
        view.contentMode = .center
        return view
    }()

    let dragAreaView: UIView = {
        let view = UIView()
        view.backgroundColor = .green
        view.clipsToBounds = true
        view.layer.masksToBounds = true
        return view
    }()

    private var leadingThumbnailViewConstraint: NSLayoutConstraint?
    private var isFinished: Bool = false
    weak var delegate: SlideToUnlickDelegate?
    
    required public init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)!
        setupView()
    }
    
    @objc private func handlePanGesture(_ sender: UIPanGestureRecognizer) {
        if isFinished {
            return
        }
        let xEndingPoint = (self.bounds.width - thumnailImageView.bounds.width)
        let translatedPoint = sender.translation(in: self).x
        switch sender.state {
        case .changed:
            if translatedPoint >= xEndingPoint {
                updateThumbnailPosition(xEndingPoint)
                return
            }
            if translatedPoint <= 0 {
                updateThumbnailPosition(0)
                return
            }
            updateThumbnailPosition(translatedPoint)
        case .ended:
            if translatedPoint >= xEndingPoint {
                updateThumbnailPosition(xEndingPoint)
                // Finish action
                isFinished = true
                delegate?.didFinish(self)
                return
            }

            let animationVelocity: Double = 0.5
            UIView.animate(withDuration: animationVelocity) {
                self.leadingThumbnailViewConstraint?.constant = 0
                self.layoutIfNeeded()
            }
        default:
            break
        }
    }
}

extension SlideToUnlockView {
    private func updateThumbnailPosition(_ x: CGFloat) {
        leadingThumbnailViewConstraint?.constant = x
        setNeedsLayout()
    }
    
    private func setupView() {
        let cornerRadius = self.frame.height/2
        backgroundView.layer.cornerRadius = cornerRadius
        dragAreaView.layer.cornerRadius = cornerRadius
        thumnailImageView.layer.cornerRadius = cornerRadius
        
        self.addSubview(backgroundView)
        self.addSubview(dragAreaView)
        backgroundView.addSubview(textLabel)
        self.addSubview(thumnailImageView)

        setupConstraint()

        let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.handlePanGesture(_:)))
        panGestureRecognizer.minimumNumberOfTouches = 1
        thumnailImageView.addGestureRecognizer(panGestureRecognizer)
    }
    
    private func setupConstraint() {
        backgroundView.translatesAutoresizingMaskIntoConstraints = false
        backgroundView.topAnchor.constraint(equalTo: self.topAnchor).isActive = true
        backgroundView.leadingAnchor.constraint(equalTo: self.leadingAnchor).isActive = true
        backgroundView.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
        backgroundView.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
        
        textLabel.translatesAutoresizingMaskIntoConstraints = false
        textLabel.centerYAnchor.constraint(equalTo: backgroundView.centerYAnchor).isActive = true
        textLabel.centerXAnchor.constraint(equalTo: backgroundView.centerXAnchor).isActive = true
        textLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor).isActive = true
        textLabel.leadingAnchor.constraint(equalTo: backgroundView.leadingAnchor).isActive = true
        
        dragAreaView.translatesAutoresizingMaskIntoConstraints = false
        dragAreaView.leadingAnchor.constraint(equalTo: backgroundView.leadingAnchor).isActive = true
        dragAreaView.topAnchor.constraint(equalTo: backgroundView.topAnchor).isActive = true
        dragAreaView.centerYAnchor.constraint(equalTo: backgroundView.centerYAnchor).isActive = true
        dragAreaView.trailingAnchor.constraint(equalTo: thumnailImageView.trailingAnchor).isActive = true
        
        thumnailImageView.translatesAutoresizingMaskIntoConstraints = false
        thumnailImageView.topAnchor.constraint(equalTo: self.topAnchor).isActive = true
        thumnailImageView.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
        thumnailImageView.heightAnchor.constraint(equalTo: thumnailImageView.widthAnchor).isActive = true
        leadingThumbnailViewConstraint = thumnailImageView.leadingAnchor.constraint(equalTo: self.leadingAnchor)
        leadingThumbnailViewConstraint?.isActive = true
    }
}