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.
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 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:
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,
blueAnimView
and redFollowView
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:
When we've add the red and blue views, it looks like this to start:
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
:
as the spring animation happens, the redFollowView
does not change size, because the blueAnimView
's frame is not changing size during the animation:
Here it is slowed-down for clarity:
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"):
We need to toss the idea of constraining the views (see the above discussion).
Instead, we'll:
CADisplayLink
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."
Edit 2
As discussed in the comments, the above is not quite right. On the update()
callback, we:
presentation layer
Unfortunately, when we set the frame it does not visually change until the next draw cycle... when the animated view is already its next size. So the follow view is always one frame behind the animated view.
One option would be to write our own (or find) Spring Animation code then calculate and set both frames on each Display Link update.
Or, we can "cheat" ...
UIView
Now, both outline views' frames will be set at the same time. We'll still be "one-frame-behind" the animation frame, but since it is clear we won't see it.
Quick sample code to demonstrate:
class CheatVC: UIViewController {
let blueView = UIView()
let redView = UIView()
let cheatAnimView = 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)
//smallRect = CGRect(x: view.frame.midX - 120, y: view.frame.midY - 80, width: 240, height: 160)
bigRect = CGRect(x: view.frame.midX - (270/2), y: view.frame.midY - (480/2), width: 270, height: 480)
// 3-point red border
redView.border(3, color: .red)
// let's make blueView's border thicker to make it easier to see what's going on
blueView.border(8, color: .blue)
view.addSubview(bigFrameView)
view.addSubview(smallFrameView)
// add blueView and redView as siblings
view.addSubview(blueView)
view.addSubview(redView)
// set the "start" frame of the blueAnimView
blueView.frame = smallRect
// calculate and set the redView frame
let h: CGFloat = blueView.frame.width * 16.0 / 9.0
redView.frame = .init(x: 0.0, y: 0.0, width: blueView.frame.width, height: h)
redView.center = blueView.center
cheatAnimView.frame = smallRect
view.addSubview(cheatAnimView)
cheatAnimView.backgroundColor = .clear
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
var timing = UISpringTimingParameters.init(dampingRatio: 0.01, frequencyResponse: 10)
// quick spring
timing = UISpringTimingParameters.init(dampingRatio: 0.1, frequencyResponse: 0.15)
let anim = UIViewPropertyAnimator(duration: 2, timingParameters: timing)
anim.addAnimations {
self.cheatAnimView.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 cheatAnimView's presentation layer
if let curAnimFrame = cheatAnimView.layer.presentation()?.frame {
let aspect: CGFloat = curAnimFrame.width / curAnimFrame.height
var w: CGFloat = 0.0
var h: CGFloat = 0.0
// if blueView's frame has aspect ratio greater than 9:16
// use blueView width and calculate the new redView height
// else
// use blueView height and calculate the new redView width
if aspect > 9.0 / 16.0 {
w = curAnimFrame.width
h = w * 16.0 / 9.0
} else {
h = curAnimFrame.height
w = h * 9.0 / 16.0
}
blueView.frame = curAnimFrame
redView.frame = .init(x: 0.0, y: 0.0, width: w, height: h)
redView.center = blueView.center
}
}
}