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
}
}