默認(rèn)情況下,CALayer 及其子類的絕大部分標(biāo)準(zhǔn)屬性都可以執(zhí)行動(dòng)畫,無論是添加一個(gè) CAAnimation 到 Layer(顯式動(dòng)畫),亦或是為屬性指定一個(gè)動(dòng)作然后修改它(隱式動(dòng)畫)。
但有時(shí)候我們希望能同時(shí)為好幾個(gè)屬性添加動(dòng)畫,使它們看起來像是一個(gè)動(dòng)畫一樣;或者,我們需要執(zhí)行的動(dòng)畫不能通過使用標(biāo)準(zhǔn) Layer 屬性動(dòng)畫來實(shí)現(xiàn)。
在本文中,我們將討論如何子類化 CALayer 并添加我們自己的屬性,以便比較容易地創(chuàng)建那些如果以其他方式實(shí)現(xiàn)起來會很麻煩的動(dòng)畫效果。
一般說來,我們希望添加到 CALayer 的子類上的可動(dòng)畫屬性有三種類型:
contents 屬性)重繪的屬性。能間接修改其它標(biāo)準(zhǔn) Layer 屬性的自定義屬性是這些選項(xiàng)中最簡單的。它們僅僅只是自定義 setter 方法。然后將它們的輸入轉(zhuǎn)換為適用于創(chuàng)建動(dòng)畫的一個(gè)或多個(gè)不同的值。
如果被我們設(shè)置的屬性已經(jīng)預(yù)設(shè)好標(biāo)準(zhǔn)動(dòng)畫,那我們完全不需要編寫任何實(shí)際的動(dòng)畫代碼,因?yàn)槲覀冃薷倪@些屬性后,它們就會繼承任何被配置在當(dāng)前 CATransaction 上的動(dòng)畫設(shè)置,并且自動(dòng)執(zhí)行動(dòng)畫。
換句話說,即使 CALayer 不知道如何對我們自定義的屬性進(jìn)行動(dòng)畫,它依然能對因自定義屬性被改變而引起的其它可見副作用進(jìn)行動(dòng)畫,而這恰好就是我們所需要的。
為了演示這種方法,讓我們來創(chuàng)建一個(gè)簡單的模擬時(shí)鐘,之后我們可以使用被聲明為 NSDate 類型 time 屬性來設(shè)置它的時(shí)間。我會將從創(chuàng)建一個(gè)靜態(tài)的時(shí)鐘面盤開始。這個(gè)時(shí)鐘包含三個(gè) CAShapeLayer 實(shí)例 —— 一個(gè)用于時(shí)鐘面盤的圓形 Layer 和兩個(gè)用于時(shí)針和分針的長方形 Sublayer。
@interface ClockFace: CAShapeLayer
@property (nonatomic, strong) NSDate *time;
@end
@interface ClockFace ()
// 私有屬性
@property (nonatomic, strong) CAShapeLayer *hourHand;
@property (nonatomic, strong) CAShapeLayer *minuteHand;
@end
@implementation ClockFace
- (id)init
{
if ((self = [super init]))
{
self.bounds = CGRectMake(0, 0, 200, 200);
self.path = [UIBezierPath bezierPathWithOvalInRect:self.bounds].CGPath;
self.fillColor = [UIColor whiteColor].CGColor;
self.strokeColor = [UIColor blackColor].CGColor;
self.lineWidth = 4;
self.hourHand = [CAShapeLayer layer];
self.hourHand.path = [UIBezierPath bezierPathWithRect:CGRectMake(-2, -70, 4, 70)].CGPath;
self.hourHand.fillColor = [UIColor blackColor].CGColor;
self.hourHand.position = CGPointMake(self.bounds.size.width / 2, self.bounds.size.height / 2);
[self addSublayer:self.hourHand];
self.minuteHand = [CAShapeLayer layer];
self.minuteHand.path = [UIBezierPath bezierPathWithRect:CGRectMake(-1, -90, 2, 90)].CGPath;
self.minuteHand.fillColor = [UIColor blackColor].CGColor;
self.minuteHand.position = CGPointMake(self.bounds.size.width / 2, self.bounds.size.height / 2);
[self addSublayer:self.minuteHand];
}
return self;
}
@end
同時(shí)我們要設(shè)置一個(gè)包含 UIDatePicker 的基本的 View Controller,這樣我們就能測試我們的 Layer (日期選擇器在 Storyboard 里設(shè)置)了:
@interface ViewController ()
@property (nonatomic, strong) IBOutlet UIDatePicker *datePicker;
@property (nonatomic, strong) ClockFace *clockFace;
@end
@implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];
// 添加時(shí)鐘面板 Layer
self.clockFace = [[ClockFace alloc] init];
self.clockFace.position = CGPointMake(self.view.bounds.size.width / 2, 150);
[self.view.layer addSublayer:self.clockFace];
// 設(shè)置默認(rèn)時(shí)間
self.clockFace.time = [NSDate date];
}
- (IBAction)setTime
{
self.clockFace.time = self.datePicker.date;
}
@end
現(xiàn)在我們只需要實(shí)現(xiàn) time 屬性的 setter 方法。這個(gè)方法使用 NSCalendar 將時(shí)間變?yōu)樾r(shí)和分鐘,之后我們將它們轉(zhuǎn)換為角坐標(biāo)。然后我們就可以使用這些角度去生成兩個(gè) CGAffineTransform 以旋轉(zhuǎn)時(shí)針和分針。
- (void)setTime:(NSDate *)time
{
_time = time;
NSCalendar *calendar = [[NSCalendar alloc] initWithCalendarIdentifier:NSGregorianCalendar];
NSDateComponents *components = [calendar components:NSHourCalendarUnit | NSMinuteCalendarUnit fromDate:time];
self.hourHand.affineTransform = CGAffineTransformMakeRotation(components.hour / 12.0 * 2.0 * M_PI);
self.minuteHand.affineTransform = CGAffineTransformMakeRotation(components.minute / 60.0 * 2.0 * M_PI);
}
結(jié)果看起來像這樣:
http://wiki.jikexueyuan.com/project/objc/images/12-18.gif" alt="" />
你可以 從 GitHub 上 下載這個(gè)項(xiàng)目看看。
如你所見,我們實(shí)在沒有做什么太費(fèi)腦筋的事情;我們并沒有創(chuàng)建一個(gè)新的可動(dòng)畫屬性,而只是在單個(gè)方法里設(shè)置了幾個(gè)標(biāo)準(zhǔn)可動(dòng)畫 Layer 屬性而已。然而,如果我們想創(chuàng)建的動(dòng)畫并不能映射到任何已有的 Layer 屬性上時(shí),該怎么辦呢?
假設(shè)不使用幾個(gè)分離的 Layer 來實(shí)現(xiàn)我們的時(shí)鐘面板,那我們可以改用 Core Graphics 來繪制時(shí)鐘。(這通常會降低性能,但我們可以假想我們所要實(shí)現(xiàn)的效果需要許多復(fù)雜的繪圖操作,而它們很難用常規(guī)的 Layer 屬性和 transform 來復(fù)制。)我們要怎么做呢?
與 NSManagedObject 很類似, CALayer 具有為任何被聲明的屬性生成 dynamic 的 setter 和 getter 的能力。在我們當(dāng)前的實(shí)現(xiàn)中,我們讓編譯器去 synthesize 了 time 屬性的 ivar 和 getter 方法,而我們自己實(shí)現(xiàn)了 setter 方法。但讓我們來改變一下:丟棄我們的 setter 并將屬性標(biāo)記為 @dynamic 。同時(shí)我們也丟棄分離的時(shí)針和分針 Layer ,因?yàn)槲覀儗⒆约喝ダL制它們。
@interface ClockFace ()
@end
@implementation ClockFace
@dynamic time;
- (id)init
{
if ((self = [super init]))
{
self.bounds = CGRectMake(0, 0, 200, 200);
}
return self;
}
@end
在我們開始之前,需要先做一個(gè)小調(diào)整:因?yàn)椴恍业氖牵?code>CALayer 不知道如何對 NSDate 屬性進(jìn)行插值(interpolate)(例如,雖然它可以處理數(shù)字類型和其它例如 CGColor 和 CGAffineTransform 這樣的類型,但它不能自動(dòng)生成不同的 NSDate 實(shí)例之間的中間值)。我們可以保留我們的自定義 setter 方法并用它設(shè)置另一個(gè)等價(jià)于 NSTimeInterval 的動(dòng)態(tài)屬性(這是一個(gè)數(shù)字值,可以被插值),但為了保持例子的簡單性,我們會用一個(gè)浮點(diǎn)值替換 NSDate 屬性來表征時(shí)鐘的小時(shí)。我們還更新了用戶界面,現(xiàn)在使用一個(gè)簡單的 UITextField 來設(shè)置浮點(diǎn)值,而不再使用日期選擇器:
@interface ViewController () <UITextFieldDelegate>
@property (nonatomic, strong) IBOutlet UITextField *textField;
@property (nonatomic, strong) ClockFace *clockFace;
@end
@implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];
// 添加時(shí)鐘面板 Layer
self.clockFace = [[ClockFace alloc] init];
self.clockFace.position = CGPointMake(self.view.bounds.size.width / 2, 150);
[self.view.layer addSublayer:self.clockFace];
}
- (BOOL)textFieldShouldReturn:(UITextField *)textField
{
[textField resignFirstResponder];
return YES;
}
- (void)textFieldDidEndEditing:(UITextField *)textField
{
self.clockFace.time = [textField.text floatValue];
}
@end
現(xiàn)在,既然我們已經(jīng)移除了自定義的 setter 方法,那我們要如何才能知曉 time 屬性的改變呢?我們需要一個(gè)無論何時(shí) time 屬性改變時(shí)都能自動(dòng)通知 CALayer 的方式,這樣它才好重繪它的內(nèi)容。我們通過覆寫 +needsDisplayForKey: 方法即可做到這一點(diǎn),如下:
+ (BOOL)needsDisplayForKey:(NSString *)key
{
if ([@"time" isEqualToString:key])
{
return YES;
}
return [super needsDisplayForKey:key];
}
這就告訴了 Layer ,無論何時(shí) time 屬性被修改,它都需要調(diào)用 -display 方法?,F(xiàn)在我們就覆寫 -display 方法,添加一個(gè) NSLog 語句打印出 time 的值:
- (void)display
{
NSLog(@"time: %f", self.time);
}
如果我們設(shè)置 time 屬性為 1.5 ,我們就會看到 -display 被調(diào)用,打印出新值:
2014-04-28 22:37:04.253 ClockFace[49145:60b] time: 1.500000
但這還不是我們真正想要的;我們希望 time 屬性能在舊值和新值之間在幾幀之內(nèi)做一個(gè)平滑的過渡動(dòng)畫。為了實(shí)現(xiàn)這一點(diǎn),我們需要為 time 屬性指定一個(gè)動(dòng)畫(或“動(dòng)作(action)”),而通過覆寫 -actionForKey: 方法就能做到:
- (id<CAAction>)actionForKey:(NSString *)key
{
if ([key isEqualToString:@"time"])
{
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:key];
animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
animation.fromValue = @(self.time);
return animation;
}
return [super actionForKey:key];
}
現(xiàn)在,如果我們再次設(shè)置 time 屬性,我們就會看到 -display 被多次調(diào)用。調(diào)用的次數(shù)大約為每秒 60 次,至于動(dòng)畫的長度,默認(rèn)為 0.25 秒,大約是 15 幀:
2014-04-28 22:37:04.253 ClockFace[49145:60b] time: 1.500000
2014-04-28 22:37:04.255 ClockFace[49145:60b] time: 1.500000
2014-04-28 22:37:04.351 ClockFace[49145:60b] time: 1.500000
2014-04-28 22:37:04.370 ClockFace[49145:60b] time: 1.500000
2014-04-28 22:37:04.388 ClockFace[49145:60b] time: 1.500000
2014-04-28 22:37:04.407 ClockFace[49145:60b] time: 1.500000
2014-04-28 22:37:04.425 ClockFace[49145:60b] time: 1.500000
2014-04-28 22:37:04.443 ClockFace[49145:60b] time: 1.500000
2014-04-28 22:37:04.461 ClockFace[49145:60b] time: 1.500000
2014-04-28 22:37:04.479 ClockFace[49145:60b] time: 1.500000
2014-04-28 22:37:04.497 ClockFace[49145:60b] time: 1.500000
2014-04-28 22:37:04.515 ClockFace[49145:60b] time: 1.500000
2014-04-28 22:37:04.755 ClockFace[49145:60b] time: 1.500000
由于某些原因,當(dāng)我們在每個(gè)中間點(diǎn)打印 time 值時(shí),我們一直看到的是最終值。為何不能得到插值呢?因?yàn)槲覀儾榭吹氖清e(cuò)誤的 time 屬性。
當(dāng)你設(shè)置某個(gè) CALayer 的某個(gè)屬性,你實(shí)際設(shè)置的是 model Layer 的值 —— 這里的 model Layer 表示正在進(jìn)行的動(dòng)畫結(jié)束時(shí), Layer 所達(dá)到的最終狀態(tài)。如果你取 model Layer 的值,它就總是給你它被設(shè)置到的最終值。
但連接到 model Layer 的是所謂的 presentation Layer ——它是 model Layer 的一個(gè)拷貝,但它的值所表示的是 當(dāng)前的,中間動(dòng)畫狀態(tài)。如果我們修改 -display 方法去打印 Layer 的 presentationLayer 的 time 屬性,那我們就會看到我們所期望的插值。(同時(shí)我們也使用 presentationLayer 的 time 屬性來獲取動(dòng)畫的開始值,替代 self.time ):
- (id<CAAction>)actionForKey:(NSString *)key
{
if ([key isEqualToString:@"time"])
{
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:key];
animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
animation.fromValue = @([[self presentationLayer] time]);
return animation;
}
return [super actionForKey:key];
}
- (void)display
{
NSLog(@"time: %f", [[self presentationLayer] time]);
}
下面是打印出的值:
2014-04-28 22:43:31.200 ClockFace[49176:60b] time: 0.000000
2014-04-28 22:43:31.203 ClockFace[49176:60b] time: 0.002894
2014-04-28 22:43:31.263 ClockFace[49176:60b] time: 0.363371
2014-04-28 22:43:31.300 ClockFace[49176:60b] time: 0.586421
2014-04-28 22:43:31.318 ClockFace[49176:60b] time: 0.695179
2014-04-28 22:43:31.336 ClockFace[49176:60b] time: 0.803713
2014-04-28 22:43:31.354 ClockFace[49176:60b] time: 0.912598
2014-04-28 22:43:31.372 ClockFace[49176:60b] time: 1.021573
2014-04-28 22:43:31.391 ClockFace[49176:60b] time: 1.134173
2014-04-28 22:43:31.409 ClockFace[49176:60b] time: 1.242892
2014-04-28 22:43:31.427 ClockFace[49176:60b] time: 1.352016
2014-04-28 22:43:31.446 ClockFace[49176:60b] time: 1.460729
2014-04-28 22:43:31.464 ClockFace[49176:60b] time: 1.500000
2014-04-28 22:43:31.636 ClockFace[49176:60b] time: 1.500000
所以現(xiàn)在我們所要做就是畫出時(shí)鐘。我們將使用普通的 Core Graphics 函數(shù)以繪制到一個(gè) Graphics Context 上來做到這一點(diǎn),然后將產(chǎn)生出圖像設(shè)置為我們 Layer 的 contents。下面是更新后的 -display 方法:
- (void)display
{
// 獲取時(shí)間插值
float time = [self.presentationLayer time];
// 創(chuàng)建繪制上下文
UIGraphicsBeginImageContextWithOptions(self.bounds.size, NO, 0);
CGContextRef ctx = UIGraphicsGetCurrentContext();
// 繪制時(shí)鐘面板
CGContextSetLineWidth(ctx, 4);
CGContextStrokeEllipseInRect(ctx, CGRectInset(self.bounds, 2, 2));
// 繪制時(shí)針
CGFloat angle = time / 12.0 * 2.0 * M_PI;
CGPoint center = CGPointMake(self.bounds.size.width / 2, self.bounds.size.height / 2);
CGContextSetLineWidth(ctx, 4);
CGContextMoveToPoint(ctx, center.x, center.y);
CGContextAddLineToPoint(ctx, center.x + sin(angle) * 80, center.y - cos(angle) * 80);
CGContextStrokePath(ctx);
// 繪制分針
angle = (time - floor(time)) * 2.0 * M_PI;
CGContextSetLineWidth(ctx, 2);
CGContextMoveToPoint(ctx, center.x, center.y);
CGContextAddLineToPoint(ctx, center.x + sin(angle) * 90, center.y - cos(angle) * 90);
CGContextStrokePath(ctx);
//set backing image 設(shè)置 contents
self.contents = (id)UIGraphicsGetImageFromCurrentImageContext().CGImage;
UIGraphicsEndImageContext();
}
結(jié)果看起來如下:
http://wiki.jikexueyuan.com/project/objc/images/12-19.gif" alt="" />
如你所見,不同于第一個(gè)時(shí)鐘動(dòng)畫,隨著時(shí)針的變化,分針實(shí)際上對每一個(gè)小時(shí)都會轉(zhuǎn)上滿滿一圈(就像一個(gè)真正的時(shí)鐘那樣),而不僅僅只是通過最短的路徑移動(dòng)到它的最終位置;因?yàn)槲覀冋趧?dòng)畫的是 time 值本身而不僅僅是時(shí)針或分針的位置,所以上下文信息被保留了。
通過這樣的方式繪制一個(gè)時(shí)鐘并不是很理想,因?yàn)?Core Graphics 函數(shù)沒有硬件加速,可能會引起動(dòng)畫幀數(shù)的下降。另一種能每秒重繪 contents 圖像 60 次的方式是用一個(gè)數(shù)組存儲一些預(yù)先繪制好的圖像,然后基于合適的插值簡單的選擇對應(yīng)的圖像即可。實(shí)現(xiàn)代碼大概如下:
const NSInteger hoursOnAClockFace = 12;
- (void)display
{
// 獲取時(shí)間插值
float time = [self.presentationLayer time] / hoursOnAClockFace;
// 從之前定義好的圖像數(shù)組里獲取圖像幀
NSInteger numberOfFrames = [self.frames count];
NSInteger index = round(time * numberOfFrames) % numberOfFrames;
UIImage *frame = self.frames[index];
self.contents = (id)frame.CGImage;
}
通過避免在每一幀里都用昂貴的軟件繪制,我們能改善動(dòng)畫的性能,但代價(jià)是我們需要在內(nèi)存里存儲所有預(yù)先繪制的動(dòng)畫幀圖像,對于一個(gè)復(fù)雜的動(dòng)畫來說,這可能造成驚人的內(nèi)存浪費(fèi)。
但這提出了一個(gè)有趣的可能性。如果我們完全不在 -display 里更新 contents 圖像會發(fā)生什么?我們做一些其它的事情怎樣?
在 -display 里更新其它 Layer 屬性就是不必要的,因?yàn)槲覀兛梢院芎唵蔚刂苯訉θ魏芜@樣的屬性做動(dòng)畫,如同我們在第一個(gè)時(shí)鐘面板例子里所做的那樣。但如果我們設(shè)置一些其它的東西,比如某些完全和 Layer 不相關(guān)的東西,會怎樣呢?
下面的代碼使用一個(gè) CALayer 結(jié)合 AVAudioPlayer 來創(chuàng)建一個(gè)可動(dòng)畫的音量控制器。通過把音量綁定到 dynamic 的 Layer 屬性上,我們可以使用 Core Animation 的屬性插值來平滑的在兩個(gè)不同的音量之間漸變,以同樣的方式我們可以動(dòng)畫 Layer 上的任何自定義屬性:
@interface AudioLayer : CALayer
- (id)initWithAudioFileURL:(NSURL *)URL;
@property (nonatomic, assign) float volume;
- (void)play;
- (void)stop;
- (BOOL)isPlaying;
@end
@interface AudioLayer ()
@property (nonatomic, strong) AVAudioPlayer *player;
@end
@implementation AudioLayer
@dynamic volume;
- (id)initWithAudioFileURL:(NSURL *)URL
{
if ((self = [self init]))
{
self.volume = 1.0;
self.player = [[AVAudioPlayer alloc] initWithContentsOfURL:URL error:NULL];
}
return self;
}
- (void)play
{
[self.player play];
}
- (void)stop
{
[self.player stop];
}
- (BOOL)isPlaying
{
return self.player.playing;
}
+ (BOOL)needsDisplayForKey:(NSString *)key
{
if ([@"volume" isEqualToString:key])
{
return YES;
}
return [super needsDisplayForKey:key];
}
- (id<CAAction>)actionForKey:(NSString *)key
{
if ([key isEqualToString:@"volume"])
{
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:key];
animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
animation.fromValue = @([[self presentationLayer] volume]);
return animation;
}
return [super actionForKey:key];
}
- (void)display
{
// 設(shè)置音量值為合適的音量插值
self.player.volume = [self.presentationLayer volume];
}
@end
我們可以通過使用一個(gè)簡單的有著播放、停止、音量增大以及音量減小按鈕的 View Controller 來做測試:
@interface ViewController ()
@property (nonatomic, strong) AudioLayer *audioLayer;
@end
@implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];
NSURL *musicURL = [NSURL fileURLWithPath:[[NSBundle mainBundle] pathForResource:@"music" ofType:@"caf"]];
self.audioLayer = [[AudioLayer alloc] initWithAudioFileURL:musicURL];
[self.view.layer addSublayer:self.audioLayer];
}
- (IBAction)playPauseMusic:(UIButton *)sender
{
if ([self.audioLayer isPlaying])
{
[self.audioLayer stop];
[sender setTitle:@"Play Music" forState:UIControlStateNormal];
}
else
{
[self.audioLayer play];
[sender setTitle:@"Pause Music" forState:UIControlStateNormal];
}
}
- (IBAction)fadeIn
{
self.audioLayer.volume = 1;
}
- (IBAction)fadeOut
{
self.audioLayer.volume = 0;
}
@end
注意:盡管我們的 Layer 沒有可見的外觀,但它依然需要被添加到屏幕上的視圖層級里,以便動(dòng)畫能正常工作。
CALayer 的 dynamic 屬性提供了一中簡單的機(jī)制來實(shí)現(xiàn)任何形式的動(dòng)畫 —— 不僅僅只是內(nèi)建的那些。而通過覆寫 -display 方法,我們可以使用這些屬性去控制任何我們想控制的東西,甚至是音量值這樣的東西。
通過使用這些屬性,我們不僅僅避免了重復(fù)造輪子,同時(shí)還確保了我們的自定義動(dòng)畫能與標(biāo)準(zhǔn)動(dòng)畫的時(shí)機(jī)和控制函數(shù)協(xié)同工作,以此就能非常容易地與其它動(dòng)畫屬性同步。