系统学习iOS动画 —— 动画组, 时间控制, 图层弹簧动画

这是我参与11月更文挑战的第18天,活动详情查看:2021最后一次更文挑战

1. 动画组和时间控制

动画组可以对动画进行分组,可以向组中添加多个动画并同时调整持续时间,代理和timingFunction等属性。 对动画进行分组会产生简化的代码,并确保所有动画将作为一个实体单元同步。

这里创建一个CAAnimationGroup

    let formGroup = CAAnimationGroup()
    formGroup.duration = 0.5
    formGroup.fillMode = .backwards
复制代码

然后创建一个向右移动的动画和变化透明度的动画

  let flyRight = CABasicAnimation(keyPath: "position.x")
    flyRight.fromValue = -view.bounds.size.width/2
    flyRight.toValue = view.bounds.size.width/2

    let fadeFieldIn = CABasicAnimation(keyPath: "opacity")
    fadeFieldIn.fromValue = 0.25
    fadeFieldIn.toValue = 1.0
复制代码

为formGroup的animations赋值

    formGroup.animations = [flyRight, fadeFieldIn]
复制代码

然后为控件添加上这个动画

heading.layer.add(formGroup, forKey: nil)

    formGroup.delegate = self
    formGroup.setValue("form", forKey: "name")
    formGroup.setValue(username.layer, forKey: "layer")

    formGroup.beginTime = CACurrentMediaTime() + 0.3
    username.layer.add(formGroup, forKey: nil)

    formGroup.setValue(password.layer, forKey: "layer")
    formGroup.beginTime = CACurrentMediaTime() + 0.4
    password.layer.add(formGroup, forKey: nil)
复制代码

接下来为登陆按钮添加动画组

 let groupAnimation = CAAnimationGroup()
    groupAnimation.beginTime = CACurrentMediaTime() + 0.5
    groupAnimation.duration = 0.5
    groupAnimation.fillMode = .backwards

    let scaleDown = CABasicAnimation(keyPath: "transform.scale")
    scaleDown.fromValue = 3.5
    scaleDown.toValue = 1.0

    let rotate = CABasicAnimation(keyPath: "transform.rotation")
    rotate.fromValue = .pi / 4.0
    rotate.toValue = 0.0

    let fade = CABasicAnimation(keyPath: "opacity")
    fade.fromValue = 0.0
    fade.toValue = 1.0
    groupAnimation.animations = [scaleDown, rotate, fade]
    groupAnimation.timingFunction = CAMediaTimingFunction(name: .easeIn)
    loginButton.layer.add(groupAnimation, forKey: nil)
复制代码

2. 图层弹簧动画

下面解释来自于Andy_Ron

2.1 阻尼谐振子

阻尼谐振子,Damped harmonic oscillators(直译就是,逐渐衰弱的振荡器),可以理解为逐渐衰减的振动。 UIKit API简化了弹簧动画的制作,不需要了解它们的原理就可以很方便的使用。 但是,由于您现在是核心动画专家,因此您需要深入研究细节。 钟摆,理想状况下钟摆是不停的摆动,像下面的一样: 在这里插入图片描述

对应的运动轨迹图就像:

在这里插入图片描述

但现实中由于能量的损耗,钟摆的摇摆的幅度会逐渐减小:

在这里插入图片描述 对应的运动轨迹:

在这里插入图片描述

这就是一个阻尼谐振子 。 钟摆停下来所需的时间长度,以及最终振荡器图形的方式取决于振荡系统的以下参数:

  • 阻尼(damping):由于空气摩擦、机械摩擦和其他作用在系统上的外部减速力。

  • 质量(mass):摆锤越重,摆动的时间越长。

  • 刚度(stiffness):振荡器的“弹簧”越硬(钟摆的“弹簧”是指地球的引力),钟摆摆动越困难,系统停下来也越快。想象一下,如果在月球或木星上使用这个钟摆;在低重力和高重力情况下的运动将是完全不同的。

  • 初始速度(initial velocity):推一下钟摆。

2.2 视图弹簧动画 vs 图层弹簧动画

UIKit以动态方式调整所有其他变量,使系统在给定的持续时间内稳定下来。 这就是为什么UIKit弹簧动画有时有点被迫 停下来的感觉。 如果仔细观察会发现UIKit动画有点不太自然。 幸运的是,核心允许通过CASpringAnimation类为图层属性创建合适的弹簧动画。 CASpringAnimation在幕后为UIKit创建弹簧动画,但是当我们直接调用它时,可以设置系统的各种变量,让动画自己稳定下来。 这种方法的缺点是不能设置固定的持续时间(duration);持续时间取决于提供的其它变量,然后系统计算所得。

CASpringAnimation的一些属性(对应之前振荡系统的参数):

  • damping 阻尼系数,阻止弹簧伸缩的系数,阻尼系数越大,停止越快
  • mass 质量,影响图层运动时的弹簧惯性,质量越大,弹簧拉伸和压缩的幅度越大
  • stiffness 刚度系数(劲度系数/弹性系数),刚度系数越大,形变产生的力就越大,运动越快
  • initialVelocity 初始速率,动画视图的初始速度大小。速率为正数时,速度方向与运动方向一致,速率为负数时,速度方向与运动方向相反

2.3 图层弹簧动画应用

将之前在animationDidStop的动画

  let pulse = CABasicAnimation(keyPath: "transform.scale")
            pulse.fromValue = 1.25
            pulse.toValue = 1.0
            pulse.duration = 0.25
            layer?.add(pulse, forKey: nil)
复制代码

修改为

     let pulse = CASpringAnimation(keyPath: "transform.scale")
      pulse.damping = 7.5
      pulse.fromValue = 1.25
      pulse.toValue = 1.0
      pulse.duration = pulse.settlingDuration
      layer?.add(pulse, forKey: nil)
复制代码

运行后发现动画明显自然很多 继续将其他的动画也修改好: 将tintBackgroundColor里面的动画改为:

func tintBackgroundColor(layer: CALayer, toColor: UIColor) {
  let tint = CASpringAnimation(keyPath: "backgroundColor")
  tint.damping = 5.0
  tint.initialVelocity = -10.0
  tint.fromValue = layer.backgroundColor
  tint.toValue = toColor.cgColor
  tint.duration = tint.settlingDuration
  layer.add(tint, forKey: nil)
  layer.backgroundColor = toColor.cgColor
}
复制代码

将roundCorners里面的动画改为:

func roundCorners(layer: CALayer, toRadius: CGFloat) {
  let round = CASpringAnimation(keyPath: "cornerRadius")
  round.damping = 5.0
  round.fromValue = layer.cornerRadius
  round.toValue = toRadius
  round.duration = round.settlingDuration
  layer.add(round, forKey: nil)
  layer.cornerRadius = toRadius
}
复制代码

在textFieldDidEndEditing里面添加判断,如果输入小于5,那么就添加动画。

 func textFieldDidEndEditing(_ textField: UITextField) {
    guard let text = textField.text else { return }

    if text.count < 5 {
      // add animations here
      let jump = CASpringAnimation(keyPath: "position.y")
      jump.initialVelocity = 100.0
      jump.mass = 10.0
      jump.stiffness = 1500.0
      jump.damping = 50.0
      jump.fromValue = textField.layer.position.y + 1.0
      jump.toValue = textField.layer.position.y
      jump.duration = jump.settlingDuration
      textField.layer.add(jump, forKey: nil)

      textField.layer.borderWidth = 3.0
      textField.layer.borderColor = UIColor.clear.cgColor

      let flash = CASpringAnimation(keyPath: "borderColor")
      flash.damping = 7.0
      flash.stiffness = 200.0
      flash.fromValue = UIColor(red: 1.0, green: 0.27, blue: 0.0, alpha: 1.0).cgColor
      flash.toValue = UIColor.white.cgColor
      flash.duration = flash.settlingDuration
      textField.layer.add(flash, forKey: nil)

      textField.layer.cornerRadius = 5
    }
  }
复制代码

2.4 弹簧动画属性

CASpringAnimation预定义的弹簧动画属性的默认值分别是:

damping: 10.0
mass: 1.0
stiffness: 100.0
initialVelocity: 0.0
复制代码
  • initialVelocity: 附着在弹簧上的物体的初始速度。默认为零,表示一个不动的对象。负值表示对象远离弹簧附着点,正值表示对象向弹簧附着点移动。
  • mass:附着在弹簧末端的物体的质量。必须大于0。默认为1。数值越大,对象跳跃的更高,并且稳定下来的时间更久了。
  • stiffness : 弹簧刚度系数。必须大于0。默认为100。让跳跃高度降低。
  • damping: 阻尼系数。必须大于或等于0,默认为10。增加阻尼系数可以让动画更快地稳定下来。

完整代码:

//
//  ViewController.swift
//  AirLoginAnimation
//
//  Created by aibus on 2021/10/27.
//

import UIKit

class ViewController: UIViewController {
    
    let screenWidth = UIScreen.main.bounds.size.width
    let screenHeight = UIScreen.main.bounds.size.height
    let titleLabel = UILabel()
    let backgroundImage = UIImageView()
    let usernameTextField = TextField()
    let passwordTextField = TextField()
    let loginButton = UIButton()
    let cloud1 = UIImageView()
    let cloud2 = UIImageView()
    let cloud3 = UIImageView()
    let cloud4 = UIImageView()
    let spinner = UIActivityIndicatorView(style: UIActivityIndicatorView.Style.large)
    
    let status = UIImageView(image: UIImage(named: "banner"))
    let label = UILabel()
    let info = UILabel()
    
    let messages = ["Connecting ...", "Authorizing ...", "Sending credentials ...", "Failed"]
    var statusPosition = CGPoint.zero
    
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        view.addSubview(backgroundImage)
        view.addSubview(titleLabel)
        view.addSubview(usernameTextField)
        view.addSubview(passwordTextField)
        view.addSubview(loginButton)
        view.addSubview(cloud1)
        view.addSubview(cloud2)
        view.addSubview(cloud3)
        view.addSubview(cloud4)
        loginButton.addSubview(spinner)
        
        let textFieldWidth = screenWidth - 60
        let buttonWidth = 260
        
        backgroundImage.image = UIImage(named: "bg-sunny")
        backgroundImage.frame = CGRect(x: 0, y: 0, width: screenWidth, height: screenHeight)
        
        titleLabel.text = "Bahama Login"
        titleLabel.textColor = .white
        titleLabel.font = UIFont.systemFont(ofSize: 28)
        let titleWidth = titleLabel.intrinsicContentSize.width
        titleLabel.frame = CGRect(x: (screenWidth - titleWidth) / 2 , y: 120, width: titleWidth, height: titleLabel.intrinsicContentSize.height)
        
        usernameTextField.backgroundColor = .white
        usernameTextField.layer.cornerRadius = 5
        usernameTextField.placeholder = "  Username"
        usernameTextField.delegate = self
        usernameTextField.frame = CGRect(x: 30, y: 202, width: textFieldWidth, height: 40)
        
        passwordTextField.backgroundColor = .white
        passwordTextField.layer.cornerRadius = 5
        passwordTextField.placeholder = "  Password"
        passwordTextField.delegate = self
        passwordTextField.frame = CGRect(x: 30, y: 263, width: textFieldWidth, height: 40)
        
        loginButton.frame = CGRect(x: (Int(screenWidth) - buttonWidth) / 2, y: 343, width: buttonWidth, height: 50)
        loginButton.setTitle("Login", for: .normal)
        loginButton.setTitleColor(.red, for: .normal)
        loginButton.layer.cornerRadius = 5
        loginButton.backgroundColor = UIColor(red: 0.63, green: 0.84, blue: 0.35, alpha: 1.0)
        loginButton.addTarget(self, action: #selector(handleLogin), for: .touchUpInside)
        
        spinner.frame = CGRect(x: -20.0, y: 6.0, width: 20.0, height: 20.0)
        spinner.startAnimating()
        spinner.alpha = 0.0
        
        
        cloud1.frame = CGRect(x: -120, y: 79, width: 160, height: 50)
        cloud1.image = UIImage(named: "bg-sunny-cloud-1")
        
        cloud2.frame = CGRect(x: 256, y: 213, width: 160, height: 50)
        cloud2.image = UIImage(named: "bg-sunny-cloud-2")
        
        
        cloud3.frame = CGRect(x: 284, y: 503, width: 74, height: 35)
        cloud3.image = UIImage(named: "bg-sunny-cloud-3")
        
        
        cloud4.frame = CGRect(x:22 , y: 545, width: 115, height: 50)
        cloud4.image = UIImage(named: "bg-sunny-cloud-4")
        
        status.isHidden = true
        status.center = loginButton.center
        view.addSubview(status)
        
        label.frame = CGRect(x: 0.0, y: 0.0, width: status.frame.size.width, height: status.frame.size.height)
        label.font = UIFont(name: "HelveticaNeue", size: 18.0)
        label.textColor = UIColor(red: 0.89, green: 0.38, blue: 0.0, alpha: 1.0)
        label.textAlignment = .center
        status.addSubview(label)
        
        statusPosition = status.center
        
        info.frame = CGRect(x: 0.0, y: loginButton.center.y + 60.0,  width: view.frame.size.width, height: 30)
        info.backgroundColor = UIColor.clear
        info.font = UIFont(name: "HelveticaNeue", size: 12.0)
        info.textAlignment = .center
        info.textColor = UIColor.white
        info.text = "Tap on a field and enter username and password"
        view.insertSubview(info, belowSubview: loginButton)
        
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        
        let formGroup = CAAnimationGroup()
        formGroup.duration = 0.5
        formGroup.fillMode = .backwards
        
        let flyRight = CABasicAnimation(keyPath: "position.x")
        flyRight.fromValue = -view.bounds.size.width/2
        flyRight.toValue = view.bounds.size.width/2
        
        let fadeFieldIn = CABasicAnimation(keyPath: "opacity")
        fadeFieldIn.fromValue = 0.25
        fadeFieldIn.toValue = 1.0
        formGroup.animations = [flyRight, fadeFieldIn]
        
        
        titleLabel.layer.add(formGroup, forKey: nil)
        
        formGroup.delegate = self
        formGroup.setValue("form", forKey: "name")
        
        formGroup.setValue(usernameTextField.layer, forKey: "layer")
        formGroup.beginTime = CACurrentMediaTime() + 0.3
        usernameTextField.layer.add(formGroup, forKey: nil)
        
        formGroup.setValue(passwordTextField.layer, forKey: "layer")
        formGroup.beginTime = CACurrentMediaTime() + 0.4
        passwordTextField.layer.add(formGroup, forKey: nil)
        
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        let fadeIn = CABasicAnimation(keyPath: "opacity")
        fadeIn.fromValue = 0.0
        fadeIn.toValue = 1.0
        fadeIn.duration = 0.5
        fadeIn.fillMode = .backwards
        fadeIn.beginTime = CACurrentMediaTime() + 0.5
        cloud1.layer.add(fadeIn, forKey: nil)
        
        fadeIn.beginTime = CACurrentMediaTime() + 0.7
        cloud2.layer.add(fadeIn, forKey: nil)
        
        fadeIn.beginTime = CACurrentMediaTime() + 0.9
        cloud3.layer.add(fadeIn, forKey: nil)
        
        fadeIn.beginTime = CACurrentMediaTime() + 1.1
        cloud4.layer.add(fadeIn, forKey: nil)
  
        
        let groupAnimation = CAAnimationGroup()
        groupAnimation.beginTime = CACurrentMediaTime() + 0.5
        groupAnimation.duration = 0.5
        groupAnimation.fillMode = .backwards
        
        let scaleDown = CABasicAnimation(keyPath: "transform.scale")
        scaleDown.fromValue = 3.5
        scaleDown.toValue = 1.0
        
        let rotate = CABasicAnimation(keyPath: "transform.rotation")
        rotate.fromValue = .pi / 4.0
        rotate.toValue = 0.0
        
        let fade = CABasicAnimation(keyPath: "opacity")
        fade.fromValue = 0.0
        fade.toValue = 1.0
        groupAnimation.animations = [scaleDown, rotate, fade]
        groupAnimation.timingFunction = CAMediaTimingFunction(name: .easeIn)
        loginButton.layer.add(groupAnimation, forKey: nil)
        
        animateCloud(layer: cloud1.layer)
        animateCloud(layer: cloud2.layer)
        animateCloud(layer: cloud3.layer)
        animateCloud(layer: cloud4.layer)
        
        let flyLeft = CABasicAnimation(keyPath: "position.x")
        flyLeft.fromValue = info.layer.position.x + view.frame.size.width
        flyLeft.toValue = info.layer.position.x
        flyLeft.duration = 5.0
        
        info.layer.add(flyLeft, forKey: "infoappear")
        
        let fadeLabelIn = CABasicAnimation(keyPath: "opacity")
        fadeLabelIn.fromValue = 0.2
        fadeLabelIn.toValue = 1.0
        fadeLabelIn.duration = 4.5
        info.layer.add(fadeLabelIn, forKey: "fadein")
    }
    
    @objc func handleLogin() {
        view.endEditing(true)
        
        UIView.animate(withDuration: 1.5, delay: 0.0, usingSpringWithDamping: 0.2, initialSpringVelocity: 0.0, options: [], animations: {
            self.loginButton.bounds.size.width += 80.0
        }, completion: nil)
        
        UIView.animate(withDuration: 0.33, delay: 0.0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0.0, options: [], animations: {
            self.loginButton.center.y += 60.0
            self.spinner.center = CGPoint(
                x: 40.0,
                y: self.loginButton.frame.size.height/2
            )
            self.spinner.alpha = 1.0
        }, completion: { _ in
            self.showMessage(index:0)
        })
        
        let tintColor = UIColor(red: 0.85, green: 0.83, blue: 0.45, alpha: 1.0)
        tintBackgroundColor(layer: loginButton.layer, toColor: tintColor)
        roundCorners(layer: loginButton.layer, toRadius: 25.0)
    }
    func showMessage(index: Int) {
        label.text = messages[index]
        
        UIView.transition(with: status, duration: 0.33, options: [.curveEaseOut, .transitionFlipFromTop], animations: {
            self.status.isHidden = false
        }, completion: { _ in
            //transition completion
            delay(2.0) {
                if index < self.messages.count-1 {
                    self.removeMessage(index: index)
                    
                } else {
                    self.resetForm()
                }
            }
        })
    }
    
    func removeMessage(index: Int) {
        UIView.animate(withDuration: 0.33, delay: 0.0, options: [], animations: {
            self.status.center.x += self.view.frame.size.width
        }, completion: { _ in
            self.status.isHidden = true
            self.status.center = self.statusPosition
            
            self.showMessage(index: index+1)
        })
    }
    
    func resetForm() {
        UIView.transition(with: status, duration: 0.2, options: .transitionFlipFromTop, animations: {
            self.status.isHidden = true
            self.status.center = self.statusPosition
        }, completion: { _ in
            let tintColor = UIColor(red: 0.63, green: 0.84, blue: 0.35, alpha: 1.0)
            tintBackgroundColor(layer: self.loginButton.layer, toColor: tintColor)
            roundCorners(layer: self.loginButton.layer, toRadius: 10.0)
        })
        
        UIView.animate(withDuration: 0.2, delay: 0.0, options: [], animations: {
            self.spinner.center = CGPoint(x: -20.0, y: 16.0)
            self.spinner.alpha = 0.0
            self.loginButton.bounds.size.width -= 80.0
            self.loginButton.center.y -= 60.0
        }, completion: nil)
    }
    
    func animateCloud(layer: CALayer) {
        
        //1
        let cloudSpeed = 60.0 / Double(view.layer.frame.size.width)
        let duration: TimeInterval = Double(view.layer.frame.size.width - layer.frame.origin.x) * cloudSpeed
        
        //2
        let cloudMove = CABasicAnimation(keyPath: "position.x")
        cloudMove.duration = duration
        cloudMove.toValue = self.view.bounds.size.width + layer.bounds.width/2
        cloudMove.delegate = self
        cloudMove.fillMode = .forwards
        cloudMove.setValue("cloud", forKey: "name")
        cloudMove.setValue(layer, forKey: "layer")
        layer.add(cloudMove, forKey: nil)
    }
    
}

func delay(_ seconds: Double, completion: @escaping ()->Void) {
    DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: completion)
}

func tintBackgroundColor(layer: CALayer, toColor: UIColor) {
  let tint = CASpringAnimation(keyPath: "backgroundColor")
  tint.damping = 5.0
  tint.initialVelocity = -10.0
  tint.fromValue = layer.backgroundColor
  tint.toValue = toColor.cgColor
  tint.duration = tint.settlingDuration
  layer.add(tint, forKey: nil)
  layer.backgroundColor = toColor.cgColor
}

func roundCorners(layer: CALayer, toRadius: CGFloat) {
  let round = CASpringAnimation(keyPath: "cornerRadius")
  round.damping = 5.0
  round.fromValue = layer.cornerRadius
  round.toValue = toRadius
  round.duration = round.settlingDuration
  layer.add(round, forKey: nil)
  layer.cornerRadius = toRadius
}

class TextField: UITextField {
    
    let padding = UIEdgeInsets(top: 0, left: 5, bottom: 0, right: 5)
    
    override open func textRect(forBounds bounds: CGRect) -> CGRect {
        return bounds.inset(by: padding)
    }
    
    override open func placeholderRect(forBounds bounds: CGRect) -> CGRect {
        return bounds.inset(by: padding)
    }
    
    override open func editingRect(forBounds bounds: CGRect) -> CGRect {
        return bounds.inset(by: padding)
    }
}

extension ViewController: CAAnimationDelegate {
    func animationDidStop(_ anim: CAAnimation,
                          finished flag: Bool) {
        print("animation did finish")
        
        guard let name = anim.value(forKey: "name") as? String else {
            return
        }
        
        if name == "form" {
            //form field found
            
            let layer = anim.value(forKey: "layer") as? CALayer
            anim.setValue(nil, forKey: "layer")
            
            let pulse = CASpringAnimation(keyPath: "transform.scale")
            pulse.damping = 7.5
            pulse.fromValue = 1.25
            pulse.toValue = 1.0
            pulse.duration = pulse.settlingDuration
            layer?.add(pulse, forKey: nil)
        }
        
        if name == "cloud" {
            if let layer = anim.value(forKey: "layer") as? CALayer {
                anim.setValue(nil, forKey: "layer")
                
                layer.position.x = -layer.bounds.width/2
                delay(0.5) {
                    self.animateCloud(layer: layer)
                }
            }
        }
        
    }
}
extension ViewController: UITextFieldDelegate {
    func textFieldDidBeginEditing(_ textField: UITextField) {
        guard let runningAnimations = info.layer.animationKeys() else {
            return
        }
        print(runningAnimations)
        info.layer.removeAnimation(forKey: "infoappear")
        info.layer.removeAnimation(forKey: "fadein")
    }
    func textFieldDidEndEditing(_ textField: UITextField) {
      guard let text = textField.text else { return }

      if text.count < 5 {
        // add animations here
        let jump = CASpringAnimation(keyPath: "position.y")
        jump.initialVelocity = 100.0
        jump.mass = 10.0
        jump.stiffness = 1500.0
        jump.damping = 50.0
        jump.fromValue = textField.layer.position.y + 1.0
        jump.toValue = textField.layer.position.y
        jump.duration = jump.settlingDuration
        textField.layer.add(jump, forKey: nil)

        textField.layer.borderWidth = 3.0
        textField.layer.borderColor = UIColor.clear.cgColor

        let flash = CASpringAnimation(keyPath: "borderColor")
        flash.damping = 7.0
        flash.stiffness = 200.0
        flash.fromValue = UIColor(red: 1.0, green: 0.27, blue: 0.0, alpha: 1.0).cgColor
        flash.toValue = UIColor.white.cgColor
        flash.duration = flash.settlingDuration
        textField.layer.add(flash, forKey: nil)

        textField.layer.cornerRadius = 5
      }
    }
}

复制代码

Guess you like

Origin juejin.im/post/7031719454576738317