Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

Tags:

ios

uiview

I have a subclassed UIView that we can call CircleView. CircleView automatically sets a corner radius to half of its width in order for it to be a circle.

The problem is that when "CircleView" is resized by an AutoLayout constraint... for example on a device rotation... it distorts badly until the resize takes place because the "cornerRadius" property has to catch up, and the OS only sends a single "bounds" change to the view's frame.

I was wondering if anyone had a good, clear strategy for implementing "CircleView" in a way that won't distort in such instances, but will still mask its contents to the shape of a circle and allow for a border to exist around said UIView.

like image 268
Ben Guild Avatar asked Mar 01 '16 01:03

Ben Guild


3 Answers

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.

like image 159
rob mayoff Avatar answered Oct 17 '22 20:10

rob mayoff


This answer builds upon the earlier answer by rob mayoff. Basically, I implemented it for our project and it worked just fine on the iPhone (iOS 9 and 10), but the issue remained on iPad (iOS 9 or 10).

Debugging, I found that the if statement:

if let boundsAnimation = layer.animation(forKey: "bounds.size") as? CABasicAnimation {

always failed on iPad. It looks like the animations are built in a different sequence on iPad than iPhone. Looking back at the original answer by Simon, it seems that sequencing has changed before. So I combined both answers giving me something like this:

override func action(for layer: CALayer, forKey event: String) -> CAAction? {
    let buildAction: (CABasicAnimation) -> Action = { boundsAnimation in
        let animation = boundsAnimation.copy() as! CABasicAnimation
        animation.keyPath = "cornerRadius"
        let action = Action()
        action.pendingAnimation = animation
        action.priorCornerRadius = layer.cornerRadius
        return action
    }

    if event == "cornerRadius" {
        if let boundsAnimation = layer.animation(forKey: "bounds.size") as? CABasicAnimation {
            return buildAction(boundsAnimation)
        } else if let boundsAnimation = self.action(for: layer, forKey: "bounds") as? CABasicAnimation {
            return buildAction(boundsAnimation)
        }
    }

    return super.action(for: layer, forKey: event)
}

By combining both answers, it seems to work properly on both iPhone and iPad under iOS 9 and 10. I haven't really tested further, and don't know enough about CoreAnimation to fully understand this change.

like image 43
Daniel Brotherston Avatar answered Oct 17 '22 21:10

Daniel Brotherston


In iOS 10 you don't need to create a CAAction, it works just creating a CABasicAnimation and provide this in your action(for layer:, for key:) -> CAAction? function (See Swift example):

private var currentBoundsAnimation: CABasicAnimation? {
    return layer.animation(forKey: "bounds.size") as? CABasicAnimation ?? layer.animation(forKey: "bounds") as? CABasicAnimation
}

override public var bounds: CGRect {
    didSet {
        layer.cornerRadius = min(bounds.width, bounds.height) / 2
    }
}

override public func action(for layer: CALayer, forKey event: String) -> CAAction? {
    if(event == "cornerRadius"), let boundsAnimation = currentBoundsAnimation {
        let animation = CABasicAnimation(keyPath: "cornerRadius")
        animation.duration = boundsAnimation.duration
        animation.timingFunction = boundsAnimation.timingFunction
        return animation
    }

    return super.action(for: layer, forKey: event)
}

Instead of overriding the bounds property you can also override the layoutSubviews:

override func layoutSubviews() {
    super.layoutSubviews()
    layer.cornerRadius = min(bounds.width, bounds.height) / 2
}

This works magically because the CABasicAnimation infers the missing from and to values from the model and presentation layers. To set the timing correctly you need the private currentBoundsAnimation property to get the current animations ("bounds" for iPad and "bounds.size" for iPhone) which where added on device rotation.

like image 1
user2782993 Avatar answered Oct 17 '22 19:10

user2782993