From case study to deep understanding of iOS animation

Included: Original address

Preface

iOS's animation framework is very mature, providing necessary information, such as the starting and ending positions of animation, and the animation effect will come out

There are many ways to realize animation,

There is a simple API provided by the system, which directly provides animation like interaction effect.

There are manual interactive effects, which look like animation, and interpolation is generally used.

As for the animation framework, there are UIView level and powerful CALayer level animations.

The CALayer level animation adopts the flexible CoreAnimation, the general operation of CoreAnimation, which is to customize the path

Of course, Apple has pushed uiview property animator for several years, which makes animation interactive well;

Don't talk much; look directly at the case

    • *

Example 1: navigation bar animation

navigationController?.hidesBarsOnSwipe = true

Simply set the "hidesBarsOnSwipe" property.

This attribute can adjust not only the head navigation bar, but also the bottom label toolbar

    • *

Example 2: screen unlocking effect

It's a bit dazzling at first glance. The actual setting is very simple

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

There are four controls in total. First, let the middle lock control rotate once, and then shift the four controls

With simple keyframe animation, handle it with elegance

    • *

Example 3: Map positioning fluctuation

The animation that looks a little dazzled can be divided into three animations

One wave is not flat, one wave is rising again, making a superposition of animation effects becomes the first animation of animation

An animation wave effect, the effect uses the change of transparency, as well as the change of range

The path of CoreAnimation is used to change the scope.

CoreAnimation is simply set to indicate the start state and end state of the animation, and then choose which animation effect to use.

The starting state of an animation, usually the starting position. The simple animation is to make him move

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)

        // Two animations

        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

        // Group animation
        let animationGroup = CAAnimationGroup()
        animationGroup.beginTime = beginTime
        animationGroup.animations = [pathAnimation, alphaAnimation]

        // Time is important
        animationGroup.duration = 2.76

        // Repeat
        animationGroup.repeatCount = Float.greatestFiniteMagnitude
        animationGroup.isRemovedOnCompletion = false
        animationGroup.fillMode = CAMediaTimingFillMode.forwards

        // Add the animation to the layer.
        // key is used to debug
        shapeLayer.add(animationGroup, forKey: "sonar")
    }

Wave effect called three times

func startAnimation() {
        // Three animations, effect synthesis,
        sonar(CACurrentMediaTime())
        sonar(CACurrentMediaTime() + 0.92)
        sonar(CACurrentMediaTime() + 1.84)
    }
    • *

Example 4: loading animation

This is the animation of UIView framework. It looks good. It makes a simple zoom and affine transformation through the "transform" attribute

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)

        // Three different delay s, progressive time
        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)
    }
    • *

Example 5: underline click transfer animation

This is also the animation of UIView

Animation is achieved by changing constraints.

It should be noted that the starting position of the animation should be accurate. At the beginning of the animation, the layoutifneed method of the parent view should be called to ensure that the actual position of the view is consistent with the constraint settings.

The constraint animation here is made by NSLayoutAnchor.

In general, we use SnapKit to set constraints, and the call is similar.

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 {
                    // Fail first, old constraint
                    constraint.isActive = false
                    // Create a new constraint and activate
                    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)

    }
    • *

Example 6: head stretch effect of list view

This doesn't use the animation framework, but makes an interactive interpolation

It is to add a continuous function, scrollViewDidScroll, to update the position and size of the head of the list view in time

override func scrollViewDidScroll(_ scrollView: UIScrollView) {
        updateHeaderView()
    }

    func updateHeaderView() {
        var headerRect = CGRect(x: 0, y: -tableHeaderHeight, width: tableView.bounds.width, height: tableHeaderHeight)
        // Determine the pull direction
        if tableView.contentOffset.y < -tableHeaderHeight {
            // Is to change the frame
            headerRect.origin.y = tableView.contentOffset.y
            headerRect.size.height = -tableView.contentOffset.y
        }

        headerView.frame = headerRect
    }
    • *

Example 7: progress animation

CoreAnimation and interpolation are used.

Each interpolation is a CoreAnimation animation, and the progress is divided into multiple interpolation.

Here, the animation effect mainly uses the strokeEnd attribute, and the stroke ends

When interpolating, notice that the beginning of the next animation is the end of the previous animation

// This is for the main effect
    let progressLayer = CAShapeLayer()
   // This is for additional colors
    let gradientLayer = CAGradientLayer()

    // Give default value, external setting
    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)
        //  Specified path
        progressLayer.path = path.cgPath

        layer.addSublayer(progressLayer)
        // There is a gradient
        gradientLayer.frame = CGRect(x: 0.0, y: 0.0, width: bounds.width, height: bounds.height)

        //  teal, turquoise

        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() {
        // End of previous paragraph
        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
    }

}

// Animation paths, combining interpolation
    • *

Example 8: gradient animation

This gradient animation mainly uses the location attribute of the gradient layer CAGradientLayer to adjust the distribution of gradient regions

Another key point is to use the mask of layer CALayer,

Simply understand, mask the gradient layer, only show the shape of the text, that is, the trace of the letters

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)

        // Grey, white, grey
        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
    }()

    // Text to picture, then draw to view

    // Set the gradient flicker effect by setting the mask 'mask' of the gradient layer and specifying the text

    @IBInspectable var text: String! {
          didSet {
               setNeedsDisplay()

                UIGraphicsBeginImageContextWithOptions(frame.size, false, 0)
                text.draw(in: bounds, withAttributes: textAttributes)
                let image = UIGraphicsGetImageFromCurrentImageContext()
                UIGraphicsEndImageContext()
               // Extract pictures from text

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

    // Set position and size
    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

        // Cycle all the time
        gradientAnimation.repeatCount = Float.infinity
        gradientAnimation.isRemovedOnCompletion = false
        gradientAnimation.fillMode = CAMediaTimingFillMode.forwards

        gradientLayer.add(gradientAnimation, forKey: nil)
    }

}
    • *

Example 9: pull down to refresh the animation

First, interpolation is done by the methods scrollViewDidScroll and scrollviewwillenddrawing

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)

           // Do mutually exclusive state management
           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()
           }
       }

}

The flying saucer moves around in the picture, which is set by cakeyframe animation (keypath: "position"), the position attribute of keyframe animation

func redrawFromProgress(_ progress: CGFloat) {

        /* PART 1 ENTER ANIMATION */

        let enterPath = paths.start

        // Animation path
        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)

    }

//  set up path
   func customPaths(frame: CGRect = CGRect(x: 4, y: 3, width: 166, height: 74)) -> ( UIBezierPath, UIBezierPath) {

        // The two way

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

}

This animation is more complex and needs a lot of mathematical calculation and debugging. See git repo at the end of the article for details

In general, we use Lottie

    • *

Example 10: text transformation animation

This animation is a little complex, focusing on CoreAnimation's group animation, which overlays five effects: zoom, size, layout, position and transparency.

See git repo at the end of the article

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]()

        // Five effects are superimposed

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

Example 11: dynamic graph animation

Take each frame image from the gif file, calculate the duration, and animate the image

internal class func animatedImageWithSource(_ source: CGImageSource) -> UIImage? {

        // Need to feed pictures,
        // Feed animation duration

        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
    }

Recommended corpus

Tags: iOS Attribute git

Posted on Thu, 07 Nov 2019 01:34:24 -0800 by bubblybabs