Wouldn’t it be awesome if we could animate any property with a UIView animation block?
1[UIView animateWithDuration:0.5 2 animations:^{ 3 self.clock.hourHand = 9.0; 4 self.clock.minutehand = 41.0; 5 }];
1UIView.animateWithDuration(0.5) { 2 self.animView?.hourHand = 9.0 3 self.animView?.minuteHand = 41.0 4}
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 .
To get started we need to create a new layer class.
1@interface OCLayer : CALayer 2@property (nonatomic) CGFloat percent; 3@end 4 5@implementation OCLayer 6@dynamic percent; 7@end
1class SWLayer: CALayer {
2 @NSManaged var percent: CGFloat
3}
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.
1- (id)initWithLayer:(id)layer 2{ 3 self = [super initWithLayer:layer]; 4 if (self) 5 { 6 if ([layer isKindOfClass:[OCLayer class]]) 7 { 8 self.percent = ((OCLayer *)layer).percent; 9 } 10 } 11 return self; 12}
1override init() { 2 super.init() 3} 4 5override init(layer: AnyObject) { 6 super.init(layer: layer) 7 if let layer = layer as? SWLayer { 8 percent = layer.percent 9 } 10} 11 12required init?(coder aDecoder: NSCoder) { 13 super.init(coder: aDecoder) 14}
Next we need to notify the system that the layer should be redisplayed after changes to our custom property.
1+ (BOOL)needsDisplayForKey:(NSString *)key 2{ 3 if ([self isCustomAnimKey:key]) return true; 4 return [super needsDisplayForKey:key]; 5} 6 7+ (BOOL)isCustomAnimKey:(NSString *)key 8{ 9 return [key isEqualToString:@"percent"]; 10}
1override class func needsDisplayForKey(key: String) -> Bool {
2 if self.isCustomAnimKey(key) {
3 return true
4 }
5 return super.needsDisplayForKey(key)
6}
7
8private class func isCustomAnimKey(key: String) -> Bool {
9 return key == "percent"
10}
We also need to provide an action for our custom property which will animate it.
1- (id<CAAction>)actionForKey:(NSString *)key 2{ 3 if ([[self class] isCustomAnimKey:key]) 4 { 5 id animation = [super actionForKey:@"backgroundColor"]; 6 if (animation == nil || [animation isEqual:[NSNull null]]) 7 { 8 [self setNeedsDisplay]; 9 return [NSNull null]; 10 } 11 [animation setKeyPath:key]; 12 [animation setFromValue:@([self.presentationLayer percent])]; 13 [animation setToValue:nil]; 14 return animation; 15 } 16 return [super actionForKey:key]; 17}
1override func actionForKey(event: String) -> CAAction? { 2 if SWLayer.isCustomAnimKey(event) { 3 if let animation = super.actionForKey("backgroundColor") as? CABasicAnimation { 4 animation.keyPath = event 5 if let pLayer = presentationLayer() { 6 animation.fromValue = pLayer.percent 7 } 8 animation.toValue = nil 9 return animation 10 } 11 setNeedsDisplay() 12 return nil 13 } 14 return super.actionForKey(event) 15}
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.
1@interface OCView : UIView
2@property (nonatomic) CGFloat percent;
3@end
4
5@implementation OCView
6
7+ (Class)layerClass
8{
9 return [OCLayer class];
10}
11
12- (void)setPercent:(CGFloat)percent
13{
14 ((OCLayer *)self.layer).percent = percent;
15}
16
17- (CGFloat)percent
18{
19 return ((OCLayer *)self.layer).percent;
20}
21
22@end
1class SWView: UIView {
2 var percent: CGFloat {
3 set {
4 if let layer = layer as? SWLayer {
5 layer.percent = newValue
6 }
7 }
8 get {
9 if let layer = layer as? SWLayer {
10 return layer.percent
11 }
12 return 0.0
13 }
14 }
15
16 override class func layerClass() -> AnyClass {
17 return SWLayer.self
18 }
19}
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
1- (void)displayLayer:(CALayer *)layer 2{ 3 CGFloat percent = [[self.layer presentationLayer] percent]; 4 CGFloat width = CGRectGetWidth(self.frame) * (percent / 100); 5 self.percentView.frame = CGRectMake(0, 0, width, CGRectGetHeight(self.frame)); 6 self.label.text = [NSString stringWithFormat:@"%.0f", floorf(percent)]; 7}
1override func displayLayer(layer: CALayer) { 2 if let pLayer = layer.presentationLayer() as? SWLayer { 3 let width = CGRectGetWidth(frame) * (pLayer.percent / 100) 4 percentView.frame = CGRectMake(0, 0, width, CGRectGetHeight(frame)) 5 label.text = String.init(format: "%.0f", floor(pLayer.percent)) 6 } 7}
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
1self.animView.percent = 0.0; 2[UIView animateWithDuration:kAnimDuration 3 animations:^{ 4 self.animView.percent = 100.0; 5 }];
1self.animView?.percent = 0 2UIView.animateWithDuration(SWViewController.kAnimDuration) { 3 self.animView?.percent = 100.0 4}
Ease out and reverse 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.
Don’t forget to check out the sample project on GitHub .
Useful links
Animating Custom Layer Properties
View Layer Synergy
Custom Animatable Property
More articles
fromNikola Lajic
Your job at codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
More articles in this subject area
Discover exciting further topics and let the codecentric world inspire you.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog author
Nikola Lajic
Do you still have questions? Just send me a message.
Do you still have questions? Just send me a message.