从案例出发,由浅到深了解 iOS 动画

作者: iOS猿_员 | 来源:发表于2019-11-07 14:53 被阅读0次

    收录:原文地址

    前言

    iOS 的动画框架很成熟,提供必要的信息,譬如动画的起始位置与终止位置,动画效果就出来了

    动画的实现方式挺多的,

    有系统提供的简单 API ,直接提供动画般的交互效果。

    有手动设置交互效果,看起来像是动画,一般要用到插值。

    至于动画框架,有 UIView 级别的,有功能强劲的 CALayer 级别的动画。

    CALayer 级别的动画通过灵活设置的 CoreAnimation,CoreAnimation 的常规操作,就是自定义路径

    当然有苹果推了几年的 UIViewPropertyAnimator, 动画可交互性做得比较好;

    话不多说;直接来看案例


    例子一:导航栏动画

    navigationController?.hidesBarsOnSwipe = true
    

    简单设置 hidesBarsOnSwipe 属性,就可以了。

    该属性,除了可以调节头部导航栏,还可以调节底部标签工具栏 toolbar


    例子二:屏幕开锁效果

    一眼看起来有点炫,实际设置很简单

    func openLock() {
            UIView.animate(withDuration: 0.4, delay: 1.0, options: [], animations: {
    
                // Rotate keyhole.
                self.lockKeyhole.transform = CGAffineTransform(rotationAngle: CGFloat.pi)
    
                }, completion: { _ in
    
                    UIView.animate(withDuration: 0.5, delay: 0.2, options: [], animations: {
    
                        // Open lock.
                        let yDelta = self.lockBorder.frame.maxY
    
                        self.topLock.center.y -= yDelta
                        self.lockKeyhole.center.y -= yDelta
                        self.lockBorder.center.y -= yDelta
                        self.bottomLock.center.y += yDelta
    
                        }, completion: { _ in
                            self.topLock.removeFromSuperview()
                            self.lockKeyhole.removeFromSuperview()
                            self.lockBorder.removeFromSuperview()
                            self.bottomLock.removeFromSuperview()
                    })
            })
        }
    

    总共有四个控件,先让中间的锁控件旋转一下,然后对四个控件,做移位操作

    用简单的关键帧动画,处理要优雅一点


    例子三:地图定位波动

    看上去有些眼花的动画,可以分解为三个动画

    image

    一波未平,一波又起,做一个动画效果的叠加,就成了动画的第一幅动画

    一个动画波动效果,效果用到了透明度的变化,还有范围的变化

    范围的变化,用的就是 CoreAnimation 的路径 path

    CoreAnimation 简单设置,就是指明 from 、to,动画的起始状态,和动画终止状态,然后选择使用哪一种动画效果。

    动画的起始状态,一般是起始位置。简单的动画,就是让他动起来

    func sonar(_ beginTime: CFTimeInterval) {
            let circlePath1 = UIBezierPath(arcCenter: self.center, radius: CGFloat(3), startAngle: CGFloat(0), endAngle:CGFloat.pi * 2, clockwise: true)
    
            let circlePath2 = UIBezierPath(arcCenter: self.center, radius: CGFloat(80), startAngle: CGFloat(0), endAngle:CGFloat.pi * 2, clockwise: true)
    
            let shapeLayer = CAShapeLayer()
            shapeLayer.strokeColor = ColorPalette.green.cgColor
            shapeLayer.fillColor = ColorPalette.green.cgColor
            shapeLayer.path = circlePath1.cgPath
            self.layer.addSublayer(shapeLayer)
    
            // 两个动画
    
            let pathAnimation = CABasicAnimation(keyPath: "path")
            pathAnimation.fromValue = circlePath1.cgPath
            pathAnimation.toValue = circlePath2.cgPath
    
            let alphaAnimation = CABasicAnimation(keyPath: "opacity")
            alphaAnimation.fromValue = 0.8
            alphaAnimation.toValue = 0
    
            // 组动画
            let animationGroup = CAAnimationGroup()
            animationGroup.beginTime = beginTime
            animationGroup.animations = [pathAnimation, alphaAnimation]
    
            // 时间有讲究
            animationGroup.duration = 2.76
    
            // 不断重复
            animationGroup.repeatCount = Float.greatestFiniteMagnitude
            animationGroup.isRemovedOnCompletion = false
            animationGroup.fillMode = CAMediaTimingFillMode.forwards
    
            // Add the animation to the layer.
            // key 用来 debug
            shapeLayer.add(animationGroup, forKey: "sonar")
        }
    

    波动效果调用了三次

    func startAnimation() {
            // 三次动画,效果合成,
            sonar(CACurrentMediaTime())
            sonar(CACurrentMediaTime() + 0.92)
            sonar(CACurrentMediaTime() + 1.84)
        }
    

    例子四:加载动画

    image

    这是 UIView 框架自带的动画,看起来不错,就是做了一个简单的缩放,通过 transform 属性做仿射变换

    func startAnimation() {
    
            dotOne.transform = CGAffineTransform(scaleX: 0.01, y: 0.01)
            dotTwo.transform = CGAffineTransform(scaleX: 0.01, y: 0.01)
            dotThree.transform = CGAffineTransform(scaleX: 0.01, y: 0.01)
    
            // 三个不同的 delay, 渐进时间
            UIView.animate(withDuration: 0.6, delay: 0.0, options: [.repeat, .autoreverse], animations: {
                self.dotOne.transform = CGAffineTransform.identity
                }, completion: nil)
    
            UIView.animate(withDuration: 0.6, delay: 0.2, options: [.repeat, .autoreverse], animations: {
                self.dotTwo.transform = CGAffineTransform.identity
                }, completion: nil)
    
            UIView.animate(withDuration: 0.6, delay: 0.4, options: [.repeat, .autoreverse], animations: {
                self.dotThree.transform = CGAffineTransform.identity
                }, completion: nil)
        }
    

    例子五:下划线点击转移动画

    这个也是 UIView 的动画

    image

    动画的实现效果,是通过更改约束。

    约束动画要注意的是,确保动画的起始位置准确,起始的时候,一般要调用其父视图的 layoutIfNeeded 方法,确保视图的实际位置与约束设置的一致。

    这里的约束动画,是通过 NSLayoutAnchor 做得。

    一般我们用的是 SnapKit 设置约束,调用也差不多。

    func animateContraintsForUnderlineView(_ underlineView: UIView, toSide: Side) {
    
            switch toSide {
            case .left:
    
                for constraint in underlineView.superview!.constraints {
                    if constraint.identifier == ConstraintIdentifiers.centerRightConstraintIdentifier {
    
                        constraint.isActive = false
    
                        let leftButton = optionsBar.arrangedSubviews[0]
                        let centerLeftConstraint = underlineView.centerXAnchor.constraint(equalTo: leftButton.centerXAnchor)
                        centerLeftConstraint.identifier = ConstraintIdentifiers.centerLeftConstraintIdentifier
    
                        NSLayoutConstraint.activate([centerLeftConstraint])
                    }
                }
    
            case .right:
    
                for constraint in underlineView.superview!.constraints {
                    if constraint.identifier == ConstraintIdentifiers.centerLeftConstraintIdentifier {
                        // 先失效,旧的约束
                        constraint.isActive = false
                        // 再新建约束,并激活
                        let rightButton = optionsBar.arrangedSubviews[1]
                        let centerRightConstraint = underlineView.centerXAnchor.constraint(equalTo: rightButton.centerXAnchor)
                        centerRightConstraint.identifier = ConstraintIdentifiers.centerRightConstraintIdentifier
    
                        NSLayoutConstraint.activate([centerRightConstraint])
    
                    }
                }
            }
    
            UIView.animate(withDuration: 0.6, delay: 0.0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0.0, options: [], animations: {
                self.view.layoutIfNeeded()
                }, completion: nil)
    
        }
    

    例子六:列表视图的头部拉伸效果

    这个没有用到动画框架,就是做了一个交互插值

    就是补插连续的函数 scrollViewDidScroll, 及时更新列表视图头部的位置、尺寸

    override func scrollViewDidScroll(_ scrollView: UIScrollView) {
            updateHeaderView()
        }
    
        func updateHeaderView() {
            var headerRect = CGRect(x: 0, y: -tableHeaderHeight, width: tableView.bounds.width, height: tableHeaderHeight)
            // 决定拉动的方向
            if tableView.contentOffset.y < -tableHeaderHeight {
                // 就是改 frame
                headerRect.origin.y = tableView.contentOffset.y
                headerRect.size.height = -tableView.contentOffset.y
            }
    
            headerView.frame = headerRect
        }
    

    例子七:进度绘制动画

    image

    用到了 CoreAnimation,也用到了插值。

    每一段插值都是一个 CoreAnimation 动画,进度的完成分为多次插值。

    这里动画效果的主要用到 strokeEnd 属性, 笔画结束

    插值的时候,要注意,下一段动画的开始,正是上一段动画的结束

    // 这个用来,主要的效果
        let progressLayer = CAShapeLayer()
       // 这个用来,附加的颜色
        let gradientLayer = CAGradientLayer()
    
        // 给个默认值,外部设置
        var range: CGFloat = 128
    
        var curValue: CGFloat = 0 {
            didSet {
                animateStroke()
            }
        }
    
        func setupLayers() {
    
            progressLayer.position = CGPoint.zero
            progressLayer.lineWidth = 3.0
            progressLayer.strokeEnd = 0.0
            progressLayer.fillColor = nil
            progressLayer.strokeColor = UIColor.black.cgColor
    
            let radius = CGFloat(bounds.height/2) - progressLayer.lineWidth
            let startAngle = CGFloat.pi * (-0.5)
            let endAngle = CGFloat.pi * 1.5
    
            let width = bounds.width
            let height = bounds.height
            let modelCenter = CGPoint(x: width / 2, y: height / 2)
            let path = UIBezierPath(arcCenter: modelCenter, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
            //  指定路径
            progressLayer.path = path.cgPath
    
            layer.addSublayer(progressLayer)
            // 有一个渐变
            gradientLayer.frame = CGRect(x: 0.0, y: 0.0, width: bounds.width, height: bounds.height)
    
            //  teal, 蓝绿色
    
            gradientLayer.colors = [ColorPalette.teal.cgColor, ColorPalette.orange.cgColor, ColorPalette.pink.cgColor]
            gradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
            gradientLayer.endPoint = CGPoint(x: 0.5, y: 1)
    
            gradientLayer.mask = progressLayer // Use progress layer as mask for gradient layer.
            layer.addSublayer(gradientLayer)
        }
    
        func animateStroke() {
            // 前一段的终点
            let fromValue = progressLayer.strokeEnd
            let toValue = curValue / range
    
            let animation = CABasicAnimation(keyPath: "strokeEnd")
            animation.fromValue = fromValue
            animation.toValue = toValue
            progressLayer.add(animation, forKey: "stroke")
            progressLayer.strokeEnd = toValue
        }
    
    }
    
    // 动画路径,结合插值
    

    例子八:渐变动画

    image

    这个渐变动画,主要用到了渐变图层 CAGradientLayerlocations 位置属性,用来调整渐变区域的分布

    另一个关键点是用了图层 CALayer 的遮罩 mask,

    简单理解,把渐变图层全部蒙起来,只露出文本的形状,就是那几个字母的痕迹

    class LoadingLabel: UIView {
    
        let gradientLayer: CAGradientLayer = {
            let gradientLayer = CAGradientLayer()
    
            gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
            gradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
    
            // 灰, 白, 灰
            let colors = [UIColor.gray.cgColor, UIColor.white.cgColor, UIColor.gray.cgColor]
            gradientLayer.colors = colors
    
            let locations = [0.25, 0.5, 0.75]
            gradientLayer.locations = locations as [NSNumber]?
    
            return gradientLayer
        }()
    
        // 文字转图片,然后绘制到视图上
    
        // 通过设置渐变图层的遮罩 `mask` , 为指定文字,来设置渐变闪烁的效果
    
        @IBInspectable var text: String! {
              didSet {
                   setNeedsDisplay()
    
                    UIGraphicsBeginImageContextWithOptions(frame.size, false, 0)
                    text.draw(in: bounds, withAttributes: textAttributes)
                    let image = UIGraphicsGetImageFromCurrentImageContext()
                    UIGraphicsEndImageContext()
                   // 从文字中,抽取图片
    
                     let maskLayer = CALayer()
                     maskLayer.backgroundColor = UIColor.clear.cgColor
                     maskLayer.frame = bounds.offsetBy(dx: bounds.size.width, dy: 0)
                     maskLayer.contents = image?.cgImage
    
                     gradientLayer.mask = maskLayer
                }
          }
    
        // 设置位置与尺寸
        override func layoutSubviews() {
            gradientLayer.frame = CGRect(x: -bounds.size.width, y: bounds.origin.y, width: 2 * bounds.size.width, height: bounds.size.height)
        }
    
        override func didMoveToWindow() {
            super.didMoveToWindow()
    
            layer.addSublayer(gradientLayer)
    
            let gradientAnimation = CABasicAnimation(keyPath: "locations")
            gradientAnimation.fromValue = [0.0, 0.0, 0.25]
            gradientAnimation.toValue = [0.75, 1.0, 1.0]
            gradientAnimation.duration = 1.7
    
            // 一直循环
            gradientAnimation.repeatCount = Float.infinity
            gradientAnimation.isRemovedOnCompletion = false
            gradientAnimation.fillMode = CAMediaTimingFillMode.forwards
    
            gradientLayer.add(gradientAnimation, forKey: nil)
        }
    
    }
    

    例子九:下拉刷新动画

    image

    首先通过方法 scrollViewDidScrollscrollViewWillEndDragging 做插值

    extension PullRefreshView: UIScrollViewDelegate{
    
        // MARK: - UIScrollViewDelegate
    
           func scrollViewDidScroll(_ scrollView: UIScrollView) {
               let offsetY = CGFloat(max(-(scrollView.contentOffset.y + scrollView.contentInset.top), 0.0))
               self.progress = min(max(offsetY / frame.size.height, 0.0), 1.0)
    
               // 做互斥的状态管理
               if !isRefreshing {
                   redrawFromProgress(self.progress)
               }
           }
    
           func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
               if !isRefreshing && self.progress >= 1.0 {
                   delegate?.PullRefreshViewDidRefresh(self)
                   beginRefreshing()
               }
           }
    
    }
    

    画面中飞碟动来动去,是通过 CAKeyframeAnimation(keyPath: "position") ,关键帧动画的位置属性,设置的

    func redrawFromProgress(_ progress: CGFloat) {
    
            /* PART 1 ENTER ANIMATION */
    
            let enterPath = paths.start
    
            // 动画指定路径走
            let pathAnimation = CAKeyframeAnimation(keyPath: "position")
            pathAnimation.path = enterPath.cgPath
            pathAnimation.calculationMode = CAAnimationCalculationMode.paced
            pathAnimation.timingFunctions = [CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)]
            pathAnimation.beginTime = 1e-100
    
            pathAnimation.duration = 1.0
            pathAnimation.timeOffset = CFTimeInterval() + Double(progress)
            pathAnimation.isRemovedOnCompletion = false
            pathAnimation.fillMode = CAMediaTimingFillMode.forwards
    
            flyingSaucerLayer.add(pathAnimation, forKey: nil)
            flyingSaucerLayer.position = enterPath.currentPoint
    
            let sizeAlongEnterPathAnimation = CABasicAnimation(keyPath: "transform.scale")
            sizeAlongEnterPathAnimation.fromValue = 0
            sizeAlongEnterPathAnimation.toValue = progress
            sizeAlongEnterPathAnimation.beginTime = 1e-100
    
            sizeAlongEnterPathAnimation.duration = 1.0
            sizeAlongEnterPathAnimation.isRemovedOnCompletion = false
            sizeAlongEnterPathAnimation.fillMode = CAMediaTimingFillMode.forwards
    
            flyingSaucerLayer.add(sizeAlongEnterPathAnimation, forKey: nil)
    
        }
    
    //  设置路径
       func customPaths(frame: CGRect = CGRect(x: 4, y: 3, width: 166, height: 74)) -> ( UIBezierPath, UIBezierPath) {
    
            // 两条路径
    
            let startY = 0.09459 * frame.height
            let enterPath = UIBezierPath()
            // ...
            enterPath.addCurve(to: CGPoint(x: frame.minX + 0.21694 * frame.width, y: frame.minY + 0.85855 * frame.height), controlPoint1: CGPoint(x: frame.minX + 0.04828 * frame.width, y: frame.minY + 0.68225 * frame.height), controlPoint2: CGPoint(x: frame.minX + 0.21694 * frame.width, y: frame.minY + 0.85855 * frame.height))
    
            enterPath.addCurve(to: CGPoint(x: frame.minX + 0.36994 * frame.width, y: frame.minY + 0.92990 * frame.height), controlPoint1: CGPoint(x: frame.minX + 0.21694 * frame.width, y: frame.minY + 0.85855 * frame.height), controlPoint2: CGPoint(x: frame.minX + 0.33123 * frame.width, y: frame.minY + 0.93830 * frame.height))
            // ...
            enterPath.usesEvenOddFillRule = true
    
            let exitPath = UIBezierPath()
            exitPath.move(to: CGPoint(x: frame.minX + 0.98193 * frame.width, y: frame.minY + 0.15336 * frame.height))
            exitPath.addLine(to: CGPoint(x: frame.minX + 0.51372 * frame.width, y: frame.minY + 0.28558 * frame.height))
            // ... 
            exitPath.miterLimit = 4
            exitPath.usesEvenOddFillRule = true
    
            return (enterPath, exitPath)
        }
    
    }
    

    这个动画比较复杂,需要做大量的数学计算,还要调试,具体看文尾的 git repo.

    一般这种动画,我们用 Lottie


    例子十:文本变换动画

    image

    这个动画有些复杂,重点使用了 CoreAnimation 的组动画,叠加了五种效果,缩放、尺寸、布局、位置与透明度。

    具体看文尾的 git repo.

    class func animation(_ layer: CALayer, duration: TimeInterval, delay: TimeInterval, animations: (() -> ())?, completion: ((_ finished: Bool)-> ())?) {
    
            let animation = CLMLayerAnimation()
    
            DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double(Int64(delay * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC)) {
    
                var animationGroup: CAAnimationGroup?
                let oldLayer = self.animatableLayerCopy(layer)
                animation.completionClosure = completion
    
                if let layerAnimations = animations {
                    CATransaction.begin()
                    CATransaction.setDisableActions(true)
                    layerAnimations()
                    CATransaction.commit()
                }
    
                animationGroup = groupAnimationsForDifferences(oldLayer, newLayer: layer)
    
                if let differenceAnimation = animationGroup {
                    differenceAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
                    differenceAnimation.duration = duration
                    differenceAnimation.beginTime = CACurrentMediaTime()
                    layer.add(differenceAnimation, forKey: nil)
                }
                else {
                    if let completion = animation.completionClosure {
                        completion(true)
                    }
                }
    
            }        
        }
    
        class func groupAnimationsForDifferences(_ oldLayer: CALayer, newLayer: CALayer) -> CAAnimationGroup? {
            var animationGroup: CAAnimationGroup?
            var animations = [CABasicAnimation]()
    
            // 叠加了五种效果
    
            if !CATransform3DEqualToTransform(oldLayer.transform, newLayer.transform) {
                let animation = CABasicAnimation(keyPath: "transform")
                animation.fromValue = NSValue(caTransform3D: oldLayer.transform)
                animation.toValue = NSValue(caTransform3D: newLayer.transform)
                animations.append(animation)
            }
    
            if !oldLayer.bounds.equalTo(newLayer.bounds) {
                let animation = CABasicAnimation(keyPath: "bounds")
                animation.fromValue = NSValue(cgRect: oldLayer.bounds)
                animation.toValue = NSValue(cgRect: newLayer.bounds)
                animations.append(animation)
            }
    
            if !oldLayer.frame.equalTo(newLayer.frame) {
                let animation = CABasicAnimation(keyPath: "frame")
                animation.fromValue = NSValue(cgRect: oldLayer.frame)
                animation.toValue = NSValue(cgRect: newLayer.frame)
                animations.append(animation)
            }
    
            if !oldLayer.position.equalTo(newLayer.position) {
                let animation = CABasicAnimation(keyPath: "position")
                animation.fromValue = NSValue(cgPoint: oldLayer.position)
                animation.toValue = NSValue(cgPoint: newLayer.position)
                animations.append(animation)
            }
    
            if oldLayer.opacity != newLayer.opacity {
                let animation = CABasicAnimation(keyPath: "opacity")
                animation.fromValue = oldLayer.opacity
                animation.toValue = newLayer.opacity
                animations.append(animation)
            }
    
            if animations.count > 0 {
                animationGroup = CAAnimationGroup()
                animationGroup!.animations = animations
            }
    
            return animationGroup
        }
    

    例子十一:动态图动画

    image

    从 gif 文件里面取出每桢图片,算出持续时间,设置动画图片

    internal class func animatedImageWithSource(_ source: CGImageSource) -> UIImage? {
    
            // 需要喂图片,
            // 喂动画持续时间
    
            let count = CGImageSourceGetCount(source)
    
            var data: (images: [CGImage], delays: [Int]) = ([CGImage](), [Int]())
    
            // Fill arrays
            for i in 0..<count {
                // Add image
                if let image = CGImageSourceCreateImageAtIndex(source, i, nil) {
                    data.images.append(image)
                }
    
                let delaySeconds = UIImage.delayForImageAtIndex(Int(i),
                    source: source)
                data.delays.append(Int(delaySeconds * 1000.0)) // Seconds to ms
            }
    
            // Calculate full duration
            let duration: Int = {
                var sum = 0
                for val: Int in data.delays {
                    sum += val
                }
                return sum
            }()
    
            let gcd = gcdForArray(data.delays)
            var frames = [UIImage]()
    
            var frame: UIImage
            var frameCount: Int
            for i in 0..<count {
                frame = UIImage(cgImage: data.images[Int(i)])
                frameCount = Int(data.delays[Int(i)] / gcd)
    
                for _ in 0..<frameCount {
                    frames.append(frame)
                }
            }
    
            let animation = UIImage.animatedImage(with: frames,
                duration: Double(duration) / 1000.0)
    
            return animation
        }
    

    推荐文集

    相关文章

      网友评论

        本文标题:从案例出发,由浅到深了解 iOS 动画

        本文链接:https://www.haomeiwen.com/subject/rixvbctx.html