1. 程式人生 > >Layer 中自定義屬性的動畫

Layer 中自定義屬性的動畫

預設情況下,CALayer 及其子類的絕大部分標準屬性都可以執行動畫,無論是新增一個 CAAnimation 到 Layer(顯式動畫),亦或是為屬性指定一個動作然後修改它(隱式動畫)。

但有時候我們希望能同時為好幾個屬性新增動畫,使它們看起來像是一個動畫一樣;或者,我們需要執行的動畫不能通過使用標準 Layer 屬性動畫來實現。

在本文中,我們將討論如何子類化 CALayer 並新增我們自己的屬性,以便比較容易地建立那些如果以其他方式實現起來會很麻煩的動畫效果。

一般說來,我們希望新增到 CALayer 的子類上的可動畫屬性有三種類型:

  • 能間接動畫 Layer (或其子類)的一個或多個標準屬性的屬性。
  • 能觸發 Layer 背後的影象(即 contents 屬性)重繪的屬性。
  • 不涉及 Layer 重繪或對任何已有屬性執行動畫的屬性。

間接屬性動畫

能間接修改其它標準 Layer 屬性的自定義屬性是這些選項中最簡單的。它們僅僅只是自定義 setter 方法。然後將它們的輸入轉換為適用於建立動畫的一個或多個不同的值。

如果被我們設定的屬性已經預設好標準動畫,那我們完全不需要編寫任何實際的動畫程式碼,因為我們修改這些屬性後,它們就會繼承任何被配置在當前 CATransaction 上的動畫設定,並且自動執行動畫。

換句話說,即使 CALayer 不知道如何對我們自定義的屬性進行動畫,它依然能對因自定義屬性被改變而引起的其它可見副作用進行動畫,而這恰好就是我們所需要的。

為了演示這種方法,讓我們來建立一個簡單的模擬時鐘,之後我們可以使用被宣告為 NSDate 型別 time 屬性來設定它的時間。我會將從建立一個靜態的時鐘面盤開始。這個時鐘包含三個 CAShapeLayer 例項 —— 一個用於時鐘面盤的圓形 Layer 和兩個用於時針和分針的長方形 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

同時我們要設定一個包含 UIDatePicker 的基本的 View Controller,這樣我們就能測試我們的 Layer (日期選擇器在 Storyboard 裡設定)了:

@interface ViewController ()

@property (nonatomic, strong) IBOutlet UIDatePicker *datePicker;
@property (nonatomic, strong) ClockFace *clockFace;

@end


@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];

    // 新增時鐘面板 Layer
    self.clockFace = [[ClockFace alloc] init];
    self.clockFace.position = CGPointMake(self.view.bounds.size.width / 2, 150);
    [self.view.layer addSublayer:self.clockFace];

    // 設定預設時間
    self.clockFace.time = [NSDate date];
}

- (IBAction)setTime
{
    self.clockFace.time = self.datePicker.date;
}

@end

現在我們只需要實現 time 屬性的 setter 方法。這個方法使用 NSCalendar 將時間變為小時和分鐘,之後我們將它們轉換為角座標。然後我們就可以使用這些角度去生成兩個 CGAffineTransform 以旋轉時針和分針。

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

結果看起來像這樣:

你可以 從 GitHub 上 下載這個專案看看。

如你所見,我們實在沒有做什麼太費腦筋的事情;我們並沒有建立一個新的可動畫屬性,而只是在單個方法裡設定了幾個標準可動畫 Layer 屬性而已。然而,如果我們想建立的動畫並不能對映到任何已有的 Layer 屬性上時,該怎麼辦呢?

動畫 Layer 內容

假設不使用幾個分離的 Layer 來實現我們的時鐘面板,那我們可以改用 Core Graphics 來繪製時鐘。(這通常會降低效能,但我們可以假想我們所要實現的效果需要許多複雜的繪圖操作,而它們很難用常規的 Layer 屬性和 transform 來複制。)我們要怎麼做呢?

NSManagedObject 很類似, CALayer 具有為任何被宣告的屬性生成 dynamic 的 setter 和 getter 的能力。在我們當前的實現中,我們讓編譯器去 synthesize 了 time 屬性的 ivar 和 getter 方法,而我們自己實現了 setter 方法。但讓我們來改變一下:丟棄我們的 setter 並將屬性標記為 @dynamic 。同時我們也丟棄分離的時針和分針 Layer ,因為我們將自己去繪製它們。

@interface ClockFace ()

@end


@implementation ClockFace

@dynamic time;

- (id)init
{
    if ((self = [super init]))
    {
        self.bounds = CGRectMake(0, 0, 200, 200);
    }
    return self;
}

@end

在我們開始之前,需要先做一個小調整:因為不幸的是,CALayer 不知道如何對 NSDate 屬性進行插值(interpolate)(例如,雖然它可以處理數字型別和其它例如 CGColorCGAffineTransform 這樣的型別,但它不能自動生成不同的 NSDate 例項之間的中間值)。我們可以保留我們的自定義 setter 方法並用它設定另一個等價於 NSTimeInterval 的動態屬性(這是一個數字值,可以被插值),但為了保持例子的簡單性,我們會用一個浮點值替換 NSDate 屬性來表徵時鐘的小時。我們還更新了使用者介面,現在使用一個簡單的 UITextField 來設定浮點值,而不再使用日期選擇器:

@interface ViewController () <UITextFieldDelegate>

@property (nonatomic, strong) IBOutlet UITextField *textField;
@property (nonatomic, strong) ClockFace *clockFace;

@end


@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];

    // 新增時鐘面板 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

現在,既然我們已經移除了自定義的 setter 方法,那我們要如何才能知曉 time 屬性的改變呢?我們需要一個無論何時 time 屬性改變時都能自動通知 CALayer 的方式,這樣它才好重繪它的內容。我們通過覆寫 +needsDisplayForKey: 方法即可做到這一點,如下:

+ (BOOL)needsDisplayForKey:(NSString *)key
{
    if ([@"time" isEqualToString:key])
    {
        return YES;
    }
    return [super needsDisplayForKey:key];
}

這就告訴了 Layer ,無論何時 time 屬性被修改,它都需要呼叫 -display 方法。現在我們就覆寫 -display 方法,新增一個 NSLog 語句打印出 time 的值:

- (void)display
{
    NSLog(@"time: %f", self.time);
}

如果我們設定 time 屬性為 1.5 ,我們就會看到 -display 被呼叫,打印出新值:

2014-04-28 22:37:04.253 ClockFace[49145:60b] time: 1.500000

但這還不是我們真正想要的;我們希望 time 屬效能在舊值和新值之間在幾幀之內做一個平滑的過渡動畫。為了實現這一點,我們需要為 time 屬性指定一個動畫(或“動作(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];
}

現在,如果我們再次設定 time 屬性,我們就會看到 -display 被多次呼叫。呼叫的次數大約為每秒 60 次,至於動畫的長度,預設為 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

由於某些原因,當我們在每個中間點列印 time 值時,我們一直看到的是最終值。為何不能得到插值呢?因為我們檢視的是錯誤的 time 屬性。

當你設定某個 CALayer 的某個屬性,你實際設定的是 model Layer 的值 —— 這裡的 model Layer 表示正在進行的動畫結束時, Layer 所達到的最終狀態。如果你取 model Layer 的值,它就總是給你它被設定到的最終值。

但連線到 model Layer 的是所謂的 presentation Layer ——它是 model Layer 的一個拷貝,但它的值所表示的是 當前的,中間動畫狀態。如果我們修改 -display 方法去列印 Layer 的 presentationLayertime 屬性,那我們就會看到我們所期望的插值。(同時我們也使用 presentationLayertime 屬性來獲取動畫的開始值,替代 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

所以現在我們所要做就是畫出時鐘。我們將使用普通的 Core Graphics 函式以繪製到一個 Graphics Context 上來做到這一點,然後將產生出影象設定為我們 Layer 的 contents。下面是更新後的 -display 方法:

- (void)display
{
    // 獲取時間插值
    float time = [self.presentationLayer time];

    // 建立繪製上下文
    UIGraphicsBeginImageContextWithOptions(self.bounds.size, NO, 0);
    CGContextRef ctx = UIGraphicsGetCurrentContext();

    // 繪製時鐘面板
    CGContextSetLineWidth(ctx, 4);
    CGContextStrokeEllipseInRect(ctx, CGRectInset(self.bounds, 2, 2));

    // 繪製時針
    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 設定 contents 
    self.contents = (id)UIGraphicsGetImageFromCurrentImageContext().CGImage;
    UIGraphicsEndImageContext();
}

結果看起來如下:

如你所見,不同於第一個時鐘動畫,隨著時針的變化,分針實際上對每一個小時都會轉上滿滿一圈(就像一個真正的時鐘那樣),而不僅僅只是通過最短的路徑移動到它的最終位置;因為我們正在動畫的是 time 值本身而不僅僅是時針或分針的位置,所以上下文資訊被保留了。

通過這樣的方式繪製一個時鐘並不是很理想,因為 Core Graphics 函式沒有硬體加速,可能會引起動畫幀數的下降。另一種能每秒重繪 contents 影象 60 次的方式是用一個數組儲存一些預先繪製好的影象,然後基於合適的插值簡單的選擇對應的影象即可。實現程式碼大概如下:

const NSInteger hoursOnAClockFace = 12;

- (void)display
{
    // 獲取時間插值 
    float time = [self.presentationLayer time] / hoursOnAClockFace;

    // 從之前定義好的影象數組裡獲取影象幀
    NSInteger numberOfFrames = [self.frames count];
    NSInteger index = round(time * numberOfFrames) % numberOfFrames;
    UIImage *frame = self.frames[index];
    self.contents = (id)frame.CGImage;
}

通過避免在每一幀裡都用昂貴的軟體繪製,我們能改善動畫的效能,但代價是我們需要在記憶體裡儲存所有預先繪製的動畫幀影象,對於一個複雜的動畫來說,這可能造成驚人的記憶體浪費。

但這提出了一個有趣的可能性。如果我們完全不在 -display 裡更新 contents 影象會發生什麼?我們做一些其它的事情怎樣?

非可視屬性的動畫

-display 裡更新其它 Layer 屬性就是不必要的,因為我們可以很簡單地直接對任何這樣的屬性做動畫,如同我們在第一個時鐘面板例子裡所做的那樣。但如果我們設定一些其它的東西,比如某些完全和 Layer 不相關的東西,會怎樣呢?

下面的程式碼使用一個 CALayer 結合 AVAudioPlayer 來建立一個可動畫的音量控制器。通過把音量繫結到 dynamic 的 Layer 屬性上,我們可以使用 Core Animation 的屬性插值來平滑的在兩個不同的音量之間漸變,以同樣的方式我們可以動畫 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
{
    // 設定音量值為合適的音量插值
    self.player.volume = [self.presentationLayer volume];
}

@end

我們可以通過使用一個簡單的有著播放、停止、音量增大以及音量減小按鈕的 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 沒有可見的外觀,但它依然需要被新增到螢幕上的檢視層級裡,以便動畫能正常工作。

結論

CALayer 的 dynamic 屬性提供了一中簡單的機制來實現任何形式的動畫 —— 不僅僅只是內建的那些。而通過覆寫 -display 方法,我們可以使用這些屬性去控制任何我們想控制的東西,甚至是音量值這樣的東西。

通過使用這些屬性,我們不僅僅避免了重複造輪子,同時還確保了我們的自定義動畫能與標準動畫的時機和控制函式協同工作,以此就能非常容易地與其它動畫屬性同步。