UPDATE: If your deployment target is iOS 11 or later:
Starting in iOS 11, UIKit will animate cornerRadius
if you update it inside an animation block. Just set your view's layer.cornerRadius
in a UIView
animation block, or (to handle interface orientation changes), set it in layoutSubviews
or viewDidLayoutSubviews
.
ORIGINAL: If your deployment target is older than iOS 11:
So you want this:
(I turned on Debug > Slow Animations to make the smoothness easier to see.)
Side rant, feel free to skip this paragraph: This turns out to be a lot harder than it should be, because the iOS SDK doesn't make the parameters (duration, timing curve) of the autorotation animation available in a convenient way. You can (I think) get at them by overriding -viewWillTransitionToSize:withTransitionCoordinator:
on your view controller to call -animateAlongsideTransition:completion:
on the transition coordinator, and in the callback you pass, get the transitionDuration
and completionCurve
from the UIViewControllerTransitionCoordinatorContext
. And then you need to pass that information down to your CircleView
, which has to save it (because it hasn't been resized yet!) and later when it receives layoutSubviews
, it can use it to create a CABasicAnimation
for cornerRadius
with those saved animation parameters. And don't accidentally create an animation when it's not an animated resize… End of side rant.
Wow, that sounds like a ton of work, and you have to involve the view controller. Here's another approach that's entirely implemented inside CircleView
. It works now (in iOS 9) but I can't guarantee it'll always work in the future, because it makes two assumptions that could theoretically be wrong in the future.
Here's the approach: override -actionForLayer:forKey:
in CircleView
to return an action that, when run, installs an animation for cornerRadius
.
These are the two assumptions:
bounds.origin
and bounds.size
get separate animations. (This is true now but presumably a future iOS could use a single animation for bounds
. It would be easy enough to check for a bounds
animation if no bounds.size
animation were found.)
- The
bounds.size
animation is added to the layer before Core Animation asks for the cornerRadius
action.
Given these assumptions, when Core Animation asks for the cornerRadius
action, we can get the bounds.size
animation from the layer, copy it, and modify the copy to animate cornerRadius
instead. The copy has the same animation parameters as the original (unless we modify them), so it has the correct duration and timing curve.
Here's the start of CircleView
:
class CircleView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
updateCornerRadius()
}
private func updateCornerRadius() {
layer.cornerRadius = min(bounds.width, bounds.height) / 2
}
Note that the view's bounds are set before the view receives layoutSubviews
, and therefore before we update cornerRadius
. This is why the bounds.size
animation is installed before the cornerRadius
animation is requested. Each property's animations are installed inside the property's setter.
When we set cornerRadius
, Core Animation asks us for a CAAction
to run for it:
override func action(for layer: CALayer, forKey event: String) -> CAAction? {
if event == "cornerRadius" {
if let boundsAnimation = layer.animation(forKey: "bounds.size") as? CABasicAnimation {
let animation = boundsAnimation.copy() as! CABasicAnimation
animation.keyPath = "cornerRadius"
let action = Action()
action.pendingAnimation = animation
action.priorCornerRadius = layer.cornerRadius
return action
}
}
return super.action(for: layer, forKey: event)
}
In the code above, if we're asked for an action for cornerRadius
, we look for a CABasicAnimation
on bounds.size
. If we find one, we copy it, change the key path to cornerRadius
, and save it away in a custom CAAction
(of class Action
, which I will show below). We also save the current value of the cornerRadius
property, because Core Animation calls actionForLayer:forKey:
before updating the property.
After actionForLayer:forKey:
returns, Core Animation updates the cornerRadius
property of the layer. Then it runs the action by sending it runActionForKey:object:arguments:
. The job of the action is to install whatever animations are appropriate. Here's the custom subclass of CAAction
, which I've nested inside CircleView
:
private class Action: NSObject, CAAction {
var pendingAnimation: CABasicAnimation?
var priorCornerRadius: CGFloat = 0
public func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) {
if let layer = anObject as? CALayer, let pendingAnimation = pendingAnimation {
if pendingAnimation.isAdditive {
pendingAnimation.fromValue = priorCornerRadius - layer.cornerRadius
pendingAnimation.toValue = 0
} else {
pendingAnimation.fromValue = priorCornerRadius
pendingAnimation.toValue = layer.cornerRadius
}
layer.add(pendingAnimation, forKey: "cornerRadius")
}
}
}
} // end of CircleView
The runActionForKey:object:arguments:
method sets the fromValue
and toValue
properties of the animation and then adds the animation to the layer. There's a complication: UIKit uses “additive” animations, because they work better if you start another animation on a property while an earlier animation is still running. So our action checks for that.
If the animation is additive, it sets fromValue
to the difference between the old and new corner radii, and sets toValue
to zero. Since the layer's cornerRadius
property has already been updated by the time the animation is running, adding that fromValue
at the start of the animation makes it look like the old corner radius, and adding the toValue
of zero at the end of the animation makes it look like the new corner radius.
If the animation is not additive (which doesn't happen if UIKit created the animation, as far as I know), then it just sets the fromValue
and toValue
in the obvious way.
Here's the whole file for your convenience:
import UIKit
class CircleView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
updateCornerRadius()
}
private func updateCornerRadius() {
layer.cornerRadius = min(bounds.width, bounds.height) / 2
}
override func action(for layer: CALayer, forKey event: String) -> CAAction? {
if event == "cornerRadius" {
if let boundsAnimation = layer.animation(forKey: "bounds.size") as? CABasicAnimation {
let animation = boundsAnimation.copy() as! CABasicAnimation
animation.keyPath = "cornerRadius"
let action = Action()
action.pendingAnimation = animation
action.priorCornerRadius = layer.cornerRadius
return action
}
}
return super.action(for: layer, forKey: event)
}
private class Action: NSObject, CAAction {
var pendingAnimation: CABasicAnimation?
var priorCornerRadius: CGFloat = 0
public func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) {
if let layer = anObject as? CALayer, let pendingAnimation = pendingAnimation {
if pendingAnimation.isAdditive {
pendingAnimation.fromValue = priorCornerRadius - layer.cornerRadius
pendingAnimation.toValue = 0
} else {
pendingAnimation.fromValue = priorCornerRadius
pendingAnimation.toValue = layer.cornerRadius
}
layer.add(pendingAnimation, forKey: "cornerRadius")
}
}
}
} // end of CircleView
My answer was inspired by this answer by Simon.