How to animate a custom property in iOS - ios

I have a custom UIView that draws its contents using Core Graphics calls. All working well, but now I want to animate a change in value that affects the display. I have a custom property to achieve this in my custom UView:
var _anime: CGFloat = 0
var anime: CGFloat {
set {
_anime = newValue
for(gauge) in gauges {
gauge.animate(newValue)
}
setNeedsDisplay()
}
get {
return _anime
}
}
And I have started an animation from the ViewController:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.emaxView.anime = 0.5
UIView.animate(withDuration: 4) {
DDLogDebug("in animations")
self.emaxView.anime = 1.0
}
}
This doesn't work - the animated value does change from 0.5 to 1.0 but it does so instantly. There are two calls to the anime setter, once with value 0.5 then immediately a call with 1.0. If I change the property I'm animating to a standard UIView property, e.g. alpha, it works correctly.
I'm coming from an Android background, so this whole iOS animation framework looks suspiciously like black magic to me. Is there any way of animating a property other than predefined UIView properties?
Below is what the animated view is supposed to look like - it gets a new value about every 1/2 second and I want the pointer to move smoothly over that time from the previous value to the next. The code to update it is:
open func animate(_ progress: CGFloat) {
//DDLogDebug("in animate: progress \(progress)")
if(dataValid) {
currentValue = targetValue * progress + initialValue * (1 - progress)
}
}
And calling draw() after it's updated will make it redraw with the new pointer position, interpolating between initialValue and targetValue

Short answer: use CADisplayLink to get called every n frames. Sample code:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let displayLink = CADisplayLink(target: self, selector: #selector(animationDidUpdate))
displayLink.preferredFramesPerSecond = 50
displayLink.add(to: .main, forMode: .defaultRunLoopMode)
updateValues()
}
var animationComplete = false
var lastUpdateTime = CACurrentMediaTime()
func updateValues() {
self.emaxView.animate(0);
lastUpdateTime = CACurrentMediaTime()
animationComplete = false
}
func animationDidUpdate(displayLink: CADisplayLink) {
if(!animationComplete) {
let now = CACurrentMediaTime()
let interval = (CACurrentMediaTime() - lastUpdateTime)/animationDuration
self.emaxView.animate(min(CGFloat(interval), 1))
animationComplete = interval >= 1.0
}
}
}
The code could be refined and generalised but it's doing the job I needed.

You will need to call layoufIfNeeded() instead of setNeedsDisplay() if you modify any auto layout constraints in your gauge.animate(newValue) function.
https://stackoverflow.com/a/12664093/255549

If that is drawn entirely with CoreGraphics there is a pretty simple way to animate this if you want to do a little math. Fortunately you have a scale there that tells you the number of radians exactly to rotate, so the math is minimal and no trigonometry is involved. The advantage of this is you won't have to redraw the entire background, or even the pointer. It can be a bit tricky to get angles and stuff right, I can help out if the following doesn't work.
Draw the background of the view normally in draw(in rect). The pointer you should put into a CALayer. You can pretty much just move the draw code for the pointer, including the centre dark gray circle into a separate method that returns a UIImage. The layer will be sized to the frame of the view (in layout subviews), and the anchor point has to be set to (0.5, 0.5), which is actually the default so you should be ok leaving that line out. Then your animate method just changes the layer's transform to rotate according to what you need. Here's how I would do it. I'm going to change the method and variable names because anime and animate were just a bit too obscure.
Because layer properties implicitly animate with a duration of 0.25 you might be able to get away without even calling an animation method. It's been a while since I've worked with CoreAnimation, so test it out obviously.
The advantage here is that you just set the RPM of the dial to what you want, and it will rotate over to that speed. And no one will read your code and be like WTF is _anime! :) I have included the init methods to remind you to change the contents scale of the layer (or it renders in low quality), obviously you may have other things in your init.
class SpeedDial: UIView {
var pointer: CALayer!
var pointerView: UIView!
var rpm: CGFloat = 0 {
didSet {
pointer.setAffineTransform(rpm == 0 ? .identity : CGAffineTransform(rotationAngle: rpm/25 * .pi))
}
}
override init(frame: CGRect) {
super.init(frame: frame)
pointer = CALayer()
pointer.contentsScale = UIScreen.main.scale
pointerView = UIView()
addSubview(pointerView)
pointerView.layer.addSublayer(pointer)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
pointer = CALayer()
pointer.contentsScale = UIScreen.main.scale
pointerView = UIView()
addSubview(pointerView)
pointerView.layer.addSublayer(pointer)
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
context.saveGState()
//draw background with values
//but not the pointer or centre circle
context.restoreGState()
}
override func layoutSubviews() {
super.layoutSubviews()
pointerView.frame = bounds
pointer.frame = bounds
pointer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
pointer.contents = drawPointer(in: bounds)?.cgImage
}
func drawPointer(in rect: CGRect) -> UIImage? {
UIGraphicsBeginImageContextWithOptions(rect.size, false, 0)
guard let context = UIGraphicsGetCurrentContext() else { return nil }
context.saveGState()
// draw the pointer Image. Make sure to draw it pointing at zero. ie at 8 o'clock
// I'm not sure what your drawing code looks like, but if the pointer is pointing
// vertically(at 12 o'clock), you can get it pointing at zero by rotating the actual draw context like so:
// perform this context rotation before actually drawing the pointer
context.translateBy(x: rect.width/2, y: rect.height/2)
context.rotate(by: -17.5/25 * .pi) // the angle judging by the dial - remember .pi is 180 degrees
context.translateBy(x: -rect.width/2, y: -rect.height/2)
context.restoreGState()
let pointerImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return pointerImage
}
}
The pointer's identity transform has it pointing at 0 RPM, so every time you up the RPM to what you want, it will rotate up to that value.
edit: tested it, it works. Except I made a couple errors - you don't need to change the layers position, I updated the code accordingly. Also, changing the layer's transform triggers layoutSubviews in the immediate parent. I forgot about this. The easiest way around this is to put the pointer layer into a UIView that is a subview of SpeedDial. I've updated the code. Good luck! Maybe this is overkill, but its a bit more reusable than animating the entire rendering of the view, background and all.

Related

Animate a rotated label in swift?

I am trying to animate a rotated label like this:
#IBOutlet fileprivate weak var loadingLabel: UILabel!
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
loadingLabel.transform = CGAffineTransform(rotationAngle: CGFloat(0.2)) // rotation line
UIView.animate(withDuration: 2.0, animations: {
self.loadingLabel.transform = CGAffineTransform(translationX: 0, y: self.view.bounds.size.height)
})
}
When I comment out the rotation line of code (and keep the label unrotated), it works fine. But when I rotate it, the label starts off the screen at the beginning of the animation:
When I comment out the animation, the label is rotated perfectly fine (but no animation obviously):
How do I rotate the image and animate it, without having this weird placement?
Edit: To clarify: I want the label to start off rotated in the center of the screen, and just simply move the label. I do not want to rotate the image during the animation.
The correct answer is that you are supposed to concatenate the transformation matrices. If you don't want to do linear algebra then the easy way is that you use the transform to set the rotation and don't animate it, then animate the view's frame/center instead.
import UIKit
class V: UIViewController {
#IBOutlet var label: UILabel!
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
label.transform = CGAffineTransform(rotationAngle: CGFloat.pi / 6)
label.center.x += 300
UIView.animate(withDuration: 2) {
self.label.center.x -= 300
}
}
}
You can perform the animation with CABasicAnimation as it will give you more control on the animation and it has a completion block on which you can hide your label as well upon your requirement.
loadingLabel.transform = CGAffineTransform(rotationAngle: CGFloat(0.2)) // rotation line
let animationKey = "position.y"
CATransaction.begin()
let moveYAnimation = CABasicAnimation( keyPath: animationKey)
moveYAnimation.fromValue = loadingLabel.frame.origin.y
moveYAnimation.toValue = self.view.bounds.size.height
moveYAnimation.duration = 2
loadingLabel.layer.add( moveYAnimation, forKey: animationKey )
// Callback function
CATransaction.setCompletionBlock {
print("end animation")
self.loadingLabel.isHidden = true
}
// Do the actual animation and commit the transaction
loadingLabel.layer.add(moveYAnimation, forKey: animationKey)
CATransaction.commit()
Hope it will help you.
The first transform is ot of the animation block, it's why it begin out of the screen.
You should move it in the animation block, and use a completion handler to animate again.
UIView.animate(withDuration: 2.0, animations: {
//
}, completion: { (result) in
//
})
Be carefull, the angle is in radians.

Why isn't the draw method of the custom CALayer called earlier?

Issue
I trigger random animations on a selection of UIViews using a Timer. All animations work as intended but one where the draw method of a CALayer is called after the exit of the timer selector.
Detailed Description
For the sake of clarity and simplification, let me schematise the actions performed.
All the animations I have created so far work as intended: they are a combination of CABasicAnimations on existing subviews/sublayers or new ones added to the selected view hierarchy. They are coded as an UIView extension, so that they can be called on any views, irrespectively of the view or the view controller the view is in. Well, all work except one.
I have indeed created a custom CALayer class which consists in drawing patterns on a CALayer. In an extension of this custom class, there is a method to animate those patterns (see hereafter the code). So all in all, when I reach the step/method animate selected view and run this particular animation, here is what should happen:
a method named animatePattern is called
this method adds the custom CALayer class to the selected view and then calls the animation extension of this layer
The issue: if with all the other animations, all the drawings are performed prior to the exit of the animate selected view step/method, in that particular case, the custom CALayer class draw method is called after the exit of the performAnimation method, which in turn results in the crash of the animation.
I should add that I have tried the custom CALayer class animation in a separate and simplified playground and it works well (I add the custom layer to a UIView in the UIViewController's viewDidLoad method and then I call the layer animation in the UIViewController's viewDidAppear method.)
The code
the method called by animate selected view step/method:
func animatePattern(for duration: TimeInterval = 1) {
let patternLayer = PatternLayer(effectType: .dark)
patternLayer.frame = self.bounds
self.layer.addSublayer(patternLayer)
patternLayer.play(for: duration)
}
(note that this method is in a UIView extension, therefore self here represents the UIView on which the animation has been called)
the simplified custom CALayer Class:
override var bounds: CGRect {
didSet {
self.setNeedsDisplay()
}
// Initializers
override init() {
super.init()
self.setNeedsDisplay()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
init(effectType: EffectType){
super.init()
// sets various properties
self.setNeedsDisplay()
}
// Drawing
override func draw(in ctx: CGContext) {
super.draw(in: ctx)
guard self.frame != CGRect.zero else { return }
self.masksToBounds = true
// do the drawings
}
the animation extension of the custom CALayer class:
func play(for duration: Double, removeAfterCompletion: RemoveAfterCompletion = .no) {
guard self.bounds != CGRect.zero else {
print("Cannot animate nil layer")
return }
CATransaction.begin()
CATransaction.setCompletionBlock {
if removeAfterCompletion.removeStatus {
if case RemoveAfterCompletion.yes = removeAfterCompletion { self.fadeOutDuration = 0.0 }
self.fadeToDisappear(duration: &self.fadeOutDuration)
}
}
// perform animations
CATransaction.commit()
}
Attempts so far
I have tried to force draw the layer by inserting setNeedsDisplay / setNeedsLayout at various places in the code but it does not work: debugging the custom CALayer class's draw method is constantly reached after the exit of the performAnimation method, whilst it should be called when the layer's frame is modified in the animatePattern method. I must miss something quite obvious but I am currently running in circles and I'd appreciate a fresh pair of eyes on it.
Thank you in advance for taking the time to consider this issue!
Best,
You override the draw(_ context) as the UIView can be the delegate of CALayer.
UIView: {
var patternLayer : PatternLayer
func animatePattern(for duration: TimeInterval = 1) {
patternLayer = PatternLayer(effectType: .dark)
patternLayer.frame = self.bounds
self.layer.addSublayer(patternLayer)
}
func draw(_ layer: CALayer, in ctx: CGContext){
layer.draw(ctx)
if NECESSARY { patternLayer.play(for: duration)}
}
}
As it is frequently the case, when you take the time to write down your issue, new ideas start to pop up. In fact, I remembered that self.setNeedsDisplay() informs the system that the layer needs to be (re)drawn but it does not (re)draw it at once: it will be done during the next refresh. It seems then that the refresh occurs after the end of the cycle for that specific animation. In order to overcome this is issue, I first added a call for the display method right after the patternLayer bounds are set, and it worked, but given #Matt comment, I changed the solution by calling displayIfNeeded method instead. It works as well.
func animatePattern(for duration: TimeInterval = 1) {
let patternLayer = PatternLayer(effectType: .dark)
patternLayer.frame = self.bounds
self.layer.addSublayer(patternLayer)
// asks the system to draw the layer now
patternLayer.displayIfNeeded()
patternLayer.play(for: duration)
}
Again, should anyone come up with another more elegant solution/explanation, please do not refrain to share!

I'm having trouble getting this CA animation to work

I'm trying to make an animation where the color red pulses on the screen and when the screen is tapped the speed of the pulse increases. Right now I have two issues. One, is that the animation does not occur, the second is that the animation must speed up without restarting the animation. I want the animation to speed up from whatever point it is at when the screen is tapped.
class ViewController: UIViewController {
var tapCount: Int = 0
var pulseSpeed: Double = 3
let pulseAnimLayer = CALayer()
let pulseAnim = CABasicAnimation(keyPath: "Opacity")
override func viewDidLoad() {
super.viewDidLoad()
counter.center = CGPoint(x: 185, y: 118)
pulseAnim.fromValue = 0.5
pulseAnim.toValue = 1.0
pulseAnim.duration = 3.0
pulseAnim.autoreverses = true
pulseAnim.repeatCount = .greatestFiniteMagnitude
pulseAnimLayer.add(pulseAnim, forKey: "Opacity")
}
func pulseAnimation(pulseSpeed: Double) {
UIView.animate(withDuration: pulseSpeed, delay: 0,
options: [UIViewAnimationOptions.repeat, UIViewAnimationOptions.autoreverse],
animations: {
self.red.alpha = 0.5
self.red.alpha = 1.0
}
)
}
#IBOutlet weak var red: UIImageView!
#IBOutlet weak var counter: UILabel!
#IBAction func screenTapButton(_ sender: UIButton) {
tapCount += 1
counter.text = "\(tapCount)"
pulseSpeed = Double(3) / Double(tapCount)
pulseAnim.duration = pulseSpeed
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
}
I didn't read this carefully enough the first time.
You've got an odd mix here of core animation and UIView animation. The two exist at different levels. Core Animation will allow you to animate a property on a view (say it's background color).
UIView animation is built on top of Core Animation and makes it easier to animate view changes automatically by building the underlying Core Animations.
You start with Core Animation when you create a CABasicAnimation but then you have a function, pulseAnimation which looks to be doing something using UIVIew animations. I think you can get rid of that function entirely.
When you add your CABasicAnimation you tell it to change the value of the layer's "Opacity". What you probably want there is "opacity" (lower case).
Your animation is being applied to a new CALayer that you've created... but you don't seem to ever put that layer into the view (so it is never drawn). More likely than not what you should probably do is attach your animation to the view's layer and just allow that layer to have it's color animated.
To add one layer to another use addSublayer (https://developer.apple.com/reference/quartzcore/calayer/1410833-addsublayer). The view's layer from your code should be available as self.view.layer though you may have to use it in viewWillAppear instead of in viewDidLoad
You need to set the alpha of the red imageview to 0.0 before animating
func pulseAnimation(pulseSpeed: Double) {
UIView.animate(withDuration: pulseSpeed, delay: 1.0,
options: [UIViewAnimationOptions.repeat, UIViewAnimationOptions.autoreverse],
animations: {
self.red.alpha = 0.5
self.red.alpha = 1.0
}
)
}
override func viewDidAppear(_ animated: Bool) {
self.red.alpha = 0.0
counter.center = CGPoint(x: 185, y: 118)
}

Properly subclassing MKOverlayRenderer

I'm trying to modify the path of a MKPolyline at runtime to avoid it overlaps with another one.
I already managed to get all the overlapping points and what I'm trying to do is in the func createPath() of the MKPolylineRenderer add an offset to does points so, theoretically, it should draw the same path with the little offset I'm adding and it shouldn't overlap anymore, but sadly, this is not happening and the Polyline is drawn in the same way like nothing changed.
I first tried to do this after the addPolyline() function but I read that once you do that, the one way to redraw a Polyline is by removing it and adding it again, so I decided, for testing purposes, to do all of this before adding the Polyline so when I finally add it to the map, it will already have the information about the overlapping points, but this didn't worked either.
Hypothesis:
1. It has something to do that the map works on different threads and the changes are not reflected because of that. This is ok. It should be this way to optimise the rendering.
2. The correct way to accomplish this is not in the createPath() function. Indeed it isn't
I should apply a transform in the draw() function of the renderer. This is it
This is the createPath() function
override func createPath()
{
let poly = polyline as! TransportPolyline
switch poly.id
{
case 1:
let newPath = CGMutablePath()
for index in 0...poly.pointCount
{
let point = poly.points()[index]
let predicate = { MKMapPointEqualToPoint($0, poly.points()[index]) }
//This is the offset I should apply
let offset: CGFloat = overlapsAtPoints.contains(predicate) ? 100000.0 : 0.0
//I tried to use a transform as well, but the result was the same
var transform = CGAffineTransform(translationX: offset, y: offset)
if index == 0
{
//Here I add the offset and/or the transform without success
newPath.moveTo(&transform, x: CGFloat(point.x) + offset, y: CGFloat(point.y) + offset)
}
else
{
//Here as well
newPath.addLineTo(&transform, x: CGFloat(point.x) + offset, y: CGFloat(point.y) + offset)
}
}
//Set the new path to the Renderer path property
self.path = newPath
default: break
}
}
And this is the draw() function
override func draw(_ mapRect: MKMapRect, zoomScale: MKZoomScale, in context: CGContext)
{
let poly = polyline as! TransportPolyline
guard poly.id == 1 else {
super.draw(mapRect, zoomScale: zoomScale, in: context)
return
}
//If I apply this the Polyline does move, obviously it move all the Path and not only the segments I want.
context.translate(x: 1000, y: 1000)
super.draw(mapRect, zoomScale: zoomScale, in: context)
}
Any suggestions are much appreciated.
UPDATE:
I found out that the problem might be in how I'm drawing the context in the draw method.
The documentation says:
The default implementation of this method does nothing. Subclasses are
expected to override this method and use it to draw the overlay’s
contents.
so by calling super.draw() I'm not doing anything.
Any ideas on how to properly override this method? Also taking into consideration this:
To improve drawing performance, the map view may divide your overlay
into multiple tiles and render each one on a separate thread. Your
implementation of this method must therefore be capable of safely
running from multiple threads simultaneously. In addition, you should
avoid drawing the entire contents of the overlay each time this method
is called. Instead, always take the mapRect parameter into
consideration and avoid drawing content outside that rectangle.
So basically I was on the right track but using the wrong tools. The actual way to accomplish this is by overriding the draw() function in you MKPolylineRenderer subclass.
override func draw(_ mapRect: MKMapRect, zoomScale: MKZoomScale, in context: CGContext)
{
//First validate that the Rect you are asked to draw in actually
has some content. See last quote above.
let theMapRect: MKMapRect = self.overlay.boundingMapRect;
guard (MKMapRectIntersectsRect(mapRect, theMapRect)) || self.path != nil else {
return
}
//Do some logic if needed.
//Create and draw your path
let path = CGMutablePath()
path.moveTo(nil, x: self.path.currentPoint.x, y: self.path.currentPoint.y)
path.addLines(nil, between: remainingPoints, count: remainingPoints.count)
context.addPath(path)
//Customise it
context.setStrokeColor(strokeColor!.cgColor)
context.setLineWidth((lineWidth + CGFloat(0.0)) / zoomScale)
//And apply it
context.strokePath()
}
By doing this I was able to successfully draw the path I wanted for each overlay without any troubles.

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

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.
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.
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.
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.
These translation answers usually go Objective-c ==> Swift, but in case there are any more stubborn Objective-c authors left, here's #Rob's answer translated...
// see https://stackoverflow.com/a/35714554/294949
#import "RoundView.h"
#interface Action : NSObject<CAAction>
#property(strong,nonatomic) CABasicAnimation *pendingAnimation;
#property(assign,nonatomic) CGFloat priorCornerRadius;
#end
#implementation Action
- (void)runActionForKey:(NSString *)event object:(id)anObject
arguments:(nullable NSDictionary *)dict {
if ([anObject isKindOfClass:[CALayer self]]) {
CALayer *layer = (CALayer *)anObject;
if (self.pendingAnimation.isAdditive) {
self.pendingAnimation.fromValue = #(self.priorCornerRadius - layer.cornerRadius);
self.pendingAnimation.toValue = #(0);
} else {
self.pendingAnimation.fromValue = #(self.priorCornerRadius);
self.pendingAnimation.toValue = #(layer.cornerRadius);
}
[layer addAnimation:self.pendingAnimation forKey:#"cornerRadius"];
}
}
#end
#interface RoundView ()
#property(weak,nonatomic) UIImageView *imageView;
#end
#implementation RoundView
- (void)layoutSubviews {
[super layoutSubviews];
[self updateCornerRadius];
}
- (void)updateCornerRadius {
self.layer.cornerRadius = MIN(self.bounds.size.width, self.bounds.size.height)/2.0;
}
- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event {
if ([event isEqualToString:#"cornerRadius"]) {
CABasicAnimation *boundsAnimation = (CABasicAnimation *)[self.layer animationForKey:#"bounds.size"];
CABasicAnimation *animation = [boundsAnimation copy];
animation.keyPath = #"cornerRadius";
Action *action = [[Action alloc] init];
action.pendingAnimation = animation;
action.priorCornerRadius = layer.cornerRadius;
return action;
}
return [super actionForLayer:layer forKey:event];;
}
#end
I would suggest not using a corner radius, but instead using a CAShapeLayer as a mask for your view's content layer.
You'd install a filled 360° arc CGPath as the shape of the shape layer and set it as the mask of your layer's view.
You could then either animate a new scale transform for the mask layer, or animate a change to the radius of the path. Both methods should stay round, although the scale transform might not give you a clean shape at smaller pixel sizes.
The timing would be the tricky part (getting the animation of the mask layer to happen in lockstep with the bounds animation.)

Resources