Overview

Custom Property UIView Block Animation

No Comments


Wouldn’t it be awesome if we could animate any property with a UIView animation block?

[UIView animateWithDuration:0.5
                 animations:^{
                     self.clock.hourHand = 9.0;
                     self.clock.minutehand = 41.0;
                 }];
UIView.animateWithDuration(0.5) {
    self.animView?.hourHand = 9.0
    self.animView?.minuteHand = 41.0
}

Synchronized animation with other properties, delays, completion callbacks and easing, among other things make this an attractive technique. Unfortunately it is not possible out of the box, but luckily for us there is a way we can make it work.

Let’s see how this looks in practice. We will implement a simple loading indicator that shows the currently loaded percent numerically and a progress bar. The indicator will have a percent property which will be animatable with UIView animation blocks. You can check out the complete project on GitHub.

Loading Indicator
UIView Animation Ease Out

To get started we need to create a new layer class.

@interface OCLayer : CALayer
@property (nonatomic) CGFloat percent;
@end
 
@implementation OCLayer
@dynamic percent;
@end
class SWLayer: CALayer {
    @NSManaged var percent: CGFloat
}

It is important to note that in Objective-C the property should be declared as @dynamic and in Swift as @NSManaged, so that the layer generates the appropriate getter and setter which will interpolate the value. This will only work for certain types, for example NSDate won’t be interpolated but NSTimeInterval will.

For the presentation layer to be instantiated properly we need to override the initWithLayer: initializer and set our custom property. Without this step the custom property will not be updated in some cases, for example when there is no animation. In the case of Swift we also need to override some additional initializers to stop the compiler from complaining.

- (id)initWithLayer:(id)layer
{
    self = [super initWithLayer:layer];
    if (self)
    {
        if ([layer isKindOfClass:[OCLayer class]])
        {
            self.percent = ((OCLayer *)layer).percent;
        }
    }
    return self;
}
override init() {
    super.init()
}
 
override init(layer: AnyObject) {
    super.init(layer: layer)
    if let layer = layer as? SWLayer {
        percent = layer.percent
    }
}
 
required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)
}

Next we need to notify the system that the layer should be redisplayed after changes to our custom property.

+ (BOOL)needsDisplayForKey:(NSString *)key
{
    if ([self isCustomAnimKey:key]) return true;
    return [super needsDisplayForKey:key];
}
 
+ (BOOL)isCustomAnimKey:(NSString *)key
{
    return [key isEqualToString:@"percent"];
}
override class func needsDisplayForKey(key: String) -> Bool {
    if self.isCustomAnimKey(key) {
        return true
    }
    return super.needsDisplayForKey(key)
}
 
private class func isCustomAnimKey(key: String) -> Bool {
    return key == "percent"
}

We also need to provide an action for our custom property which will animate it.

- (id<CAAction>)actionForKey:(NSString *)key
{
    if ([[self class] isCustomAnimKey:key])
    {
        id animation = [super actionForKey:@"backgroundColor"];
        if (animation == nil || [animation isEqual:[NSNull null]])
        {
            [self setNeedsDisplay];
            return [NSNull null];
        }
        [animation setKeyPath:key];
        [animation setFromValue:@([self.presentationLayer percent])];
        [animation setToValue:nil];
        return animation;
    }
    return [super actionForKey:key];
}
override func actionForKey(event: String) -> CAAction? {
    if SWLayer.isCustomAnimKey(event) {
        if let animation = super.actionForKey("backgroundColor") as? CABasicAnimation {
            animation.keyPath = event
            if let pLayer = presentationLayer() {
                animation.fromValue = pLayer.percent
            }
            animation.toValue = nil
            return animation
        }
        setNeedsDisplay()
        return nil
    }
    return super.actionForKey(event)
}

After checking that the key is for our custom property, we check if we are currently in an animation by checking if other properties return actions, in this case backgroundColor is a good candidate. If no action is returned then we are not in an animation but still need to call setNeedsDisplay in case we have stopped a running animation. In case an action is returned we repurpose it so we have a consistent duration and timing with other properties that are being animated in the current block.

This wraps up our custom layer class. Next we need to create a custom view class and return our layer as the backing layer.

@interface OCView : UIView
@property (nonatomic) CGFloat percent;
@end
 
@implementation OCView
 
+ (Class)layerClass
{
    return [OCLayer class];
}
 
- (void)setPercent:(CGFloat)percent
{
    ((OCLayer *)self.layer).percent = percent;
}
 
- (CGFloat)percent
{
    return ((OCLayer *)self.layer).percent;
}
 
@end
class SWView: UIView {
    var percent: CGFloat {
        set {
            if let layer = layer as? SWLayer {
                layer.percent = newValue
            }
        }
        get {
            if let layer = layer as? SWLayer {
                return layer.percent
            }
            return 0.0
        }
    }
 
    override class func layerClass() -> AnyClass {
        return SWLayer.self
    }
}

Notice that we are mirroring the custom property declared on our layer, without making it @dynamic or @NSManaged. Instead of synthesizing the property, we create a getter and a setter that connect it directly to the percent property we declared earlier on our layer.

At this point the system is correctly interpolating the values of our custom property, but we aren’t doing anything with it. We can update our UI in multiple places displayLayer:, drawLayer:inContext: or drawRect:, but only one of these methods can be implemented at a time since the others won’t be called. Also make sure that you are using the presentation layer value of the property.

Objective-C Swift

- (void)displayLayer:(CALayer *)layer
{
    CGFloat percent = [[self.layer presentationLayer] percent];
    CGFloat width = CGRectGetWidth(self.frame) * (percent / 100);
    self.percentView.frame = CGRectMake(0, 0, width, CGRectGetHeight(self.frame));
    self.label.text = [NSString stringWithFormat:@"%.0f", floorf(percent)];
}
override func displayLayer(layer: CALayer) {
    if let pLayer = layer.presentationLayer() as? SWLayer {
        let width = CGRectGetWidth(frame) * (pLayer.percent / 100)
        percentView.frame = CGRectMake(0, 0, width, CGRectGetHeight(frame))
        label.text = String.init(format: "%.0f", floor(pLayer.percent))
    }
}

This completes our view class. Now we are able to animate our custom property together with other properties with all the flexibility and options of standard UIView block animations.

Objective-C Swift

self.animView.percent = 0.0;
[UIView animateWithDuration:kAnimDuration
                 animations:^{
                     self.animView.percent = 100.0;
                 }];
self.animView?.percent = 0
UIView.animateWithDuration(SWViewController.kAnimDuration) {
    self.animView?.percent = 100.0
}

Ease out animation
UIView Animation Ease Out

Ease out and reverse animation
UIView Animation Ease Out & Reverse

Spring animation
UIView Spring Animation

At the end I have to mention that there is a slight caveat to this approach, which I was not able to solve. If you do, please let me know in the comments or @nlajic or submit a pull request on GitHub. The issue is that custom properties will not be updated in the same frame as the default ones. This means that there could be some stuttering or misalignment during animations of multiple objects, where the position of one is updated directly by the animation and the other by a custom property.

Animation stuttering
UIView Animation Stuttering

Don’t forget to check out the sample project on GitHub.

Useful links

Animating Custom Layer Properties
View Layer Synergy
Custom Animatable Property

Comment

Your email address will not be published. Required fields are marked *