Search code examples
iosuikitcore-animationnslayoutconstraint

How to get Spring Animations with Constraints to behave properly?


Trying to get a spring animation to work properly. I made a git repo. Basically the red view is constrained as follows:

let ac = brokenView.widthAnchor.constraint(equalTo: brokenView.heightAnchor, multiplier: 9/16)
let xc = brokenView.centerXAnchor.constraint(equalTo: animView.centerXAnchor)
let yc = brokenView.centerYAnchor.constraint(equalTo: animView.centerYAnchor)
let widthC = brokenView.widthAnchor.constraint(equalTo: animView.widthAnchor)
widthC.priority = .defaultLow
let gewc = brokenView.widthAnchor.constraint(greaterThanOrEqualTo: animView.widthAnchor)
let geHC = brokenView.heightAnchor.constraint(greaterThanOrEqualTo: animView.heightAnchor)
geHC.priority = .required

Blue view starts at aspect ratio != 9/16 -> is animated to 9/16. What I'd like to see is when the blue view gets too tall, the red view starts to get fat. Instead it just adheres to the lower priority width anchor. Any advice is appreciated.

Repo Here

Code that is not as useful as it could be without storyboard file:

import UIKit


class MyViewController: UIViewController {

    @IBOutlet weak var animView: UIView!
    @IBOutlet weak var brokenView: UIView!
    
    var isBig = false
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        brokenView.border(3, color: .red)
        animView.border(3, color: .blue)
        
        animView.frame = CGRect(x: view.frame.midX - 120, y: view.frame.midY - 180, width: 240, height: 360)
        
        brokenView.translatesAutoresizingMaskIntoConstraints = false
        
        let ac = brokenView.widthAnchor.constraint(equalTo: brokenView.heightAnchor, multiplier: 9/16)
        let xc = brokenView.centerXAnchor.constraint(equalTo: animView.centerXAnchor)
        let yc = brokenView.centerYAnchor.constraint(equalTo: animView.centerYAnchor)
        let widthC = brokenView.widthAnchor.constraint(equalTo: animView.widthAnchor)
        widthC.priority = .defaultLow
        let gewc = brokenView.widthAnchor.constraint(greaterThanOrEqualTo: animView.widthAnchor)
        let geHC = brokenView.heightAnchor.constraint(greaterThanOrEqualTo: animView.heightAnchor)
        geHC.priority = .required
        
        NSLayoutConstraint.activate([
            ac,
            xc,
            yc,
            widthC,
            gewc,
            geHC
        ])
        
        let gr = UITapGestureRecognizer(target: self, action: #selector(MyViewController.handleTap(_:)))
        view.addGestureRecognizer(gr)
    }

    
    @objc func handleTap(_ gr: UITapGestureRecognizer) {
        let newFrame: CGRect
        if isBig {
            newFrame = CGRect(x: view.frame.midX - 120, y: view.frame.midY - 180, width: 240, height: 360)
        } else {
            newFrame = CGRect(x: view.frame.midX - (270/2), y: view.frame.midY - (480/2), width: 270, height: 480)
        }
        let timing = UISpringTimingParameters.init(dampingRatio: 0.01, frequencyResponse: 10)
        let anim = UIViewPropertyAnimator(duration: 10, timingParameters: timing)
        anim.addAnimations {
            self.animView.frame = newFrame
        }
        anim.addCompletion { _ in
            self.isBig = !self.isBig
        }
        anim.startAnimation()
    }

}

extension UIView {
    
    func border(_ width: Float, color: UIColor) {
        layer.borderColor = color.cgColor
        layer.borderWidth = CGFloat(width)
    }
}

extension UISpringTimingParameters {
    public convenience init(dampingRatio: CGFloat, frequencyResponse: CGFloat) {
        
        let mass = 1 as CGFloat
        let stiffness = pow(2 * .pi / frequencyResponse, 2) * mass
        let damping = 4 * .pi * dampingRatio * mass / frequencyResponse
        
        self.init(mass: mass, stiffness: stiffness, damping: damping, initialVelocity: .zero)
    }
}

It's Broken


Solution

  • It's not entirely clear what your end-goal is, but...

    UIViewPropertyAnimator with UISpringTimingParameters is a visual effect ... it oscillates with a "spring" until it reaches its "final destination."

    This is what it might look like:

    enter image description here

    it is not continuously changing the frame of the view.

    Here's your code, modified so we can explain it:

    class SomeViewController: UIViewController {
        
        let blueAnimView: UIView = UIView()
        let redFollowView: UIView = UIView()
        
        let smallFrameView: UIView = UIView()
        let bigFrameView: UIView = UIView()
        
        var bigRect: CGRect = .zero
        var smallRect: CGRect = .zero
        
        var isBig = false
        
        override func viewDidLoad() {
            super.viewDidLoad()
    
            // define the small and big frames (rects)
            smallRect = CGRect(x: view.frame.midX - 120, y: view.frame.midY - 180, width: 240, height: 360)
            bigRect = CGRect(x: view.frame.midX - (270/2), y: view.frame.midY - (480/2), width: 270, height: 480)
            
            // 3-point red border
            redFollowView.border(3, color: .red)
    
            // let's make blue view's border thicker to make it easier to see what's going on
            blueAnimView.border(8, color: .blue)
    
            view.addSubview(bigFrameView)
            view.addSubview(smallFrameView)
            view.addSubview(blueAnimView)
            view.addSubview(redFollowView)
            
            // set the "start" frame of the blue view
            blueAnimView.frame = smallRect
    
            redFollowView.translatesAutoresizingMaskIntoConstraints = false
    
            // for simplicity, let's constrain redView to blueView on all 4 sides
            NSLayoutConstraint.activate([
                redFollowView.topAnchor.constraint(equalTo: blueAnimView.topAnchor),
                redFollowView.leadingAnchor.constraint(equalTo: blueAnimView.leadingAnchor),
                redFollowView.trailingAnchor.constraint(equalTo: blueAnimView.trailingAnchor),
                redFollowView.bottomAnchor.constraint(equalTo: blueAnimView.bottomAnchor),
            ])
            
            let gr = UITapGestureRecognizer(target: self, action: #selector(MyViewController.handleTap(_:)))
            view.addGestureRecognizer(gr)
    
            // so we can see the small and big frames
            bigFrameView.backgroundColor = .systemYellow
            smallFrameView.backgroundColor = .yellow
            bigFrameView.frame = bigRect
            smallFrameView.frame = smallRect
        }
        
        @objc func handleTap(_ gr: UITapGestureRecognizer) {
    
            let newFrame: CGRect
            if isBig {
                newFrame = smallRect
            } else {
                newFrame = bigRect
            }
            
            // slow spring
            let timing = UISpringTimingParameters.init(dampingRatio: 0.01, frequencyResponse: 10)
            
            // quick spring
            //let timing = UISpringTimingParameters.init(dampingRatio: 0.1, frequencyResponse: 0.5)
            
            let anim = UIViewPropertyAnimator(duration: 10, timingParameters: timing)
            anim.addAnimations {
                self.blueAnimView.frame = newFrame
            }
            anim.addCompletion { _ in
                print("completion")
                self.isBig = !self.isBig
            }
            
            anim.startAnimation()
        }
        
    }
    
    extension UIView {
        
        func border(_ width: Float, color: UIColor) {
            layer.borderColor = color.cgColor
            layer.borderWidth = CGFloat(width)
        }
    }
    
    extension UISpringTimingParameters {
        public convenience init(dampingRatio: CGFloat, frequencyResponse: CGFloat) {
            
            let mass = 1 as CGFloat
            let stiffness = pow(2 * .pi / frequencyResponse, 2) * mass
            let damping = 4 * .pi * dampingRatio * mass / frequencyResponse
            
            self.init(mass: mass, stiffness: stiffness, damping: damping, initialVelocity: .zero)
        }
    }
    

    First,

    • let's call the views blueAnimView and redFollowView
    • let's declare small and big frames (rects)
    • let's forget the 9:16 aspect ratio for now, and we'll constrain the redFollowView to all 4 sides of the blueAnimView

    Let's also add yellow and systemYellow views so we can see the small and big frames:

    enter image description here

    When we've add the red and blue views, it looks like this to start:

    enter image description here

    Now, as soon as we call anim.startAnimation() we see that the redFollowView's constraints cause it to match the "final destination" frame of the blueAnimView:

    enter image description here

    as the spring animation happens, the redFollowView does not change size, because the blueAnimView's frame is not changing size during the animation:

    enter image description here

    Here it is slowed-down for clarity:

    enter image description here

    If you want the red view to follow the blue view as it's animating you'll need to take a different approach.


    Edit

    To get this desired effect -- Blue view uses Spring animation to change size, and the Red (subview) matches either the width or height while maintaining a 9:16 ratio (effectively a "reverse-aspect-fit"):

    enter image description here

    enter image description here

    We need to toss the idea of constraining the views (see the above discussion).

    Instead, we'll:

    • use explicit frames
    • implement CADisplayLink
    • in the display link callback, calculate the new 9:16 frame for the Red view based on the blue view's presentation layer frame

    Sample code (no @IBOutlet or @IBAction connections) ... just assign a new, blank view controller to UsingDisplayLinkVC:

    class UsingDisplayLinkVC: UIViewController {
        
        let blueAnimView: UIView = UIView()
        let redFollowView: UIView = UIView()
        
        let smallFrameView: UIView = UIView()
        let bigFrameView: UIView = UIView()
        
        var bigRect: CGRect = .zero
        var smallRect: CGRect = .zero
        
        var isBig = false
        
        var displayLink: CADisplayLink!
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = .systemBackground
            
            // define the small and big frames (rects)
            smallRect = CGRect(x: view.frame.midX - 120, y: view.frame.midY - 180, width: 240, height: 360)
            bigRect = CGRect(x: view.frame.midX - (270/2), y: view.frame.midY - (480/2), width: 270, height: 480)
            
            // 3-point red border
            redFollowView.border(8, color: .red)
            
            // let's make blue view's border thicker to make it easier to see what's going on
            blueAnimView.border(3, color: .blue)
            
            view.addSubview(bigFrameView)
            view.addSubview(smallFrameView)
            
            // add redFollowView as subview of blueAnimView
            blueAnimView.addSubview(redFollowView)
            
            view.addSubview(blueAnimView)
            
            // set the "start" frame of the blueAnimView
            blueAnimView.frame = smallRect
            
            // calculate and set the redFollowView frame
            let h: CGFloat = blueAnimView.frame.width * 16.0 / 9.0
            redFollowView.frame = .init(x: 0.0, y: 0.0, width: blueAnimView.frame.width, height: h)
            redFollowView.center.x = blueAnimView.bounds.midX
            redFollowView.center.y = blueAnimView.bounds.midY
            
            
            let gr = UITapGestureRecognizer(target: self, action: #selector(MyViewController.handleTap(_:)))
            view.addGestureRecognizer(gr)
            
            // so we can see the small and big frames
            bigFrameView.backgroundColor = .systemYellow
            smallFrameView.backgroundColor = .yellow
            bigFrameView.frame = bigRect
            smallFrameView.frame = smallRect
            
            // init displayLink
            displayLink = CADisplayLink(target: self, selector: #selector(update))
    
            // start displayLink paused
            displayLink.isPaused = true
            
            // add to run loop
            displayLink.add(to: .current, forMode: .common)
    
        }
        
        @objc func handleTap(_ gr: UITapGestureRecognizer) {
            
            // if animation is running, return
            if !displayLink.isPaused { return }
            
            let newFrame: CGRect
            if isBig {
                newFrame = smallRect
            } else {
                newFrame = bigRect
            }
            
            // slow spring
            let timing = UISpringTimingParameters.init(dampingRatio: 0.01, frequencyResponse: 10)
            
            // quick spring
            //let timing = UISpringTimingParameters.init(dampingRatio: 0.1, frequencyResponse: 0.5)
            
            let anim = UIViewPropertyAnimator(duration: 10, timingParameters: timing)
            anim.addAnimations {
                self.blueAnimView.frame = newFrame
            }
            
            anim.addCompletion { _ in
                print("completion")
                self.isBig = !self.isBig
                // pause displayLink
                self.displayLink.isPaused = true
            }
            
            // un-pause displayLink
            displayLink.isPaused = false
            
            anim.startAnimation()
            
        }
        
        @objc func update() {
    
            // get the current frame of blueAnimView's presentation layer
            if let curBlueAnimFrame = blueAnimView.layer.presentation()?.frame {
                let aspect: CGFloat = curBlueAnimFrame.width / curBlueAnimFrame.height
                var w: CGFloat = 0.0
                var h: CGFloat = 0.0
                
                // if curBlueAnimFrame has aspect ratio greater than 9:16
                //  use curBlueAnimFrame width and calculate the new redFollowView height
                // else
                //  use curBlueAnimFrame height and calculate the new redFollowView width
                if aspect > 9.0 / 16.0 {
                    w = curBlueAnimFrame.width
                    h = w * 16.0 / 9.0
                } else {
                    h = curBlueAnimFrame.height
                    w = h * 9.0 / 16.0
                }
                // keep redFollowView centered
                let x: CGFloat = curBlueAnimFrame.midX - curBlueAnimFrame.origin.x
                let y: CGFloat = curBlueAnimFrame.midY - curBlueAnimFrame.origin.y
                redFollowView.frame = .init(x: 0.0, y: 0.0, width: w, height: h)
                redFollowView.center = .init(x: x, y: y)
            }
            
        }
        
    }
    

    Note: this is Sample Code Only!! It is not intended to be, nor should it be considered to be, "production ready."