How to Make a Uiview Grow in Uiview.Animate() with an Accurate Corner Radius

How to make a UIView grow in UIView.animate() with an accurate corner radius?

My goal is to make the circle expand and detract maintaining the form of a circle

Then don't make a circle in the "stupid" way (cornerRadius). Mask to a circular path and animate the growth / shrinkage of the mask (and/or the path).

In this example, I grow the yellow view along with its mask. The animation is deliberately slow (2-second duration) to show you that this works smoothly. The yellow view contains a label at the top left and a label at the bottom right, so that you can see that (1) it is a view and (2) it grows coherently.

Sample Image

Circular (round) UIView resizing with AutoLayout... how to animate cornerRadius during the resize animation?

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:

smoothly resizing circle view

(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.

UIView Animation Removes layer.cornerRadius After Completion

I suspect you're subclassing UILabel here since it looks like you have padding in there, is that correct?

There could be something going awry with any custom drawing/calculations you're doing in there, so it would probably be helpful to post that code for inspection as well.

A few questions:

  • Do you have masksToBounds set to YES?
  • If you're not using a custom UILabel subclass, are you wrapping the label in a view?
  • How is the animation being triggered? Is it by a button? A callback from a NSURLRequest? If it's triggered by an async callback are you jumping back on the main queue to perform the animation?
  • If the animation is triggered automatically within the lifecycle, which lifecycle method is it triggered in?

I wasn't able to reproduce the issue in a test project with a vanilla UILabel. I then tried it with a UILabel subclass which includes additional padding and still wasn't able to reproduce it there.

I've included example code snippets below:

#import "ViewController.h"
#import "R4NInsetLabel.h"

@interface ViewController ()
@property BOOL showingToast;
@property (strong, nullable) IBOutlet R4NInsetLabel *toastLabel;
@property (strong, nullable) IBOutlet NSLayoutConstraint *toastLabelTopConstraint;
@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];
self.navigationController.navigationBar.titleTextAttributes = @{NSForegroundColorAttributeName : [UIColor whiteColor]};
self.showingToast = NO;
// start with the label pushed off the top of the screen
self.toastLabelTopConstraint.constant = -40.0f;
self.toastLabel.layer.cornerRadius = 6.0f;
self.toastLabel.layer.masksToBounds = YES;
}

- (IBAction)toggleToast:(id)sender {
[UIView animateWithDuration:0.3 delay:0 usingSpringWithDamping:0.7 initialSpringVelocity:0.4 options:UIViewAnimationOptionCurveEaseInOut animations:^{
if (self.showingToast == NO) {
self.toastLabelTopConstraint.constant = 16;
self.showingToast = YES;
} else {
self.toastLabelTopConstraint.constant = -40;
self.showingToast = NO;
}
[self.view layoutIfNeeded];
} completion:nil];
}

@end
#import "R4NInsetLabel.h"

IB_DESIGNABLE
@interface R4NInsetLabel()
@property IBInspectable CGFloat contentPadding;
@property (nonatomic) UIEdgeInsets contentInsets;
- (CGSize)_addInsetsToSize:(CGSize)size;
@end

@implementation R4NInsetLabel

- (UIEdgeInsets)contentInsets {
return UIEdgeInsetsMake(self.contentPadding, self.contentPadding, self.contentPadding, self.contentPadding);
}

- (CGSize)_addInsetsToSize:(CGSize)size {
CGFloat width = size.width + self.contentInsets.left + self.contentInsets.right;
CGFloat height = size.height + self.contentInsets.top + self.contentInsets.bottom;
return CGSizeMake(width, height);
}

- (void)drawTextInRect:(CGRect)rect {
CGRect insetRect = UIEdgeInsetsInsetRect(rect, self.contentInsets);
[super drawTextInRect:insetRect];
}

- (CGSize)intrinsicContentSize {
CGSize baseSize = [super intrinsicContentSize];
return [self _addInsetsToSize:baseSize];
}

- (CGSize)sizeThatFits:(CGSize)size {
CGSize baseSize = [super sizeThatFits:size];
return [self _addInsetsToSize:baseSize];
}

@end

And here's what it looks like:

Toast_Animation_With_Corner_Radius

CGAffineTransform affects the corner radius of my UIView

I think your best bet is to just change the frame property of elementView:

UIView.animate(withDuration: 0.5, delay: delay, options: [.autoreverse, .repeat], animations: {
elementView.frame = CGRect(x: elementView.frame.origin.x, y: elementView.frame.origin.y, width: elementView.frame.size.width, height: elementView.frame.size.height * 0.4)
}) { (finished) in

}

or if you're using auto-layout:

heightContraint.constant = heightConstraint * 0.4
UIView.animate(withDuration: 0.5, delay: delay, options: [.autoreverse, .repeat], animations: {
elementView.layoutIfNeeded()
}) { (finished) in

}

Either way should keep your corner radius while shortening the view.

Turn rectangular UIImageView into a circle

Your solution sounds fine to me, but what might be going wrong, is the fact that animating corner radius is not supported by UIView.animateWithDuration as seen in the View Programming Guide for iOS. Therefor, the results are sometimes not whats expected.

You can try out the following code.

func animate() {

//Some properties that needs to be set on UIImageView
self.imageView.clipsToBounds = true
self.imageView.contentMode = .ScaleAspectFill

//Create animations
let cornerRadiusAnim = changeCornerRadiusAnimation()
let squareAnim = makeSquareAnimation()
let boundsAnim = changeBoundsAnimation()
let positionAnim = changePositionAnimation(CGPointMake(300,480))

//Use group for sequenced execution of animations
let animationGroup = CAAnimationGroup()
animationGroup.animations = [cornerRadiusAnim, squareAnim, boundsAnim, positionAnim]
animationGroup.fillMode = kCAFillModeForwards
animationGroup.removedOnCompletion = false
animationGroup.duration = positionAnim.beginTime + positionAnim.duration
imageView.layer.addAnimation(animationGroup, forKey: nil)

}

func makeSquareAnimation() -> CABasicAnimation {

let center = imageView.center
let animation = CABasicAnimation(keyPath:"bounds")
animation.duration = 0.1;
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
animation.fromValue = NSValue(CGRect: imageView.frame)
animation.toValue = NSValue(CGRect: CGRectMake(center.x,center.y,imageView.frame.width,imageView.frame.width))
animation.fillMode = kCAFillModeForwards
animation.removedOnCompletion = false
return animation

}

func changeBoundsAnimation() -> CABasicAnimation {

let animation = CABasicAnimation(keyPath:"transform.scale")
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
animation.fromValue = imageView.layer.mask?.valueForKeyPath("transform.scale")
animation.toValue = 0.1
animation.duration = 0.5
animation.beginTime = 0.1
animation.fillMode = kCAFillModeForwards
animation.removedOnCompletion = false
return animation

}

func changeCornerRadiusAnimation() -> CABasicAnimation {

let animation = CABasicAnimation(keyPath:"cornerRadius")
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
animation.fromValue = 0
animation.toValue = imageView.frame.size.width * 0.5
animation.duration = 0.1
animation.fillMode = kCAFillModeForwards
animation.removedOnCompletion = false
return animation

}

func changePositionAnimation(newPosition: CGPoint) -> CABasicAnimation {

let animation = CABasicAnimation(keyPath: "position")
animation.fromValue = NSValue(CGPoint: imageView.layer.position)
animation.toValue = NSValue(CGPoint: newPosition)
animation.duration = 0.3
animation.beginTime = 0.6
animation.fillMode = kCAFillModeForwards
animation.removedOnCompletion = false
return animation
}

How do I create a smoothly resizable circular UIView?

With many thanks to David, this is the solution I found. In the end what turned out to be the key to it was using the view's -[actionForLayer:forKey:] method, since that's used inside UIView blocks instead of whatever the layer's -[actionForKey] returns.

@implementation SGBRoundView

-(CGFloat)radiusForBounds:(CGRect)bounds
{
return fminf(bounds.size.width, bounds.size.height) / 2;
}

- (id)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
self.backgroundColor = [UIColor clearColor];
self.opaque = NO;
self.layer.backgroundColor = [[UIColor purpleColor] CGColor];
self.layer.borderColor = [[UIColor greenColor] CGColor];
self.layer.borderWidth = 3;
self.layer.cornerRadius = [self radiusForBounds:self.bounds];
}
return self;
}

-(void)setBounds:(CGRect)bounds
{
self.layer.cornerRadius = [self radiusForBounds:bounds];
[super setBounds:bounds];
}

-(id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event
{
id<CAAction> action = [super actionForLayer:layer forKey:event];

if ([event isEqualToString:@"cornerRadius"])
{
CABasicAnimation *boundsAction = (CABasicAnimation *)[self actionForLayer:layer forKey:@"bounds"];
if ([boundsAction isKindOfClass:[CABasicAnimation class]] && [boundsAction.fromValue isKindOfClass:[NSValue class]])
{
CABasicAnimation *cornerRadiusAction = [CABasicAnimation animationWithKeyPath:@"cornerRadius"];
cornerRadiusAction.delegate = boundsAction.delegate;
cornerRadiusAction.duration = boundsAction.duration;
cornerRadiusAction.fillMode = boundsAction.fillMode;
cornerRadiusAction.timingFunction = boundsAction.timingFunction;

CGRect fromBounds = [(NSValue *)boundsAction.fromValue CGRectValue];
CGFloat fromRadius = [self radiusForBounds:fromBounds];
cornerRadiusAction.fromValue = [NSNumber numberWithFloat:fromRadius];

return cornerRadiusAction;
}
}

return action;
}

@end

By using the action that the view provides for the bounds, I was able to get the right duration, fill mode and timing function, and most importantly delegate - without that, the completion block of UIView animations didn't run.

The radius animation follows that of the bounds in almost all circumstances - there are a few edge cases that I'm trying to iron out, but it's basically there. It's also worth mentioning that the pinch gestures are still sometimes jerky - I guess even the accelerated drawing is still costly.

Why the edges of UIView are not smooth for huge corner radius?

With iOS 13.0 you can simple do, in addition to setting corner radius

yourView.layer.cornerCurve = .continuous

UIView animateWithDuration doesn't animate cornerRadius variation

tl;dr: Corner radius is not animatable in animateWithDuration:animations:.



What the documentation says about view animations.

As the section on Animations in the "View Programming Guide for iOS" says

Both UIKit and Core Animation provide support for animations, but the level of support provided by each technology varies. In UIKit, animations are performed using UIView objects

The full list of properties that you can animate using either the older

[UIView beginAnimations:context:];
[UIView setAnimationDuration:];
// Change properties here...
[UIView commitAnimations];

or the newer

[UIView animateWithDuration:animations:];

(that you are using) are:

  • frame
  • bounds
  • center
  • transform (CGAffineTransform, not the CATransform3D)
  • alpha
  • backgroundColor
  • contentStretch

As you can see, cornerRadius is not in the list.

Some confusion

UIView animations is really only meant for animating view properties. What confuses people is that you can also animate the same properties on the layer inside the UIView animation block, i.e. the frame, bounds, position, opacity, backgroundColor. So people see layer animations inside animateWithDuration and believe that they can animate any view property in there.

The same section goes on to say:

In places where you want to perform more sophisticated animations, or animations not supported by the UIView class, you can use Core Animation and the view’s underlying layer to create the animation. Because view and layer objects are intricately linked together, changes to a view’s layer affect the view itself.

A few lines down you can read the list of Core Animation animatable properties where you see this one:

  • The layer’s border (including whether the layer’s corners are rounded)

So to animate the cornerRadius you need to use Core Animation as you've already said in your updated question (and answer). I just added tried to explain why its so.



Some extra clarification

When people read the documentations that says that animateWithDuration is the recommended way of animating it is easy to believe that it is trying to replace CABasicAnimation, CAAnimationGroup, CAKeyframeAnimation, etc. but its really not. Its replacing the beginAnimations:context: and commitAnimations that you seen above.



Related Topics



Leave a reply



Submit