Core animation progress callback

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)
    }
}

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)
        }
    }

}