[UIKit] Animations: Shake Animations

Junyoung Park·2022년 12월 14일
0

UIKit

목록 보기
117/142
post-thumbnail
post-custom-banner

iOS Dev 37: Animating Layers with Core Animation | Swift 5, XCode 13

Animations: Shake Animations

구현 목표

  • 이미지 뷰 회전 및 라디우스 변경 애니메이션 구현
  • 텍스트 필드 흔들리는 애니메이션 구현

구현 태스크

  • 로그인 UI 구현(텍스트 필드, 버튼, 이미지 뷰 등)
  • 키패스를 통한 애니메이션 필드 지정
  • 상황에 따른 애니메이션 함수 연결

핵심 코드

private func rotateImage() {
        let animation = CABasicAnimation(keyPath: "transform.rotation.z")
        animation.fromValue = 0
        animation.toValue = CGFloat.pi * 2
        animation.duration = 2.0
        animation.repeatCount = .infinity
        animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
        imageView.layer.add(animation, forKey: nil)
    }
  • 키패스를 통해 회전 영역의 애니메이션을 지정
  • 0도에서 2파이 크기까지 2초에 걸쳐 무한번 반복하는 애니메이션
  • 2초를 한 번으로 봤을 때 easeInEaseOut 효과가 적용됨으로써 완료가 된 이후 다시 시작할 때까지의 텀
private func animateRadius() {
        let animation = CABasicAnimation(keyPath: "cornerRadius")
        animation.fromValue = 0
        animation.toValue = 100
        animation.duration = 2.0
        animation.autoreverses = true
        animation.repeatCount = .infinity
        imageView.layer.add(animation, forKey: nil)
    }
  • 키패스가 cornerRadius로 이미지 뷰의 이미지 라디우스 값을 변경하는 애니메이션
  • autoreverses 값이 참이므로 원상복구가 되는 구조
private func shakeField(_ textField: UITextField) {
        let animation = CAKeyframeAnimation(keyPath: "position.x")
        animation.values = [0, 10, -10, 10, 0]
        animation.keyTimes = [0, 0.08, 0.24, 0.415, 0.5]
        animation.duration = 0.5
        animation.fillMode = .forwards
        animation.isRemovedOnCompletion = false
        animation.isAdditive = true
        textField.layer.add(animation, forKey: nil)
    }
  • 특정 텍스트 필드의 x축 값을 변경하는 애니메이션 함수
  • x축 상수 값이 원점, 왼쪽, 오른쪽, 왼쪽, 원점으로 이어짐
  • 해당 위치에 들어가는 시간 또한 keyTimes으로 지정, values와 연관
  • isAdditive 값이 참이므로 현재 주어진 텍스트 필드의 위치에 해당 x값이 추가되는 구조
@objc private func didTapButton() {
        guard validateEmail() else {
            shakeField(emailField)
            return
        }
        guard validatePassword() else {
            shakeField(passwordField)
            return
        }
        DispatchQueue.main.async { [weak self] in
            self?.rotateImage()
            self?.animateRadius()
        }
    }
  • 흔들리는 애니메이션을 줄 텍스트 필드만을 별도로 파라미터로 넘기기

소스 코드

import UIKit

class ViewController: UIViewController {
    private let imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFill
        imageView.layer.masksToBounds = true
        return imageView
    }()
    private let emailField: UITextField = {
        let textField = UITextField()
        textField.placeholder = "Enter Email"
        textField.textColor = .label
        textField.borderStyle = .roundedRect
        return textField
    }()
    private let passwordField: UITextField = {
        let textField = UITextField()
        textField.placeholder = "Enter Password"
        textField.textColor = .label
        textField.borderStyle = .roundedRect
        textField.textContentType = .newPassword
        textField.isSecureTextEntry = true
        return textField
    }()
    private lazy var textFieldStackView: UIStackView = {
        let stackView = UIStackView(arrangedSubviews: [emailField, passwordField])
        stackView.axis = .vertical
        stackView.spacing = 30
        stackView.distribution = .equalSpacing
        return stackView
    }()
    private let loginButton: UIButton = {
        let button = UIButton()
        var config = UIButton.Configuration.filled()
        config.baseBackgroundColor = .systemBlue
        config.baseForegroundColor = .white
        config.title = "LOGIN"
        button.configuration = config
        return button
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
        downloadImage()
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        imageView.frame = CGRect(x: (view.frame.size.width - view.safeAreaInsets.left - view.safeAreaInsets.right - 200) / 2, y: view.safeAreaInsets.top + 50, width: 200, height: 200)
        imageView.layer.cornerRadius = 200 / 2
    }
    
    private func setUI() {
        view.backgroundColor = .systemBackground
        view.addSubview(imageView)
        view.addSubview(textFieldStackView)
        view.addSubview(loginButton)
        textFieldStackView.translatesAutoresizingMaskIntoConstraints = false
        let stackViewConstraints = [
            textFieldStackView.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 50),
            textFieldStackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 20),
            textFieldStackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -20),
        ]
        NSLayoutConstraint.activate(stackViewConstraints)
        loginButton.translatesAutoresizingMaskIntoConstraints = false
        let loginButtonConstraints = [
            loginButton.topAnchor.constraint(equalTo: textFieldStackView.bottomAnchor, constant: 30),
            loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            loginButton.widthAnchor.constraint(equalToConstant: 80),
            loginButton.heightAnchor.constraint(equalToConstant: 30)
        ]
        NSLayoutConstraint.activate(loginButtonConstraints)
        loginButton.addTarget(self, action: #selector(didTapButton), for: .touchUpInside)
    }
    
    private func downloadImage() {
        guard let url = URL(string: "https://pbs.twimg.com/media/E8oxWb0WEAEZmKx?format=png&name=small") else { return }
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            guard
                let data = data,
                let response = response as? HTTPURLResponse,
                response.statusCode >= 200 && response.statusCode < 400,
                error == nil else { return }
            DispatchQueue.main.async { [weak self] in
                self?.imageView.image = UIImage(data: data)
            }
        }
        .resume()
    }
    
    @objc private func didTapButton() {
        guard validateEmail() else {
            shakeField(emailField)
            return
        }
        guard validatePassword() else {
            shakeField(passwordField)
            return
        }
        DispatchQueue.main.async { [weak self] in
            self?.rotateImage()
            self?.animateRadius()
        }
    }
    
    private func shakeField(_ textField: UITextField) {
        let animation = CAKeyframeAnimation(keyPath: "position.x")
        animation.values = [0, 10, -10, 10, 0]
        animation.keyTimes = [0, 0.08, 0.24, 0.415, 0.5]
        animation.duration = 0.5
        animation.fillMode = .forwards
        animation.isRemovedOnCompletion = false
        animation.isAdditive = true
        textField.layer.add(animation, forKey: nil)
    }
    
    private func validateEmail() -> Bool {
        guard
            let text = emailField.text,
            !text.isEmpty else { return false }
        let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,30}"
        let emailPred = NSPredicate(format: "SELF MATCHES %@", emailRegEx)
        return emailPred.evaluate(with: text)
    }
    
    private func validatePassword() -> Bool {
        guard
            let text = passwordField.text,
            text.count >= 8 else { return false }
        return true
    }
    
    private func rotateImage() {
        let animation = CABasicAnimation(keyPath: "transform.rotation.z")
        animation.fromValue = 0
        animation.toValue = CGFloat.pi * 2
        animation.duration = 2.0
        animation.repeatCount = .infinity
        animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
        imageView.layer.add(animation, forKey: nil)
    }
    
    private func animateRadius() {
        let animation = CABasicAnimation(keyPath: "cornerRadius")
        animation.fromValue = 0
        animation.toValue = 100
        animation.duration = 2.0
        animation.autoreverses = true
        animation.repeatCount = .infinity
        imageView.layer.add(animation, forKey: nil)
    }
}

구현 화면

profile
JUST DO IT
post-custom-banner

0개의 댓글