iOS 8 Auto Layout界面自動佈局系列5-自身內容尺寸約束、修改約束、佈局動畫

首先感謝衆多網友的支持,最近我實在是事情太多,所以沒有寫太多。不過看到大家的反饋和評價,我還是要堅持擠出時間給大家分享我的經驗。如果你對我寫的東西有任何建議、意見或者疑問,請到我的CSDN博客留言:

http://blog.csdn.net/pucker

好了,言歸正傳。本系列的前幾篇文章講解了自動佈局的原理,以及如何添加約束。這篇文章主要介紹以下內容:

  • 某些用戶控件具有自身內容尺寸約束
  • 使用視圖調試工具在運行時查看和調試程序界面視圖層次、尺寸和自動佈局約束
  • 創建約束的對象關聯
  • 通過修改約束的常量值、刪除舊約束添加新約束、設置約束激活屬性、設置約束優先級等方式,實現視圖的佈局更新
  • 使用動畫更新界面佈局
  • 設置帶有自身內容控件的抗壓縮與抗拉抻優先級

下面結合一個用戶登錄界面的例子來講解。首先請下載初始項目:

http://yunpan.cn/cQDIbjtf98zzV (提取碼:3d6b)

解壓縮並使用Xcode打開該項目,選擇任意一個iPhone模擬器,編譯項目並運行,如圖所示。

這裏寫圖片描述

一、自身內容尺寸約束

回到Xcode打開Main.storyboard,選中用戶頭像圖片視圖Head Image View,並打開尺寸窗口(Size Inspector,快捷鍵⌥⌘5)查看其佈局約束。

這裏寫圖片描述

可以看到該圖片視圖當前具有2個約束:

  • 水平中心點與其父視圖水平中心點對齊(確定圖片水平位置x)
  • 底部與下方文本控件頂部相隔20點的距離(已知下方文本控件的垂直位置是確定的,因此也就確定了圖片垂直位置y)

等等,這裏貌似有問題。細心的讀者可能會發問了,本系列的第一篇文章明確說過,要確定一個視圖的精確位置,至少需要4個佈局約束(以確定水平位置x、垂直位置y、寬度w和高度h)。可現有的2個約束僅能確定x和y,缺少必要的信息來確定w和h。然而此時Interface Builder並沒有提示缺少約束的錯誤(如果真的缺少約束,則Interface Builder會顯示紅色錯誤圓圈,並提示Missing Constraints),並且程序運行正常且沒有報錯,這是怎麼回事呢?

請注意,某些用來展現內容的用戶控件,例如文本控件UILabel、按鈕UIButton、圖片視圖UIImageView等,它們具有自身內容尺寸(Intrinsic Content Size),此類用戶控件會根據自身內容尺寸添加布局約束。也就是說,如果開發者沒有顯式給出其寬度或者高度約束,則其自動添加的自身內容約束將會起作用。因此看似“缺失”約束,實際上並非如此。

對於UIImageView,其自身內容尺寸就是圖片(1倍圖)的尺寸。打開Images.xcassets,選中head中的1x圖,在屬性窗口(Attribute Inspector)中可以看到其尺寸爲133px*133px。

這裏寫圖片描述

我們不妨使用Xcode提供的界面層次調試工具在運行時動態查看視圖層次、尺寸以及佈局約束等信息。如果當前沒有運行程序,請編譯運行,然後打開調試導航窗口(Debug Navigator),點擊進程查看選項按鈕(Process View Option),選擇界面層次(View UI Hierarchy)以開啓界面層次調試工具。

這裏寫圖片描述

這裏寫圖片描述

這裏寫圖片描述

此時Xcode左側會列出視圖層次、視圖類型(包括系統私有類型)與佈局約束。中間區域顯示視圖的詳細樣式、尺寸、層次等,可以在空白處拖動鼠標以不同視角觀察和調試界面。右側會根據所選內容顯示其不同屬性。

這裏寫圖片描述

選中UIImageView,在右側打開尺寸窗口,在Auto Layout區域可以看到4個黑色的約束,其中兩個就定義了寬度w爲133點,高度h爲133點,並且後面加了(content size)表示此約束是自身內容尺寸約束。視圖調試工具對解決界面自動佈局問題很有幫助,當出現問題卻又不知什麼原因的時候,不妨用該工具調試。

當然,我們也可以使用代碼打印出某個視圖的自動佈局約束,這也是常用的調試手段。在Main.storyboard中選中Head Image View並在屬性窗口中設置其Tag爲99,然後在ViewController.m中添加viewDidAppear:方法:

- (void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];

    UIView* headImageView = [self.view viewWithTag:99];

    for (NSLayoutConstraint* eachCon in headImageView.constraints)
    {
        NSLog(@"\n%@\nPriority:%f", eachCon, eachCon.priority);
    }
}

運行後的輸出爲:

<NSContentSizeLayoutConstraint:0x7aeda9e0 H:[head(133)] Hug:251 CompressionResistance:750   (Names: head:0x7af84130 )>
Priority:1000.000000
<NSContentSizeLayoutConstraint:0x7aedab30 V:[head(133)] Hug:251 CompressionResistance:750   (Names: head:0x7af84130 )>
Priority:1000.000000

可以看到打印的每條約束都使用VFL語言進行描述。至於什麼是Hug和CompressionResistance,在後面會講到抗擠壓與抗拉抻效果。另外我們還打印出了約束的優先級,在後面也會講解優先級的作用。

(請思考,可否將上面的代碼不放在viewDidAppear:方法中,而是放在viewDidLoad方法中執行?爲什麼?)

如果開發者顯式給出了寬度和高度約束,則默認情況下,以顯式約束爲準。選中Head Image View並添加寬度120點、高度120點的約束,重新編譯運行程序,則視圖調試工具顯示其佈局約束爲:

這裏寫圖片描述

其中的自身內容尺寸約束爲灰色,表示不起作用。同時控制檯輸出爲:

<NSLayoutConstraint:0x7c189ac0 H:[head(120)]   (Names: head:0x7c1897a0 )>
Priority:1000.000000
<NSLayoutConstraint:0x7c189af0 V:[head(120)]   (Names: head:0x7c1897a0 )>
Priority:1000.000000
<NSContentSizeLayoutConstraint:0x7bea62a0 H:[head(133)] Hug:251 CompressionResistance:750   (Names: head:0x7c1897a0 )>
Priority:1000.000000
<NSContentSizeLayoutConstraint:0x7bea63f0 V:[head(133)] Hug:251 CompressionResistance:750   (Names: head:0x7c1897a0 )>
Priority:1000.000000

二、創建約束的對象關聯並修改約束

我們這個用戶登錄的app有一個不太好的用戶體驗,那就是在輸入用戶名和密碼時,鍵盤會遮擋住文本輸入框和登錄按鈕:

這裏寫圖片描述

我們需要在鍵盤彈出或者收回時更新界面佈局,主要有以下幾種方式來更新界面佈局:

  • 修改約束的常量值
  • 設置約束激活屬性(刪除舊約束並添加新約束)
  • 調整約束的優先級

當只需要平移視圖的位置就能解決問題時,可以使用第一種方法直接修改某一約束的常量值。這種方式最簡單最高效,但是不能解決所有問題,這時可以使用後兩種方式。

1. 修改約束常量值

對於這個App來說,所有控件的垂直位置都是基於位於中央的文本控件的垂直位置而定。我們打算在鍵盤未彈出時,文本控件頂部距離Top Layout Guide的垂直間距爲250(label.top = 250);在鍵盤彈出時,將該間距縮小爲0(label.top = 0)。

Interface Builder不僅允許我們創建視圖對象的IBOutlet對象關聯,還可以創建約束對象的對象關聯,這樣就能通過代碼來訪問並修改某個約束。

回到Xcode打開Main.storyboard,選中文本控件User Name and Pwd Label,在右側的尺寸窗口中單擊頂部約束藍線,並雙擊下方的Top Space to: Top Layout Guide約束:

這裏寫圖片描述

此時左側的項目窗口會高亮選中該約束。切換到助手編輯器,確認右側窗口中打開的是ViewController.m,然後選中該約束並按住⌃鍵拖拽到右側ViewController類的類擴展區域,在彈出窗口中將其命名爲userNamePwdLabelTopCons,點擊Connect按鈕就創建了約束對象的對象關聯,其步驟類似於創建視圖的對象關聯。

這裏寫圖片描述

接下來ViewController類需要響應鍵盤彈出和收回事件,向ViewController類的viewDidLoad方法中添加如下代碼:

    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillShow:) name:UIKeyboardWillShowNotification object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillHide:) name:UIKeyboardWillHideNotification object:nil];

UIKeyboardWillShowNotification與UIKeyboardWillHideNotification這兩個通知消息會在鍵盤即將彈出以及鍵盤即將收回時拋出,我們可以在keyboardWillShow:和keyboardWillHide:這兩個方法中修改userNamePwdLabelTopCons約束。

注意,對於約束的如下幾個重要屬性:

/* accessors
 firstItem.firstAttribute {==,<=,>=} secondItem.secondAttribute * multiplier + constant
 */
@property (readonly, assign) id firstItem;
@property (readonly) NSLayoutAttribute firstAttribute;
@property (readonly) NSLayoutRelation relation;
@property (readonly, assign) id secondItem;
@property (readonly) NSLayoutAttribute secondAttribute;
@property (readonly) CGFloat multiplier;

/* Unlike the other properties, the constant may be modified after constraint creation.  Setting the constant on an existing constraint performs much better than removing the constraint and adding a new one that's just like the old but for having a new constant.
 */
@property CGFloat constant;

當使用代碼來修改約束時,只能修改約束的常量值constant。一旦創建了約束,其他只讀屬性都是無法修改的,特別要注意的是比例係數multiplier也是隻讀的。

然後向ViewController類添加如下代碼:

- (void)keyboardWillShow:(NSNotification *)notification
{
    //在鍵盤彈出時,文本控件頂部距離Top Layout Guide的垂直間距爲0
    self.userNamePwdLabelTopCons.constant = 0.0f;
}

- (void)keyboardWillHide:(NSNotification *)notification
{
    //鍵盤未彈出時,文本控件頂部距離Top Layout Guide的垂直間距爲250
    self.userNamePwdLabelTopCons.constant = 250.0f;
}

- (void)dealloc
{
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}

別忘記在dealloc方法中移除鍵盤事件監聽。編譯運行程序,點擊文本輸入框,這一次鍵盤彈出後由於文本控件上移,所有界面控件的位置都上移了,就不會被鍵盤擋住了。

這裏寫圖片描述

由於ViewController類重寫了觸屏方法,並取消了文本輸入框的第一響應者狀態,因此此時點擊文本輸入框之外的區域就會收起鍵盤,這樣就會恢復到原始佈局狀態。

#pragma mark - Touch event Handler
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    [super touchesBegan:touches withEvent:event];

    [self.userNameTextField resignFirstResponder];
    [self.userPwdTextField resignFirstResponder];
}

2. 修改約束激活屬性,或者刪除舊約束並添加新約束

現在我們打算這樣佈局界面:在鍵盤未彈出時,文本控件垂直中心與其父視圖垂直中心相同(label.centerY = superView.centerY);在鍵盤彈出時,文本控件垂直中心是其父視圖垂直中心的0.6倍(label.centerY = 0.6 * superView.centerY)。

對於剛纔的例子,我們可以通過修改某個約束的常量值來解決問題。但是這次不一樣了,比例係數是隻讀的,在約束創建之後就不可以修改。所以對於這種情況,我們就不能對某個約束進行修改,而是需要把不需要的約束去掉,然後添加一個新的約束。

在Main.storyboard中,在左側視圖層次窗口中選中文本控件距離頂部Top Layout Guide的約束Vertical Space - (250) - User Name and Pwd Label - Top Layout Guide,然後按下Delete鍵刪除該約束。

這裏寫圖片描述

然後選中文本控件User Name and Pwd Label,點擊Align菜單,勾選Vertical Center in Container並取值爲0,點擊Add 1 Constraint按鈕。這樣就使得文本控件垂直居中。

這裏寫圖片描述

重複上圖中的步驟,再次創建一個文本控件垂直居中的約束。選中文本控件User Name and Pwd Label,在右側尺寸窗口中單擊垂直中心約束藍線,下方會列出剛纔我們創建的兩個垂直居中約束。

這裏寫圖片描述

雙擊上方的Align Center Y to: Superview約束,確保First Item爲User Name and Pwd Label.Center Y,Second Item爲SuperView.Center Y。如果不是,則點擊First Item或者Second Item下拉菜單,選中Reverse First And Second Item,對調First Item與Second Item(本系列第二篇文章介紹過的相對關係與反函數)。然後在右側尺寸窗口中將Multiplier的值由1改爲0.6:

這裏寫圖片描述

改完之後Interface Builder會出現錯誤提示,因爲我們剛剛添加的這兩個約束是彼此衝突的(label.centerY = superView.centerY && label.centerY = 0.6 * superView.centerY,這不可能同時滿足)。

這裏寫圖片描述

點擊視圖層次窗口上方的紅色箭頭,Interface Builder會列出上述兩個彼此衝突的約束。選中某個約束,右側尺寸窗口會列出該約束的詳細信息。我們選中Multiplier爲0.6的那個約束,然後在右側尺寸窗口下方取消勾選Installed選框。

這裏寫圖片描述

Installed選框的值就對應約束對象的active屬性的值,即表示該約束是否爲激活狀態,勾選表示激活狀態(生效狀態,active屬性爲YES),不勾選表示未激活狀態(無效狀態,active屬性爲NO)。現在Multiplier爲0.6的那個約束不再生效,因此就不存在約束衝突了。

然後按照上文中介紹的方法,添加上面兩個約束的對象關聯,Multiplier爲1的約束命名爲labelCenterYNormalCons,Multiplier爲0.6的約束命名爲labelCenterYKeyboardCons,且Storage設置爲Strong:

這裏寫圖片描述

這是由於需要向視圖動態添加或者移除約束,因此需要確保使用強引用確保約束對象不會被回收。

然後修改keyboardWillShow:與keyboardWillHide:方法:

- (void)keyboardWillShow:(NSNotification *)notification
{
    self.labelCenterYNormalCons.active = NO;
    self.labelCenterYKeyboardCons.active = YES;
}

- (void)keyboardWillHide:(NSNotification *)notification
{
    self.labelCenterYKeyboardCons.active = NO;
    self.labelCenterYNormalCons.active = YES;
}

注意,儘量先設置需要將active置爲NO的約束,然後再設置需要將active置爲YES的約束,如果顛倒上面兩條語句的話,可能會引起運行時約束錯誤。另外由於active屬性是iOS 8 SDK新添加的屬性,對於iOS 6與iOS 7來說,需要調用addConstraint:與removeConstraint:方法。編譯運行如圖:

這裏寫圖片描述

3. 調整不同約束的優先級

剛纔的例子是通過調整不同約束的active屬性(刪舊添新)來實現界面佈局調整。另外還有一種方式也很重要,就是下面說的調整不同約束的優先級。

每個約束都會具有優先級(Priority),對應NSLayoutConstraint對象的priority屬性:

@interface NSLayoutConstraint : NSObject
......
/* If a constraint's priority level is less than UILayoutPriorityRequired, then it is optional.  Higher priority constraints are met before lower priority constraints.
 Constraint satisfaction is not all or nothing.  If a constraint 'a == b' is optional, that means we will attempt to minimize 'abs(a-b)'.
 This property may only be modified as part of initial set up.  An exception will be thrown if it is set after a constraint has been added to a view.
 */
@property UILayoutPriority priority;
......
@end

優先級是一個浮點值,取值範圍從1(最低)到1000(最高)。一些常用的優先級值被定義了別名:

typedef float UILayoutPriority;
static const UILayoutPriority UILayoutPriorityRequired NS_AVAILABLE_IOS(6_0) = 1000; // A required constraint.  Do not exceed this.
static const UILayoutPriority UILayoutPriorityDefaultHigh NS_AVAILABLE_IOS(6_0) = 750; // This is the priority level with which a button resists compressing its content.
static const UILayoutPriority UILayoutPriorityDefaultLow NS_AVAILABLE_IOS(6_0) = 250; // This is the priority level at which a button hugs its contents horizontally.
static const UILayoutPriority UILayoutPriorityFittingSizeLevel NS_AVAILABLE_IOS(6_0) = 50;

具有優先級1000(UILayoutPriorityRequired)的約束爲強制約束(Required Constraint),也就是必須要滿足的約束;優先級小於1000的約束爲可選約束(Optional Constraint)。默認創建的是強制約束。

在使用自動佈局後,某個視圖的具體位置和尺寸可能由多個約束來共同決定。這些約束會按照優先級從高到低的順序來對視圖進行佈局,也就是視圖會優先滿足優先級高的約束,然後滿足優先級低的約束。

對於上面的例子,我們曾經創建了兩個相互衝突的約束,即label.centerY = superView.centerY && label.centerY = 0.6 * superView.centerY。之所以出現衝突,是因爲這兩者的優先級相同,都是1000。但是如果將其中一個的優先級降低,那麼就不會存在衝突,因爲優先級高的那個約束會優先起作用。

打開Main.storyboard,將Multiplier爲0.6的約束的Installed選框勾上,此時再次出現佈局衝突。接着在右側尺寸窗口中將其Priority設置爲250,此時佈局衝突消失,同時注意到界面中代表該約束的藍線變爲虛線,表示這是一個優先級較低的可選約束。

這裏寫圖片描述

以同樣的方式,設置另外的Multiplier爲1的垂直居中約束的Priority爲750。

然後將keyboardWillShow:與keyboardWillHide:方法修改如下:

- (void)keyboardWillShow:(NSNotification *)notification
{
    self.labelCenterYNormalCons.priority = UILayoutPriorityDefaultLow;
    self.labelCenterYKeyboardCons.priority = UILayoutPriorityDefaultHigh;
}

- (void)keyboardWillHide:(NSNotification *)notification
{
    self.labelCenterYKeyboardCons.priority = UILayoutPriorityDefaultLow;
    self.labelCenterYNormalCons.priority = UILayoutPriorityDefaultHigh;
}

重新編譯運行,效果同上。

需要注意的是,只能修改可選約束的優先級,也就是說:

  • 不允許將優先級由小於1000的值改爲1000
  • 不允許將優先級由1000修改爲小於1000的值

例如,如果將優先級由250修改爲1000,則會拋出異常:

*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Mutating a priority from required to not on an installed constraint (or vice-versa) is not supported.  You passed priority 1000 and the existing priority was 250.'

這就是爲什麼在storyboard中要先將兩者的約束分別設置爲750和250的原因。

4. 使用動畫更新界面佈局

由於修改的約束會立即生效,因此當鍵盤彈出或者收回時,控件位置的變化顯得非常生硬。我們不妨使用動畫來更新界面佈局,方法是調用UIView的靜態動畫方法,在動畫塊代碼體中向需要更新約束的視圖對象調用layoutIfNeeded方法即可。分別向keyboardWillShow:和keyboardWillHide:方法的最後插入如下代碼:

    [UIView animateWithDuration:0.25f animations:^
    {
        [self.view layoutIfNeeded];
    }];

重新編譯運行,由於使用了動畫來重新對界面佈局,變化的過程就顯得非常自然了。

三、自身內容尺寸約束的抗擠壓與抗拉抻效果

前面講了,某些控件具有自身內容尺寸約束,也就是根據自身內容的大小添加必要的約束。我們不妨將這類控件看作是一個彈簧。

彈簧的原始狀態

彈簧會有自身固有長度,當有外力作用時,彈簧會抵抗外力作用,儘量接近固有長度。

  • 抗拉抻:當外力拉長彈簧時,彈簧長度大於固有長度,且產生向內收的力阻止外力拉抻,且儘量維持長度接近自身固有長度。
    彈簧的拉抻狀態
  • 抗擠壓:當外力擠壓彈簧時,彈簧長度小於固有長度,且產生向外頂的力阻止外力擠壓,且儘量維持長度接近自身固有長度。
    彈簧的壓縮狀態

ViewController類的viewDidAppear:方法打印出了頭像圖片視圖的所有約束:

<NSLayoutConstraint:0x7c189ac0 H:[head(120)]   (Names: head:0x7c1897a0 )>
Priority:1000.000000
<NSLayoutConstraint:0x7c189af0 V:[head(120)]   (Names: head:0x7c1897a0 )>
Priority:1000.000000
<NSContentSizeLayoutConstraint:0x7bea62a0 H:[head(133)] Hug:251 CompressionResistance:750   (Names: head:0x7c1897a0 )>
Priority:1000.000000
<NSContentSizeLayoutConstraint:0x7bea63f0 V:[head(133)] Hug:251 CompressionResistance:750   (Names: head:0x7c1897a0 )>
Priority:1000.000000

對於自身內容尺寸約束,Hug值表示抗拉抻優先級,CompressionResistance值表示抗壓縮優先級。Hug值越高越難被拉抻,CompressionResistance值越高越難被壓縮。

由於自身內容是運行時動態變化的,我們可以通過這兩個優先級來決定控件是否允許在某些條件下被壓縮、拉抻。對於上面的輸出結果,圖片本身大小133*133,抗壓優先級CompressionResistance爲750,顯式寬度約束爲120優先級爲1000。由於顯示寬度優先級大於抗壓優先級,所以最終圖片寬度爲120。但是,當我們降低顯式寬度約束的優先級,令其小於抗壓優先級時,以自身寬度133爲主。在Main.storyboard中選中用戶頭像,雙擊圖片下方的顯式寬度約束,將其優先級Priority設置爲500。

這裏寫圖片描述

這裏寫圖片描述

注意到上圖中紅圈部分,頭像寬度變爲133,高度維持120。這說明當顯式約束優先級高於抗壓抗拉優先級時,以顯式約束爲準;當顯式約束優先級小於抗壓抗拉優先級時,以自身內容約束爲準。

再舉一個例子,當我們輸入用戶名和密碼,然後點擊程序的登錄按鈕後,下方的兩個文本控件會顯示出輸入的用戶名和密碼:

這裏寫圖片描述

兩個文本控件不超過父視圖的兩邊,且兩者間具有水平間距互不覆蓋。當輸入的用戶名和密碼比較短時,兩者都能完整顯示。但是當內容較長時,我們發現左側文本控件被截斷了。如果我們希望保持左側文本框完整,必要時截斷右側文本框,則可以令左側文本框的抗壓優先級高於右側文本框抗壓優先級。可以在IB中直接設置抗壓抗拉優先級。在Main.storyboard的左側視圖結構窗口中,選中左側文本控件User Name Label,在右側尺寸窗口的Content Compression Resistance Priority部分,將Horizontal的值改爲751。重新編譯運行,輸入用戶名和密碼,現在左側文本控件完整,右側文本控件被截斷。

這裏寫圖片描述

這裏寫圖片描述

這是由於左側抗壓優先級高於右側抗壓優先級的緣故。

當然我們也可以使用代碼來設置水平和垂直抗壓抗拉優先級,方法是調用UIView的如下幾個方法:

- (UILayoutPriority)contentCompressionResistancePriorityForAxis:(UILayoutConstraintAxis)axis;

- (void)setContentCompressionResistancePriority:(UILayoutPriority)priority forAxis:(UILayoutConstraintAxis)axis;

- (UILayoutPriority)contentHuggingPriorityForAxis:(UILayoutConstraintAxis)axis;

- (void)setContentHuggingPriority:(UILayoutPriority)priority forAxis:(UILayoutConstraintAxis)axis;

在此就不舉例贅述了。

四、總結

本篇文章講解的內容比較繁雜,程序最終的代碼如下。

http://yunpan.cn/cQ4snekTsfyMC (提取碼:57f6)

如果你有關於Autolayout的任何問題,請在我的CSDN博客留言。

在下一篇文章中,我想講一講iOS 8中關於設備適配、Adaptive Layout與Size Class的使用,敬請期待。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章