Smoothly speed up rotation in SwiftUI - ios

I would like to smoothly accelerate the rotation speed of a shape in a SwiftUI application then slow it back down again to fixed speed. First I tried toggling the animation speed using a #State Bool as I would any other property (e.g. .speed(speedUp ? 5.0 : 1.0)), but I suppose animation properties themselves are not themselves animatable. I've also tried using an AnimatableModifier to no effect:
import SwiftUI
struct SpeedModifier: AnimatableModifier {
var speed: Double
var animatableData: Double {
get { speed }
set { speed = newValue }
}
func body(content: Content) -> some View {
return content.animation(
Animation
.linear(duration: 5.0)
.speed(speed)
.repeatForever(autoreverses: false)
)
}
}
struct SwiftUIView: View {
#State var isRotating = false
#State var speedUp = false
var body: some View {
Rectangle()
.frame(width: 200, height: 200)
.rotationEffect(.degrees(isRotating ? 360 : 0))
.modifier(SpeedModifier(speed: speedUp ? 5.0 : 1.0))
.onAppear {
self.isRotating.toggle()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.speedUp.toggle()
}
}
}
}
struct SwiftUIView_Previews: PreviewProvider {
static var previews: some View {
SwiftUIView()
}
}

You may try .timingCurve animation. In this example rectangle rotated slower and faster all the time:
struct RotatingView: View {
#State private var rotationDegree = 0.0
private var timeCurveAnimation: Animation {
return Animation.timingCurve(0.5, 0.8, 0.8, 0.3, duration: 6)
.repeatForever(autoreverses: false)
}
var body: some View {
Rectangle()
.frame(width: 200, height: 200)
.rotationEffect(.degrees(rotationDegree))
.onAppear() {
withAnimation(self.timeCurveAnimation) {
self.rotationDegree = 720.0
}
}
}
}
Unfortunately there are almost no documentation what does (c0x:c0y:c1x: c1y:) parameters mean, tried some samples from this github.
More complex animations are described in this article, it should be useful

So far as I've been able to determine, being able to dynamically change a running animation is, as of this writing (iOS 13, OSX 10.15), unfortunately a job for Core Animation, where it's reasonably simple.
For example, using Core Animation, we can add the following animation to a layer, causing it to rotate once every 0.5 seconds, indefinitely.
private let animation: CABasicAnimation = {
let animation = CABasicAnimation(keyPath: "transform.rotation")
animation.duration = 0.5
animation.fromValue = 0.0
animation.toValue = 2.0 * -Float.pi
animation.repeatCount = .infinity
return animation
}()
Once you've got that going, then the following extension allows for smooth changing of the layer's speed, and stopping the layer in-situ.
import QuartzCore
extension CALayer {
// Set up our view of the world such that time began from here,
// so that we don't feel the need to change anything when our
// properties are mutated. Handy for things like changing the
// speed without first snapping back to the model state.
func syncTimeToCurrent() {
timeOffset = convertTime(CACurrentMediaTime(), from: nil)
beginTime = CACurrentMediaTime()
}
// Attempt to sync up the model transform with the presentation
// transform. Handy for preventing the presentation from snapping
// back to the model after removing a transform animation.
func syncTransformToPresentation() {
if let presentationTransform = presentation()?.transform {
transform = presentationTransform
}
}
}
And you'd use it like so. Details of how the view is instantiated omitted; in my case, the view is a layer hosting view with 3 image sublayers, two of which are static, and one of which rotates.
final class Rotating: NSView {
private let animation: CABasicAnimation = {
let animation = CABasicAnimation(keyPath: "transform.rotation")
animation.duration = 0.5
animation.fromValue = 0.0
animation.toValue = 2.0 * -Float.pi
animation.repeatCount = .infinity
return animation
}()
private let rotatingLayer: CALayer = { return CALayer() }()
// Our speed is that of our rotating layer; return it to callers when
// requested, and allow them to set it.
var speed: Float {
get { return rotatingLayer.speed }
set {
// Starting rotation from a dead stop is just adding
// the animation to the layer.
func run() {
rotatingLayer.add(animation, forKey: nil)
}
// Key to setting the speed, if we are already rotating,
// is to ensure we don't jump to the start position when
// we do that, so set up the layer's view of time such
// that it'll feel there's nothing to do in that regard.
func set() {
rotatingLayer.syncTimeToCurrent()
}
// Stopping rotation is just removing the transform
// animation, but we ideally want to halt it where it is
// at the moment, rather than having it snap back to the
// original position.
func off() {
rotatingLayer.syncTransformToPresentation()
rotatingLayer.removeAllAnimations()
}
// If we're being asked to set a zero speed, then it's
// likely that the caller knows things that we don't,
// such as that we're about to disappear. Stop rotation,
// so things are in a well-defined state.
//
// If the new speed isn't zero, but our current speed is
// zero, then we need to run.
//
// Otherwise, we need to set the already-running rotation
// to the new speed.
if newValue == .zero { off() }
else if speed == .zero { run() }
else { set() }
rotatingLayer.speed = newValue
}
}
}
So, reasonably simple there, really; all the tools are there to make things dynamically modifiable in a straightforward manner, so you could just do the same and then import the view into SwiftUI, bind it, etc.
I'd love to have someone explain how to accomplish the same thing in pure SwiftUI, without having to drop into Core Animation. At the moment, that's how I do it.

Related

SwiftUI perform action after Spring() animation has completed

I'm trying to figure out how to detect that an animation has completed in SwiftUI, to be specific: a Spring() animation. My first thought was to use a GeometryReader to detect when the Circle in the example below reaches the point of origin (offset = .zero), however there is one caveat to this approach: the Spring() animation goes a little bit beyond the point where it should end and then bounces back. So the "end of the animation" would be triggered before the animation has finished.
I did some research and found another approach : SwiftUI withAnimation completion callback. However, in this solution the offset of the animated object is compared to the point of origin so it's the same problem as described above.
I could use a timer but that wouldn't be an elegant solution since the duration of the Spring() animation dynamically changes depending from where it started, so that's not the way.
In the example below, I would like that the circle gets green after the animation has finished.
Is there a way to solve this issue? Thanks for helping!
struct ContentView: View {
#State var offset: CGSize = .zero
#State var animationRunning = false
var body: some View {
VStack {
Circle()
.foregroundColor(self.animationRunning ? .red : .green)
.frame(width: 200, height: 200)
.offset(self.offset)
.gesture(
DragGesture()
.onChanged{ gesture in
self.offset = gesture.translation
}
.onEnded{_ in
self.animationRunning = true
withAnimation(.spring()){
self.offset = .zero
}
})
Spacer()
}
}
}
Default animation duration (for those animations which do not have explicit duration parameter) is usually 0.25-0.35 (independently of where it is started & platform), so in your case it is completely safe (tested with Xcode 11.4 / iOS 13.4) to use the following approach:
withAnimation(.spring()){
self.offset = .zero
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.animationRunning = false
}
}
Note: you can tune that 0.5 delay, but the difference is not remarkable for human eye.

What does animatableData in SwiftUI do?

I was playing around with the animatableData property for a custom Shape I made but I couldn't really visualise what it does and where the system uses it.
I didn't understand how the system knows which animatableData properties it should interpolate when there's a state change. I also didn't understand what the get part of the animatableData property is used for by the system. The only thing I sort of understand is that SwiftUI will update the animatableData property to all the intermediary values between the original and final value for when an #State variable is changed.
If someone can give a very detailed order of events for the use of animatableData by the system I'll be extremely grateful. Make it as detailed as you can because I'm one of those people who feels scratchy even if I'm not understanding 1% of something (however if I do have any question I'll just ask you in the comments).
Thanks in advance!
P.S. I tried returning a constant in the getter for animatableData and my animation still worked perfectly which has confused me even more. Please let me know what the getter is used for if you can.
The simplest answer to your question is to override the default animatableData [inherited by the Animatable protocol] with values used to draw your View. Here's an example of how to do that:
var animatableData: Double {
get { return percent }
set { percent = newValue }
}
Here's an example for you. It:
Draws a Ring on the parent View.
As the value of percent [which you hook up when you define
animatableData] changes, the animation updates the view by drawing a line along the circumference of the defined circle using the percent value at the time of the update.
import SwiftUI
/// This repeats an animation until 5 seconds elapse
struct SimpleAnswer: View {
/// the start/stop sentinel
static var shouldAnimate = true
/// the percentage of the circumference (arc) to draw
#State var percent = 0.0
/// animation duration/delay values
var animationDuration: Double { return 1.0 }
var animationDelay: Double { return 0.2 }
var exitAnimationDuration: Double { return 0.3 }
var finalAnimationDuration: Double { return 1.0 }
var minAnimationInterval: Double { return 0.1 }
var body: some View {
ZStack {
AnimatingOverlay(percent: percent)
.stroke(Color.yellow, lineWidth: 8.0)
.rotationEffect(.degrees(-90))
.aspectRatio(1, contentMode: .fit)
.padding(20)
.onAppear() {
self.performAnimations()
}
.frame(width: 150, height: 150,
alignment: .center)
Spacer()
}
.background(Color.blue)
.edgesIgnoringSafeArea(.all)
}
func performAnimations() {
run()
if SimpleAnswer.shouldAnimate {
restartAnimation()
}
/// Stop the Animation after 5 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: { SimpleAnswer.shouldAnimate = false })
}
func run() {
withAnimation(.easeIn(duration: animationDuration)) {
percent = 1
}
let deadline: DispatchTime = .now() + animationDuration + animationDelay
DispatchQueue.main.asyncAfter(deadline: deadline) {
withAnimation(.easeOut(duration: self.exitAnimationDuration)) {
}
withAnimation(.easeOut(duration: self.minAnimationInterval)) {
}
}
}
func restartAnimation() {
let deadline: DispatchTime = .now() + 2 * animationDuration + finalAnimationDuration
DispatchQueue.main.asyncAfter(deadline: deadline) {
self.percent = 0
self.performAnimations()
}
}
}
/// Draws a Ring on the parent View
/// By default, `Shape` returns the instance of `EmptyAnimatableData` struct as its animatableData.
/// All you have to do is replace this default `EmptyAnimatableData` with a different value.
/// As the value of percent changes, the animation updates the view
struct AnimatingOverlay: Shape {
var percent: Double
func path(in rect: CGRect) -> Path {
let end = percent * 360
var p = Path()
p.addArc(center: CGPoint(x: rect.size.width/2, y: rect.size.width/2),
radius: rect.size.width/2,
startAngle: Angle(degrees: 0),
endAngle: Angle(degrees: end),
clockwise: false)
return p
}
/// This example defines `percent` as the value to animate by
/// overriding the value of `animatableData`
/// inherited as Animatable.animatableData
var animatableData: Double {
get { return percent }
set { percent = newValue }
}
}
#if DEBUG
struct SimpleAnswer_Previews : PreviewProvider {
static var previews: some View {
SimpleAnswer()
}
}
#endif
I found these links to help me answer your question. You should find them useful as well.
Wenderlich - How to Create a Splash Screen With SwiftUI
Majid - The Magic of Animatable Values
Animations in SwiftUI - Majid

Activating an animation in SwiftUI automatically based on saved state

I'm trying to write a view that displays 3 buttons, I cannot get the animation to start on load.
When a button is tapped, I want it to animate until either:
it is tapped a second time
another of the 3 buttons is tapped
I have got the code working using a #Environment object to store the running state. It toggles between the 3 buttons nicely:
The code for this is here:
struct ContentView : View {
#EnvironmentObject var model : ModelClockToggle
var body: some View {
VStack {
ForEach(0...2) { timerButton in
ActivityBreak(myId: timerButton)
.padding()
}
}
}
}
import SwiftUI
struct ActivityBreak : View {
var myId: Int
#EnvironmentObject var model : ModelClockToggle
let anim1 = Animation.basic(duration: 1.0, curve: .easeInOut).repeatCount(Int.max)
let noAni = Animation.basic(duration: 0.2, curve: .easeInOut).repeatCount(0)
var body: some View {
return Circle()
.foregroundColor(.red)
.scaleEffect(self.model.amIRunning(clock: self.myId) ? 1.0 : 0.6)
.animation( self.model.amIRunning(clock: self.myId) ? anim1 : noAni )
.tapAction {
self.model.toggle(clock: self.myId)
}
}
}
For completeness, the model is:
import Foundation
import SwiftUI
import Combine
class ModelClockToggle: BindableObject {
let didChange = PassthroughSubject<ModelClockToggle, Never>()
private var clocksOn: [Bool] = [false,false,false]
init() {
clocksOn = []
clocksOn.append(UserDefaults.standard.bool(forKey: "toggle1"))
clocksOn.append(UserDefaults.standard.bool(forKey: "toggle2"))
clocksOn.append(UserDefaults.standard.bool(forKey: "toggle3"))
debugPrint(clocksOn)
}
func toggle(clock: Int) {
debugPrint(#function)
if clocksOn[clock] {
clocksOn[clock].toggle()
} else {
clocksOn = [false,false,false]
clocksOn[clock].toggle()
}
saveState()
didChange.send(self)
}
func amIRunning(clock: Int) -> Bool {
debugPrint(clocksOn)
return clocksOn[clock]
}
private func saveState() {
UserDefaults.standard.set(clocksOn[0], forKey: "toggle1")
UserDefaults.standard.set(clocksOn[1], forKey: "toggle2")
UserDefaults.standard.set(clocksOn[2], forKey: "toggle3")
}
}
How do I make the repeating animation start at load time based on the #Environment object I have passed into the View? Right now SwiftUI only seems to consider state change once the view is loaded.
I tried adding an .onAppear modifier, but that meant I had to use a different animator - which had very strange effects.
help gratefully received.
In your example, you are using an implicit animation. Those are animations that will look for changes on any animatable parameter such as size, position, opacity, color, etc. When SwiftUI detects any change, it will animate it.
In your specific case, Circles are normally scaled to 0.6 while not active, and 1.0 when active. Changes between inactive and active states, make your Circle to alter the scale, and this changes are animated in a loop.
However, your problem is that a Circle that is initially loaded at a 1.0 scale (because the model says it is active), will not detect a change: It starts at 1.0 and remains at 1.0. So there is nothing to animate.
In your comments you mention a solution, that involves having the model postpone loading the state of the Circle states. That way, your view is created first, then you ask the model to load states and then there is a change in your view that can be animated. That works, however, there is a problem with that.
You are making your model's behaviour dependent on the view. When it should really be the other way around. Suppose you have two instances of your view on the screen. Depending on timing, one will start fine, but the other will not.
The way to solve it, is making sure the entire logic is handle by the view itself. What you want to accomplish, is that your Circle always gets created with a scale of 0.6. Then, you check with the model to see if the Circel should be active. If so, you immediately change it to 1.0. This way you guarantee the view's animation.
Here is a possible solution, that uses a #State variable named booted to keep track of this. Your Circles will always be created with a scale of 0.6, but once the onAppear() method is call, the view will scale to 1.0 (if active), producing the corresponding animation.
struct ActivityBreak : View {
var myId: Int
#EnvironmentObject var model : ModelClockToggle
#State private var booted: Bool = false
// Beta 4
let anim1 = Animation.easeInOut(duration: 1.0).repeatCount(Int.max)
let noAni = Animation.easeInOut(duration: 0.2).repeatCount(0)
// Beta 3
// let anim1 = Animation.basic(duration: 1.0, curve: .easeInOut).repeatCount(Int.max)
// let noAni = Animation.basic(duration: 0.2, curve: .easeInOut).repeatCount(0)
var body: some View {
return Circle()
.foregroundColor(.red)
.scaleEffect(!booted ? 0.6 : self.model.amIRunning(clock: self.myId) ? 1.0 : 0.6)
.animation( self.model.amIRunning(clock: self.myId) ? anim1 : noAni )
.tapAction {
self.model.toggle(clock: self.myId)
}
.onAppear {
self.booted = true
}
}
}

How to animate a custom property in 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.

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