Is there an easy way to be called back when a Core Animation reaches certain points as it's running (for example, at 50% and 66% of completion ?
I'm currently thinking about setting up an NSTimer, but that's not really as accurate as I'd like.
I've finally developed a solution for this problem.
Essentially I wish to be called back for every frame and do what I need to do.
There's no obvious way to observe the progress of an animation, however it is actually possible:
Firstly we need to create a new subclass of CALayer that has an animatable property called 'progress'.
We add the layer into our tree, and then create an animation that will drive the progress value from 0 to 1 over the duration of the animation.
Since our progress property can be animated, drawInContext is called on our sublass for every frame of an animation. This function doesn't need to redraw anything, however it can be used to call a delegate function :)
Here's the class interface:
#protocol TAProgressLayerProtocol <NSObject>
- (void)progressUpdatedTo:(CGFloat)progress;
#end
#interface TAProgressLayer : CALayer
#property CGFloat progress;
#property (weak) id<TAProgressLayerProtocol> delegate;
#end
And the implementation:
#implementation TAProgressLayer
// We must copy across our custom properties since Core Animation makes a copy
// of the layer that it's animating.
- (id)initWithLayer:(id)layer
{
self = [super initWithLayer:layer];
if (self) {
TAProgressLayer *otherLayer = (TAProgressLayer *)layer;
self.progress = otherLayer.progress;
self.delegate = otherLayer.delegate;
}
return self;
}
// Override needsDisplayForKey so that we can define progress as being animatable.
+ (BOOL)needsDisplayForKey:(NSString*)key {
if ([key isEqualToString:#"progress"]) {
return YES;
} else {
return [super needsDisplayForKey:key];
}
}
// Call our callback
- (void)drawInContext:(CGContextRef)ctx
{
if (self.delegate)
{
[self.delegate progressUpdatedTo:self.progress];
}
}
#end
We can then add the layer to our main layer:
TAProgressLayer *progressLayer = [TAProgressLayer layer];
progressLayer.frame = CGRectMake(0, -1, 1, 1);
progressLayer.delegate = self;
[_sceneView.layer addSublayer:progressLayer];
And animate it along with the other animations:
CABasicAnimation *anim = [CABasicAnimation animationWithKeyPath:#"progress"];
anim.duration = 4.0;
anim.beginTime = 0;
anim.fromValue = #0;
anim.toValue = #1;
anim.fillMode = kCAFillModeForwards;
anim.removedOnCompletion = NO;
[progressLayer addAnimation:anim forKey:#"progress"];
Finally, the delegate will be called back as the animation progresses:
- (void)progressUpdatedTo:(CGFloat)progress
{
// Do whatever you need to do...
}
If you don't want to hack a CALayer to report progress to you, there's another approach. Conceptually, you can use a CADisplayLink to guarantee a callback on each frame, and then simply measure the time that has passed since the start of the animation divided by the duration to figure out the percent complete.
The open source library INTUAnimationEngine packages this functionality up very cleanly into an API that looks almost exactly like the UIView block-based animation one:
// INTUAnimationEngine.h
// ...
+ (NSInteger)animateWithDuration:(NSTimeInterval)duration
delay:(NSTimeInterval)delay
animations:(void (^)(CGFloat percentage))animations
completion:(void (^)(BOOL finished))completion;
// ...
All you need to do is call this method at the same time you start other animation(s), passing the same values for duration and delay, and then for each frame of the animation the animations block will be executed with the current percent complete. And if you want peace of mind that your timings are perfectly synchronized, you can drive your animations exclusively from INTUAnimationEngine.
I made a Swift (2.0) implementation of the CALayer subclass suggested by tarmes in the accepted answer:
protocol TAProgressLayerProtocol {
func progressUpdated(progress: CGFloat)
}
class TAProgressLayer : CALayer {
// MARK: - Progress-related properties
var progress: CGFloat = 0.0
var progressDelegate: TAProgressLayerProtocol? = nil
// MARK: - Initialization & Encoding
// We must copy across our custom properties since Core Animation makes a copy
// of the layer that it's animating.
override init(layer: AnyObject) {
super.init(layer: layer)
if let other = layer as? TAProgressLayerProtocol {
self.progress = other.progress
self.progressDelegate = other.progressDelegate
}
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
progressDelegate = aDecoder.decodeObjectForKey("progressDelegate") as? CALayerProgressProtocol
progress = CGFloat(aDecoder.decodeFloatForKey("progress"))
}
override func encodeWithCoder(aCoder: NSCoder) {
super.encodeWithCoder(aCoder)
aCoder.encodeFloat(Float(progress), forKey: "progress")
aCoder.encodeObject(progressDelegate as! AnyObject?, forKey: "progressDelegate")
}
init(progressDelegate: TAProgressLayerProtocol?) {
super.init()
self.progressDelegate = progressDelegate
}
// MARK: - Progress Reporting
// Override needsDisplayForKey so that we can define progress as being animatable.
class override func needsDisplayForKey(key: String) -> Bool {
if (key == "progress") {
return true
} else {
return super.needsDisplayForKey(key)
}
}
// Call our callback
override func drawInContext(ctx: CGContext) {
if let del = self.progressDelegate {
del.progressUpdated(progress)
}
}
}
Ported to Swift 4.2:
protocol CAProgressLayerDelegate: CALayerDelegate {
func progressDidChange(to progress: CGFloat)
}
extension CAProgressLayerDelegate {
func progressDidChange(to progress: CGFloat) {}
}
class CAProgressLayer: CALayer {
private struct Const {
static let animationKey: String = "progress"
}
#NSManaged private(set) var progress: CGFloat
private var previousProgress: CGFloat?
private var progressDelegate: CAProgressLayerDelegate? { return self.delegate as? CAProgressLayerDelegate }
override init() {
super.init()
}
init(frame: CGRect) {
super.init()
self.frame = frame
}
override init(layer: Any) {
super.init(layer: layer)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.progress = CGFloat(aDecoder.decodeFloat(forKey: Const.animationKey))
}
override func encode(with aCoder: NSCoder) {
super.encode(with: aCoder)
aCoder.encode(Float(self.progress), forKey: Const.animationKey)
}
override class func needsDisplay(forKey key: String) -> Bool {
if key == Const.animationKey { return true }
return super.needsDisplay(forKey: key)
}
override func display() {
super.display()
guard let layer: CAProgressLayer = self.presentation() else { return }
self.progress = layer.progress
if self.progress != self.previousProgress {
self.progressDelegate?.progressDidChange(to: self.progress)
}
self.previousProgress = self.progress
}
}
Usage:
class ProgressView: UIView {
override class var layerClass: AnyClass {
return CAProgressLayer.self
}
}
class ExampleViewController: UIViewController, CAProgressLayerDelegate {
override func viewDidLoad() {
super.viewDidLoad()
let progressView = ProgressView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
progressView.layer.delegate = self
view.addSubview(progressView)
var animations = [CAAnimation]()
let opacityAnimation = CABasicAnimation(keyPath: "opacity")
opacityAnimation.fromValue = 0
opacityAnimation.toValue = 1
opacityAnimation.duration = 1
animations.append(opacityAnimation)
let progressAnimation = CABasicAnimation(keyPath: "progress")
progressAnimation.fromValue = 0
progressAnimation.toValue = 1
progressAnimation.duration = 1
animations.append(progressAnimation)
let group = CAAnimationGroup()
group.duration = 1
group.beginTime = CACurrentMediaTime()
group.animations = animations
progressView.layer.add(group, forKey: nil)
}
func progressDidChange(to progress: CGFloat) {
print(progress)
}
}
Related
I have a UI component (a loading spinner) that has an endless animation:
class MySpinner: UIView {
...
override var bounds: CGRect {
didSet {
updateLayers()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
self.layer.addSublayer(self.animatedLayer)
self.updateLayers()
self.startAnimating()
}
private func updateLayers() {
//Set up path, stroke color and such
self.animatedLayer.frame = self.bounds
}
private func startAnimating() {
CATransaction.begin()
CATransaction.setDisableActions(true) //disable automatic animations by iOS
let endlessRotationAnimation = CAKeyframeAnimation(keyPath: "transform.rotation")
endlessRotationAnimation.values = [ 0.0, CGFloat(2.0 * .pi) ]
endlessRotationAnimation.keyTimes = [ 0.0, 1.0 ]
endlessRotationAnimation.repeatDuration = .infinity
endlessRotationAnimation.duration = 1.0
self.animatedLayer.add(endlessRotationAnimation, forKey: "rotationAnimation")
CATransaction.commit()
}
}
It turns out this is not working. I assume that if I call startAnimating() before the view is added to the window, iOS will immediately remove the animation. If I first add the view to the view hierarchy and then call startAnimating(), things start working.
Is there a way to prevent iOS removing my animation? Or is there some documentation about this behaviour, I couldn't find any?
I was able to work around this issue by overriding didMoveToWindow.
public override func didMoveToWindow() {
super.didMoveToWindow()
startAnimating()
}
I have Swift class (derived of UIView), which I call in Objective C++ code.
Initially, it worked well and looked like this:
#objc
public class HorizonView: UIView {
var dashGap: (CGFloat, CGFloat)? = nil
let titleLayer = HorizonTextLayer()
let angleLayer = HorizonTextLayer()
let dashLayer = CAShapeLayer()
override public init(frame: CGRect) {
super.init(frame: frame)
setUpLayers()
}
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setUpLayers()
}
public override var frame: CGRect {
didSet {
setUpLayers()
}
}
// other stuff here ...
}
Objective C++ calls to alloc view:
_horizonView = [[HorizonView alloc] initWithFrame:CGRectMake(10, 10, 200, 30)];
[bigView addSubview:_horizonView];
Objective C++ calls to delete view:
if ( _horizonView ) { [_horizonView removeFromSuperview]; [_horizonView release]; _horizonView = nil; }
I decided to encapsulate HorizonView by exposing only one public method. I don't know if is possible to declare a protocol, which is subclass of UIView (protocol isn't a class, is it?), so I came to class:
#objc
public class HorizonView: UIView {
static func create(p1: CGPoint, andP2 p2: CGPoint) -> HorizonView {
let implFrame = CGRect(x: p1.x, y: p1.y-10, width: p2.x-p1.x, height: 20)
let impl = HorizonViewImpl(frame: implFrame)
return impl
}
}
private class HorizonViewImpl: HorizonView {
var dashGap: (CGFloat, CGFloat)? = nil
let titleLayer = HorizonTextLayer()
let angleLayer = HorizonTextLayer()
// old class code ...
}
Now Objective C++ alloc call looks so:
if ( _horizonView ) { [_horizonView removeFromSuperview]; [_horizonView release]; _horizonView = nil; }
_horizonView = [HorizonView createWithP1:CGPointMake(10, 10)
andP2:CGPointMake(210,40)];
[EGLV addSubview:_horizonView];
And I constantly receive a crash, when profiling, seeing this:
Could you give some hints to detect the problem?
Can such inheritance approach have some flaws?
I have a custom UIView and I add it to my ViewController like this:
let myCustomView = Bundle.main.loadNibNamed("MyCustomView", owner: nil, options: nil) as! MyCustomView
myCustomView.layer.cornerRadius = 10
myCustomView.layer.masksToBounds = true
I round the corners of the view. But I am wondering, is there a way to move this logic of rounding the corners inside the MyCustomView class?
As you use IB, you may find it more convenient to make an extension of UIView
extension UIView {
#IBInspectable var borderColor: UIColor? {
set {
layer.borderColor = newValue?.cgColor
}
get {
if let color = layer.borderColor {
return UIColor(cgColor:color)
} else {
return nil
}
}
}
#IBInspectable var borderWidth: CGFloat {
set {
layer.borderWidth = newValue
}
get {
return layer.borderWidth
}
}
#IBInspectable var cornerRadius: CGFloat {
set {
layer.cornerRadius = newValue
clipsToBounds = newValue > 0
}
get {
return layer.cornerRadius
}
}
}
Then you can set those values from Attributes inspector.
Yes - If you're loading a nib with a custom view, that nib is very likely referring to another class. If that's the case, you can move the logic inside the class itself.
That said, I really like Lawliet's suggestion of making a UIView extension with IBInspectable properties. The downside to that approach is that every single view now has these properties, which creates a certain overhead and potential for clashes.
You can do something like this in your UIView subclass:
class RoundedView: UIView {
/*
// Only override draw() if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
override func draw(_ rect: CGRect) {
// Drawing code
}
*/
override init(frame: CGRect) {
super.init(frame: frame)
self.layer.cornerRadius = 10
self.layer.masksToBounds = true
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.layer.cornerRadius = 10
self.layer.masksToBounds = true
}
}
Also if you want to pass a custom value instead of '10' in the cornerRadius property, you can try to implement a convenience init by looking here:
Override Init method of UIView in Swift
I made a custom circular progress view by subclassing UIView, adding a CAShapeLayer sublayer to its layer and overriding drawRect() to update the shape layer's path property.
By making the view #IBDesignable and the progress property #IBInspectable, I was able to edit its value in Interface Builder and see the updated bezier path in real time. Non-essential, but really cool!
Next, I decided to make the path animated: Whenever you set a new value in code, the arc indicating the progress should "grow" from zero length to whatever percentage of the circle is achieved (think of arcs in the Activity app in apple Watch).
To achieve this, I swapped my CAShapeLayer sublayer by a custom CALayer subclass that has a #dynamic (#NSManaged) property, observed as key for ananimation (I implemented needsDisplayForKey(), actionForKey(), drawInContext() etc. ).
My View code (the relevant parts) looks something like this:
// Triggers path update (animated)
private var progress: CGFloat = 0.0 {
didSet {
updateArcLayer()
}
}
// Programmatic interface:
// (pass false to achieve immediate change)
func setValue(newValue: CGFloat, animated: Bool) {
if animated {
self.progress = newValue
} else {
arcLayer.animates = false
arcLayer.removeAllAnimations()
self.progress = newValue
arcLayer.animates = true
}
}
// Exposed to Interface Builder's inspector:
#IBInspectable var currentValue: CGFloat {
set(newValue) {
setValue(newValue: currentValue, animated: false)
self.setNeedsLayout()
}
get {
return progress
}
}
private func updateArcLayer() {
arcLayer.frame = self.layer.bounds
arcLayer.progress = progress
}
And the layer code:
var animates: Bool = true
#NSManaged var progress: CGFloat
override class func needsDisplay(forKey key: String) -> Bool {
if key == "progress" {
return true
}
return super.needsDisplay(forKey: key)
}
override func action(forKey event: String) -> CAAction? {
if event == "progress" && animates == true {
return makeAnimation(forKey: event)
}
return super.action(forKey: event)
}
override func draw(in ctx: CGContext) {
ctx.beginPath()
// Define the arcs...
ctx.closePath()
ctx.setFillColor(fillColor.cgColor)
ctx.drawPath(using: CGPathDrawingMode.fill)
}
private func makeAnimation(forKey event: String) -> CABasicAnimation? {
let animation = CABasicAnimation(keyPath: event)
if let presentationLayer = self.presentation() {
animation.fromValue = presentationLayer.value(forKey: event)
}
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
animation.duration = animationDuration
return animation
}
The animation works, but now I can't get my paths to show in Interface Builder.
I have tried implementing my view's prepareForInterfaceBuilder() like this:
override func prepareForInterfaceBuilder() {
super.prepareForInterfaceBuilder()
self.topLabel.text = "Hello, Interface Builder!"
updateArcLayer()
}
...and the label text change is reflected in Interface Builder, but the path isn't rendered.
Am I missing something?
Well, isn't it funny... It turns out the declaration of my #Inspectable property had a very silly bug.
Can you spot it?
#IBInspectable var currentValue: CGFloat {
set(newValue) {
setValue(newValue: currentValue, animated: false)
self.setNeedsLayout()
}
get {
return progress
}
}
It should be:
#IBInspectable var currentValue: CGFloat {
set(newValue) {
setValue(newValue: newValue, animated: false)
self.setNeedsLayout()
}
get {
return progress
}
}
That is, I was discarding the passed value (newValue) and using the current one (currentValue) to set the internal variable instead. "Logging" the value (always 0.0) to Interface Builder (via my label's text property!) gave me a clue.
Now it is working fine, no need for drawRect() etc.
Currently, I have subclassed CALayer to create a animatable bottom border for my UITextField. However, my animateColor function is not working. I've tested to see if the function is being called and it is. However, no animation is taking place. What am I doing wrong?
class KTextFieldBottomBorder:CALayer {
override init!() {
super.init()
commonInit()
}
override init!(layer: AnyObject!) {
super.init(layer: layer)
commonInit()
}
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func commonInit() {
// Appearance
self.backgroundColor = UIColor.lightGrayColor().CGColor
}
// MARK: Animations
func animateColor (endingColor:UIColor, duration:CFTimeInterval) {
var colorAnimation = CABasicAnimation(keyPath: "color")
colorAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
colorAnimation.toValue = endingColor.CGColor
colorAnimation.duration = duration
colorAnimation.fillMode = kCAFillModeForwards
super.addAnimation(colorAnimation, forKey: "color")
println("X")
}
}
You need to define the property that shall be animated by setting the keyPath.
colorAnimation.keyPath = "backgroundColor"
The key argument of addAnimation is merely used as an identifier for the animation.