前言
不得不說,單單是文章的標題,可能不足以說明本文的內容。因此,在繼續講述約束動畫之前,我先放上本文要實現的動畫效果。
動畫
約束動畫並不是非常復雜的技巧,在你熟練使用約束之後,你總能創建些獨具匠心的動畫。在上一篇autolayout動畫初體驗中,我們根據監聽列表視圖的滾動偏移來不斷改變約束值,從而制作出動畫的效果。但上個動畫的實現更像是我們制作了一幀幀連續的界面從而達成動畫的效果 —— 這未免太過繁雜。而在本文我們將拋棄這種繁雜的方式,通過調用UIView的重繪制視圖方法來實現動畫。
本文的動畫主要存在這麼幾個:
賬戶紀錄列表的彈出和收回
登錄按鈕的點擊形變
登錄按鈕被點擊後的轉圈動畫(不做詳細講述)
實際上來說,上述的轉圈動畫我是通過CoreAnimation框架的動畫+定時器的方式實現的,當然這也意味著在本篇文章是約束動畫的終結
准備
首先我們需要把所有控件的層次弄清楚,然後搭建整個界面。在demo中,在動畫開始前可視的控件總共有五個 —— 用戶頭像、賬戶輸入框、下拉按鈕、密碼輸入框以及登錄按鈕,還有一個保存賬號信息的列表在賬戶輸入框下面。我們通過修改這個列表跟賬戶頂部約束值來使其下移出現:
紀錄列表向下移動
在這些控件的約束中我們需要在代碼中改變用到的約束包括:
紀錄列表的頂部約束listTopConstraint,修改後可以讓列表向下移動出現
紀錄列表的高度約束listHeightConstraint,用來設置產生展開動畫
賬戶輸入框的高度約束accountHeightConstraint,用來設置列表的下移量
登錄按鈕左右側相對於父視圖的間距約束loginLeftConstraint以及loginRightConstraint,通過改變這兩個約束來使按鈕縮小
登錄按鈕的高度約束loginHeightConstraint,用來計算縮小後的按鈕寬度
除了約束屬性之外,我們還需要一些數據來支持我們的動畫:
@property(assign, nonatomic) BOOL isAnimating; // 用來判斷登錄按鈕的動畫狀態 @property(strong, nonatomic) NSArray * records; // 列表的數據源,demo中存儲五個字符串
為了保證列表出現的時候不被其他視圖遮擋,設置的視圖層級關系如下:
下拉按鈕 > 賬戶輸入框 > 紀錄列表 > 頭像 = 登錄按鈕 = 密碼輸入框
下拉動畫
在這裡我把demo的動畫分為兩小節來講解,因為這兩個動畫的實現方式有很大的差別。當然了,這兩者都能通過直接修改約束的constant值來實現,但這不是本文的講解目的。
在我們點擊下拉按鈕的時候會出現兩個動畫,包括下拉列表的180°旋轉以及下拉或者隱藏消失。正常來說,我們需要使用一個BOOL類型的變量來標識列表是否處在展開的狀態以此來決定動畫的方式,但在關聯事件方法的時候作為發送者的下拉按鈕已經提供給了我們這個變量isSelected,通過修改這個值來完成標記列表展開狀態。因此旋轉下拉按鈕的代碼如下:
/// 點擊打開或者隱藏列表 - (IBAction)actionToOpenOrCloseList:(UIButton *)sender { [self.view endEditing: YES]; [self animateToRotateArrow: sender.selected]; sender.isSelected ? [self showRecordList] : [self hideRecordList]; } /// 按鈕轉向動畫 - (void)animateToRotateArrow: (BOOL)selected { CATransform3D transform = selected ? CATransform3DIdentity : CATransform3DMakeRotation(M_PI, 0, 0, 1); [_dropdownButton setSelected: !selected]; [UIView animateWithDuration: 0.25 animations: ^{ _dropdownButton.layer.transform = transform; }]; }
可以看到我們的代碼中根據按鈕的isSelected屬性來決定列表的展開或者收回,對此我們需要修改列表的listHeightConstraint和listTopConstraint來設置列表的大小和位置,而且我們需要給展開的列表加上一個彈出來的動畫:
/// 顯示紀錄列表 - (void)showRecordList { [UIView animateWithDuration: 0.25 delay: 0 usingSpringWithDamping: 0.4 initialSpringVelocity: 5 options: UIViewAnimationOptionCurveEaseInOut | UIViewAnimationOptionAllowUserInteraction animations: ^{ _listTopConstraint.constant = _accountHeightConstraint.constant; _listHeightConstraint.constant = _accountHeightConstraint.constant * 5; } completion: nil]; } /// 隱藏紀錄列表 - (void)hideRecordList { [UIView animateWithDuration: 0.25 animations: ^{ _listTopConstraint.constant = 0; _listHeightConstraint.constant = 0; } completion: nil]; }
好,運行你的代碼,看看效果,這肯定不會是你想要的效果。
錯誤的動畫
在UIView動畫中有趣的一件事情是:如果你直接在動畫的block中提交修改了視圖的相關屬性時,它們會按照你預期的效果執行產生動畫。但在你修改約束值的時候會直接計算出約束生效後的布局結果,並且直接顯示 —— 即便你把修改約束的代碼放在了動畫的block當中執行。
針對這個問題,iOS為所有視圖提供了一個方法- (void)layoutIfNeeded用來立刻刷新界面,這個方法會調用當前視圖上面所有的子視圖的- (void)layoutSubviews讓子視圖進行重新布局。如果我們先設置好約束值,然後在動畫的執行代碼調用layoutIfNeeded就能讓界面不斷重新繪制產生動畫效果。因此,上面的展開收回代碼改成下面這樣:
/// 顯示紀錄列表 - (void)showRecordList { _listTopConstraint.constant = _accountHeightConstraint.constant; _listHeightConstraint.constant = _accountHeightConstraint.constant * 5; [UIView animateWithDuration: 0.25 delay: 0 usingSpringWithDamping: 0.4 initialSpringVelocity: 5 options: UIViewAnimationOptionCurveEaseInOut | UIViewAnimationOptionAllowUserInteraction animations: ^{ [self.view layoutIfNeeded]; } completion: nil]; } /// 隱藏紀錄列表 - (void)hideRecordList { _listTopConstraint.constant = 0; _listHeightConstraint.constant = 0; [UIView animateWithDuration: 0.25 animations: ^{ [self.view layoutIfNeeded]; } completion: nil]; }
現在再次運行你的代碼,紀錄列表已經能夠正常的實現彈出展開以及收回的動畫了
登錄按鈕動畫
毫不客氣的說,在本次的demo中,我最喜歡的是登錄按鈕點擊之後的動畫效果。當然,這也意味著這個動畫效果是耗時最長的。
由於如demo動畫所示,在點擊登錄動畫的時候按鈕中間會有進度的旋轉動畫。如果在controller中實現這個效果,需要涉及到layer層的操作,而這不應該是控制器的職能,因此我將登錄按鈕單獨封裝出來處理,並提供了兩個接口:- (void)start和- (void)stop方便控制器調用來開始和停止動畫。這兩個方法內部實現如下:
const NSTimeInterval duration = 1.2; ///動畫開始隱藏文字 - (void)start { [self addAnimate]; if (_timer) { [_timer invalidate]; } NSTimer * timer = [NSTimer scheduledTimerWithTimeInterval: duration target: self selector: @selector(addAnimate) userInfo: nil repeats: YES]; _timer = timer; [UIView animateWithDuration: 0.5 animations: ^{ _circle.opacity = 1; [self setTitleColor: [UIColor colorWithWhite: 1 alpha: 0] forState: UIControlStateNormal]; }]; } ///動畫結束時顯示文字 - (void)stop { if (_timer) { [_timer invalidate]; } [self.circle removeAllAnimations]; [UIView animateWithDuration: 0.5 animations: ^{ _circle.opacity = 0; [self setTitleColor: [UIColor colorWithWhite: 1 alpha: 1] forState: UIControlStateNormal]; }]; }
前文我已經說過按鈕的轉圈動畫基於定時器和CoreAnimation框架動畫實現,由於這不屬於約束動畫范疇,我就不對具體實現進行闡述了,感興趣的可以到本文的demo中去查看實現。
除了按鈕自身轉圈、文字隱藏顯示的動畫之外,還包括了自身的尺寸變化代碼。在縮小之後按鈕依舊保持在視圖的x軸中心位置,因此如果我們修改左右約束,那麼要保證這兩個值是相等的。在動畫前後按鈕的高度都沒有發生變化,在縮小的過程中寬度縮小成和高度一樣的大小,我們現在有按鈕的高度height,通過代碼計算出左右新約束:
/// 點擊登錄動畫 - (IBAction)actionToSignIn:(UIButton *)sender { _isAnimating = !_isAnimating; if (_isAnimating) { [_signInButton start]; [self animateToMakeButtonSmall]; } else { [_signInButton stop]; [self animateToMakeButtonBig]; } } ///縮小動畫 - (void)animateToMakeButtonSmall { CGFloat height = _loginHeightConstraint.constant; CGFloat screenWidth = CGRectGetWidth(self.view.frame); CGFloat spacingConstant = (screenWidth - height) / 2; _loginLeftConstraint.constant = _loginRightConstraint.constant = spacingConstant; [UIView animateWithDuration: 0.15 delay: 0 options: UIViewAnimationOptionCurveEaseOut animations: ^{ [self.view layoutIfNeeded]; } completion: nil]; } ///放大動畫 - (void)animateToMakeButtonBig { _loginLeftConstraint.constant = _loginRightConstraint.constant = 0; [UIView animateWithDuration: 0.15 delay: 0 options: UIViewAnimationOptionCurveEaseOut animations: ^{ [self.view layoutIfNeeded]; } completion: nil]; }
我們通過了計算左右間隔設置好了登錄按鈕的動畫,這很好,但是我們想想,上面的動畫實現思路是:
獲取按鈕高度和父視圖寬度 -> 計算按鈕左右間隔 -> 實現動畫
可是我們最開始的實現思路是什麼?
按鈕變小,保持寬高一致 -> 按鈕居中 -> 實現動畫
這說起來有些荒謬,雖然動畫實現了,但是這並不應該是我們的實現方式。因此,為了保證我們的思路能夠正確執行,我們的操作步驟應該如下:
1、移除登錄按鈕左右約束
2、添加寬高等比約束
3、添加按鈕相對於父視圖的居中約束
在執行這些步驟之前,我們先來看看關於約束的計算公式以及這些計算的變量在NSLayoutConstraint對象中代表的屬性:
約束公式
根據這個公式假設我現在要給當前視圖上的一個按鈕添加水平居中的約束,那麼約束的創建代碼如下:
NSLayoutConstraint * centerXConstraint = [NSLayoutConstraint
constraintWithItem: _button //firstItem
attribute: NSLayoutAttributeCenterX //firstAttribute
relatedBy: NSLayoutRelationEqual //relation
toItem: _button.superview //secondItem
attribute: NSLayoutAttributeCenterX //secondAttribute
multiplier: 1.0 //multiplier
constant: 0]; //constant
我們可以通過上面這段代碼清楚的看到布局的相關屬性和代碼的對應,如果你在xcode中通過查找進入到了NSLayoutConstraint的類文件中,你還會發現這些屬性中只有constant是可寫的,這意味著你沒辦法通過正常方式設置multipier這樣的值來改變某個控件在父視圖中的寬度。盡管KVC可以做到這一點,但這不應該是解決方式。
因此,我們需要通過創建新的約束並且移除舊的約束來實現登錄按鈕的動畫效果。在iOS8之前這個工作無疑是繁雜的,我們需要通過
- (void)addConstraint:(NSLayoutConstraint *)constraint; - (void)addConstraints:(NSArray *)constraints; - (void)removeConstraint:(NSLayoutConstraint *)constraint; - (void)removeConstraints:(NSArray *)constraints;
這一系列方法來增刪約束,但在iOS8之後,NSLayoutConstraint提供了active的BOOL類型的變量供我們提供設置約束是否有效,這個值設置NO的時候約束就失效。同樣我們創建了一個約束對象之後只需要設置active為YES之後就會自動生效了。因此,根據上面的公式,我們修改代碼如下:
/// 縮小按鈕 - (void)animateToMakeButtonSmall { _loginLeftConstraint.active = NO; _loginRightConstraint.active = NO; //創建寬高比約束 NSLayoutConstraint * ratioConstraint = [NSLayoutConstraint constraintWithItem: _signInButton attribute: NSLayoutAttributeWidth relatedBy: NSLayoutRelationEqual toItem: _signInButton attribute: NSLayoutAttributeHeight multiplier: 1. constant: 0]; ratioConstraint.active = YES; _loginRatioConstraint = ratioConstraint; //創建居中約束 NSLayoutConstraint * centerXConstraint = [NSLayoutConstraint constraintWithItem: _signInButton attribute: NSLayoutAttributeCenterX relatedBy: NSLayoutRelationEqual toItem: _signInButton.superview attribute: NSLayoutAttributeCenterX multiplier: 1. constant: 0.]; centerXConstraint.active = YES; _loginCenterXConstraint = centerXConstraint; [UIView animateWithDuration: 0.15 delay: 0 options: UIViewAnimationOptionCurveEaseIn animations: ^{ [self.view layoutIfNeeded]; } completion: nil]; } /// 還原按鈕 - (void)animateToMakeButtonBig { _loginCenterXConstraint.active = NO; _loginRatioConstraint.active = NO; NSLayoutConstraint * leftConstraint = [NSLayoutConstraint constraintWithItem: _signInButton attribute: NSLayoutAttributeLeading relatedBy: NSLayoutRelationEqual toItem: _signInButton.superview attribute: NSLayoutAttributeLeading multiplier: 1. constant: 25]; _loginLeftConstraint = leftConstraint; leftConstraint.active = YES; NSLayoutConstraint * rightConstraint = [NSLayoutConstraint constraintWithItem: _signInButton attribute: NSLayoutAttributeTrailing relatedBy: NSLayoutRelationEqual toItem: _signInButton.superview attribute: NSLayoutAttributeTrailing multiplier: 1. constant: -25]; _loginRightConstraint = rightConstraint; rightConstraint.active = YES; [UIView animateWithDuration: 0.15 delay: 0 options: UIViewAnimationOptionCurveEaseOut animations: ^{ [self.view layoutIfNeeded]; } completion: nil]; }
增刪約束實現動畫時,你還要記住的是當一個約束的active屬性被設為NO之後,即便我們重新將其激活,這個約束依舊是無效的,必須重新創建。
在上面的代碼中我還添加了兩個屬性loginRatioConstraint和loginCenterXConstraint使其分別指向每次動畫創建的新約束,方便在停止動畫時使約束無效化。當然,除了這種引用的方式,我們還可以直接通過判斷約束雙方對象以及約束的屬性類型來獲取對應的約束並使其無效:
[_signInButton.constraints enumerateObjectsWithOptions: NSEnumerationReverse usingBlock: ^(__kindof NSLayoutConstraint * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { if (obj.firstItem == _signInButton && obj.firstAttribute == NSLayoutAttributeCenterX) { obj.active = NO; } else if (obj.firstAttribute == NSLayoutAttributeWidth && obj.secondAttribute == NSLayoutAttributeHeight) { obj.active = NO; } }];
這段代碼就等同於上面的
_loginCenterXConstraint.active = NO; _loginRatioConstraint.active = NO;
雖然使用代碼移除約束的方式更加復雜,但是在我們封裝控件的時候,總是有可能用到的,所以這也是我們需要掌握的技巧。當然了,這種判斷的方式也確實過於麻煩,NSLayoutConstraint還提供了類型為字符串identifier屬性幫助我們識別約束。在故事板中我們可以通過右側的屬性欄直接看到該屬性並且進行設置:
約束id
這樣上面的判斷代碼就可以簡化成簡單的判斷id:
static NSString * centerXIdentifier = @"centerXConstraint"; static NSString * ratioIdentifier = @"ratioIdentifier"; /// 縮小按鈕 - (void)animateToMakeButtonSmall { ...... //創建寬高比約束 NSLayoutConstraint * ratioConstraint = ...//create ratioConstraint ratioConstraint.identifier = ratioIdentifier; //創建居中約束 NSLayoutConstraint * centerXConstraint = ...//create centerXConstraint centerXConstraint.identifier = centerXIdentifier; ...... } /// 還原按鈕 - (void)animateToMakeButtonBig { ...... [_signInButton.constraints enumerateObjectsWithOptions: NSEnumerationReverse usingBlock: ^(__kindof NSLayoutConstraint * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { if ([obj.identifier isEqualToString: centerXIdentifier]) { obj.active = NO; } else if ([obj.identifier isEqualToString: ratioIdentifier]) { obj.active = NO; } }]; ...... }
尾言
距離約束動畫的開篇也有一段時間了,約束動畫的制作對於我來說很長一段時間以來都是空白,直到這段時間開筆了動畫文章才接觸使用,感觸頗深。同之前的文章一樣,這篇文章意味著我對約束動畫的文章的終結,在本文的demo中我還摻雜了核心動畫的內容,或許這也說明了我對於約束動畫使用的淺薄之處。對於動畫而言,我所了解以及掌握的太少太少,或者說思想力不足以支撐我對於動畫制作的野心。當然,我會繼續努力追求更酷炫易用的動畫。
掌握約束應該是我們iOS開發者的必備本領。一方面,IB可視化編程在我的工作日常中已經是離不開的,它極大的提高了我的開發效率(以往需要動畫的地方我都是純代碼創建控件);另一方面,蘋果手機的尺寸難保不會繼續增加,class size和autolayout都是我們適配不同屏幕的最佳幫手。因此,希望這篇文章能給大家帶來約束使用上的更多內容。
本文demo
文/Sindri的小巢(簡書作者)
原文鏈接:http://www.jianshu.com/p/2a8787919794#