iOS自動佈局——Masonry詳解

騰訊雲加社群發表於2018-10-30

歡迎大家前往騰訊雲+社群,獲取更多騰訊海量技術實踐乾貨哦~

本文由鵝廠新鮮事兒發表於雲+社群專欄

作者:oceanlong | 騰訊 移動客戶端開發工程師

前言

UI佈局是整個前端體系裡不可或缺的一環。程式碼的佈局是設計語言與使用者視覺感受溝通的橋樑,不論它看起來多麼簡單或是瑣碎,但不得不承認,絕大部分軟體開發的問題,都是介面問題。那麼,如何高效的完成UI開發,也是軟體行業一直在克服的問題。

img

所以,軟體介面開發的核心點即是:如何減少UI設計稿的建模難度和減少建模轉化到程式碼的實現難度

最初iOS提供了平面直角座標系的方式,來解決佈局問題,即所謂的手動佈局。平面直角座標系確實是一套完備在理論,這在數學上已經驗證過了,只要我們的螢幕還是平面,它就肯定是有效的。但有效不一定高效,我們在日常的生活中,很少會用平面直角座標系來向人描述位置關係。更多的是依靠相對位置。

所幸,iOS為我們提供自動佈局的方法,來解決這一困境。

img

自動佈局的基本理念

其實說到本質,它和手動佈局是一樣的。對一個控制元件放在哪裡,我們依然只關心它的(x, y, width, height)。但手動佈局的方式是,一次性計算出這四個值,然後設定進去,完成佈局。但當父控制元件或螢幕發生變化時,子控制元件的計算就要重新來過,非常麻煩。

因此,在自動佈局中,我們不再關心(x, y, width, height)的具體值,我們只關心(x, y, width, height)四個量對應的約束。

約束

那麼何為約束呢?

obj1.property1 =(obj2.property2 * multiplier)+ constant value
複製程式碼

子控制元件的某一個量一定與另一個控制元件的某一個量呈線性關係,這就是約束。

那麼,給(x, y, width, height)四個量,分別給一個約束,就可以確定一個控制元件的最終位置。

    //建立左邊約束
    NSLayoutConstraint *leftLc = [NSLayoutConstraint constraintWithItem:blueView attribute:NSLayoutAttributeLeft relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeLeft multiplier:1.0 constant:20];
    [self.view addConstraint:leftLc];
複製程式碼

這一段程式碼即是:控制元件(blueView)的 x = rootView的x * 1.0 + 20這裡一定要注意,這樣的一條約束,涉及了子控制元件和父控制元件,所以這條約束一定要新增到父控制元件中。

新增約束的規則:

  • 如果兩個控制元件是父子控制元件,則新增到父控制元件中。
  • 如果兩個控制元件不是父子控制元件,則新增到層級最近的共同父控制元件中。

示例

    //關閉Autoresizing
    blueView.translatesAutoresizingMaskIntoConstraints = NO;
    
    //建立左邊約束
    NSLayoutConstraint *leftLc = [NSLayoutConstraint constraintWithItem:blueView attribute:NSLayoutAttributeLeft relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeLeft multiplier:1.0 constant:20];
    [self.view addConstraint:leftLc];
    
    //建立右邊約束
    NSLayoutConstraint *rightLc = [NSLayoutConstraint constraintWithItem:blueView attribute:NSLayoutAttributeRight relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeRight multiplier:1.0 constant:-20];
    [self.view addConstraint:rightLc];
    
    //建立底部約束
    NSLayoutConstraint *bottomLc = [NSLayoutConstraint constraintWithItem:blueView attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeBottom multiplier:1.0 constant:-20];
    [self.view addConstraint:bottomLc];
    
    //建立高度約束
    NSLayoutConstraint *heightLc = [NSLayoutConstraint constraintWithItem:blueView attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:0.0 constant:50];
    [blueView addConstraint: heightLc];
複製程式碼

img

我們注意到,自動佈局其實工作分兩步:

  1. 建立檢視的約束
  2. 將約束新增到合適的位置約束關係從上面的描述中,已經非常清晰了。那麼如何尋找約束新增的合適位置呢?

img


到這裡,我們只是解決了如何減少UI設計稿的建模難度的問題,顯然,減少建模轉化到程式碼的實現難度這個效果沒能達成。關於如何解決減少建模轉化到程式碼的實現難度的問題,

開源庫

上面的程式碼,我們可以看到,雖然自動佈局已經比手動佈局優雅不少了,但它依然行數較多。每條約束大約都需要三行程式碼,面對複雜的頁面,這樣開發出來,會很難閱讀。

Masonry則為我們解決了這個問題。

Masonry地址

引入Masonry

我們選擇使用Cocoapods的方式。引入比較簡單:

  1. 我們先在工程目錄下,建立Podfile檔案:

img

2.編輯Podfile

img

其中,'IosOcDemo'就是我們工程的名字,根據需要,我們自行替換。

3.新增依賴

完成後,執行指令pod install。CocoaPods就會為我們自動下載並新增依賴。

實踐

img

這樣的一個程式碼,用手動佈局,我們大致的程式碼應該是這樣:

-(void)initBottomView
{
    self.bottomBarView = [[UIView alloc]initWithFrame:CGRectZero];
    self.bottomButtons = [[NSMutableArray alloc]init];
    _bottomBarView.backgroundColor = [UIColor yellowColor];
    [self addSubview:_bottomBarView];
    for(int i = 0 ; i < 3 ; i++)
    {
        UIButton *button = [[UIButton alloc]initWithFrame:CGRectZero];
        button.backgroundColor = [UIColor redColor];
        [_bottomButtons addObject:button];
       [self addSubview:button];
    }
}


-(void)layoutBottomView
{
    _bottomBarView.frame = CGRectMake(20, _viewHeight - 200, _viewWidth - 40, 200);
    for (int i = 0 ; i < 3; i++) {
        UIButton *button = _bottomButtons[i];
        CGFloat x = i * (_viewWidth - 40 - 20 * 4) / 3 + 20*(i+1) + 20;
        CGFloat y = _viewHeight - 200;
        CGFloat width = (_viewWidth - 40 - 20 * 4) / 3;
        CGFloat height = 200;
        button.frame = CGRectMake(x, y, width, height);
        
    }
}
複製程式碼

我們來看一下,在Masonry的幫助下,我們可以把剛剛的程式碼寫成什麼樣的:

   -(void)initBottomView
{
        _bottomBarView = [[UIView alloc]initWithFrame:CGRectZero];
        _bottomBarView.backgroundColor = [UIColor yellowColor];
        _bottomBarView.translatesAutoresizingMaskIntoConstraints = NO;
        [self addSubview:_bottomBarView];
        [_bottomBarView mas_makeConstraints:^(MASConstraintMaker *make) {
            make.left.equalTo(self).with.offset(20);
            make.right.equalTo(self).with.offset(-20);
            make.height.mas_equalTo(200);
            make.bottom.equalTo(self);
        }];
    
    _bottomButtons = [[NSMutableArray alloc]init];
    for(int i = 0 ; i < 3 ; i++)
    {
        UIButton *button = [[UIButton alloc]initWithFrame: CGRectZero];
        button.backgroundColor = [UIColor redColor];
        button.translatesAutoresizingMaskIntoConstraints = NO;
        [_bottomButtons addObject:button];
        [_bottomBarView addSubview:button];
        [button mas_makeConstraints:^(MASConstraintMaker *make) {
            if (i == 0) {
                make.left.mas_equalTo(20);
            }else{
                UIButton *previousButton = _bottomButtons[i-1];
                make.left.equalTo(previousButton.mas_right).with.offset(20);
            }
            make.top.mas_equalTo(_bottomBarView.mas_top);
            make.width.equalTo(_bottomBarView.mas_width).with.multipliedBy(1.0f/3).offset(-20*4/3);
            make.height.equalTo(_bottomBarView.mas_height);
        }];
        
    }
}
複製程式碼

我們可以看到在Masonry的封裝下,程式碼變得非常簡練易讀,需要行數略有增加,但是計算過程減少了,我們能更加關注於多個UIView間的位置關係,這與當前的UI設計語言是契合的。所以Masonry能否讓我們更直觀地表達UI。

原始碼解讀

Masonry的封裝很有魅力,那麼,我們可以簡單地來看一下,它是如何封裝的。我們再仔細看一下Masonry的API會發現,我們是直接在UIView上進行呼叫的。也就是說,Masonry對UIView進行了擴充套件。

View+MASUtilities.h中:

#if TARGET_OS_IPHONE || TARGET_OS_TV

    #import <UIKit/UIKit.h>
    #define MAS_VIEW UIView
    #define MAS_VIEW_CONTROLLER UIViewController
    #define MASEdgeInsets UIEdgeInsets

複製程式碼

然後在View+MASAdditions.h中,我們看到了Masonry的擴充套件:

#import "MASUtilities.h"
#import "MASConstraintMaker.h"
#import "MASViewAttribute.h"

/**
 *  Provides constraint maker block
 *  and convience methods for creating MASViewAttribute which are view + NSLayoutAttribute pairs
 */
@interface MAS_VIEW (MASAdditions)

/**
 *  following properties return a new MASViewAttribute with current view and appropriate NSLayoutAttribute
 */
@property (nonatomic, strong, readonly) MASViewAttribute *mas_left;
@property (nonatomic, strong, readonly) MASViewAttribute *mas_top;
@property (nonatomic, strong, readonly) MASViewAttribute *mas_right;
@property (nonatomic, strong, readonly) MASViewAttribute *mas_bottom;
@property (nonatomic, strong, readonly) MASViewAttribute *mas_leading;
@property (nonatomic, strong, readonly) MASViewAttribute *mas_trailing;
@property (nonatomic, strong, readonly) MASViewAttribute *mas_width;
@property (nonatomic, strong, readonly) MASViewAttribute *mas_height;
@property (nonatomic, strong, readonly) MASViewAttribute *mas_centerX;
@property (nonatomic, strong, readonly) MASViewAttribute *mas_centerY;
@property (nonatomic, strong, readonly) MASViewAttribute *mas_baseline;
@property (nonatomic, strong, readonly) MASViewAttribute *(^mas_attribute)(NSLayoutAttribute attr);

...

/**
 *  Creates a MASConstraintMaker with the callee view.
 *  Any constraints defined are added to the view or the appropriate superview once the block has finished executing
 *
 *  @param block scope within which you can build up the constraints which you wish to apply to the view.
 *
 *  @return Array of created MASConstraints
 */
- (NSArray *)mas_makeConstraints:(void(NS_NOESCAPE ^)(MASConstraintMaker *make))block;
複製程式碼

一些,適配的程式碼,我省略了,先看核心程式碼。在剛剛的例子中,我們正是呼叫的mas_makeConstraints方法。

- (NSArray *)mas_makeConstraints:(void(^)(MASConstraintMaker *))block {
    self.translatesAutoresizingMaskIntoConstraints = NO;
    MASConstraintMaker *constraintMaker = [[MASConstraintMaker alloc] initWithView:self];
    block(constraintMaker);
    return [constraintMaker install];
}
複製程式碼

mas_makeConstraints方法比較簡單,只是封裝了MASConstraintMaker初始化,設定約束和安裝。這裡的block就是我們剛剛在外層設定的約束的函式指標。也就是這一串:

^(MASConstraintMaker *make) {
        make.left.equalTo(self.view).with.offset(10);
        make.right.equalTo(self.view).with.offset(-10);
        make.height.mas_equalTo(50);
        make.bottom.equalTo(self.view).with.offset(-10);
    }
複製程式碼

由於約束條件的設定比較複雜,我們先來看看初始化和安裝。

初始化

- (id)initWithView:(MAS_VIEW *)view {
    self = [super init];
    if (!self) return nil;
    
    self.view = view;
    self.constraints = NSMutableArray.new;
    
    return self;
}
複製程式碼

初始化的程式碼比較簡單,將傳入的view放入MASConstraintMaker成員,然後建立MASConstraintMaker的約束容器(NSMutableArray)。

安裝

- (NSArray *)install {
    if (self.removeExisting) {
        NSArray *installedConstraints = [MASViewConstraint installedConstraintsForView:self.view];
        for (MASConstraint *constraint in installedConstraints) {
            [constraint uninstall];
        }
    }
    NSArray *constraints = self.constraints.copy;
    for (MASConstraint *constraint in constraints) {
        constraint.updateExisting = self.updateExisting;
        [constraint install];
    }
    [self.constraints removeAllObjects];
    return constraints;
}
複製程式碼

安裝的程式碼分為三塊:

  1. 判斷是否需要移除已有的約束。如果需要,會遍歷已有約束,然後逐個uninstall
  2. copy已有的約束,遍歷,並逐一install
  3. remove掉所有約束,並將已新增的constraints返回。

install的方法,還是繼續封裝到了Constraint中,我們繼續跟進閱讀:

我們會發現Constraint只是一個介面,Masonry中對於Constraint介面有兩個實現,分別是:MASViewConstraintMASCompositeConstraint。這兩個類,分別是單個約束和約束集合。在上面的例子中,我們只是對單個UIView進行約束,所以我們先看MASViewConstraint的程式碼。以下程式碼MASViewConstraint進行了一定程度的簡化,省略了一些擴充套件屬性,只展示我們的例子中,會執行的程式碼:

- (void)install {
    if (self.hasBeenInstalled) {
        return;
    }
    ...
    
    MAS_VIEW *firstLayoutItem = self.firstViewAttribute.item;
    NSLayoutAttribute firstLayoutAttribute = self.firstViewAttribute.layoutAttribute;
    MAS_VIEW *secondLayoutItem = self.secondViewAttribute.item;
    NSLayoutAttribute secondLayoutAttribute = self.secondViewAttribute.layoutAttribute;

    // alignment attributes must have a secondViewAttribute
    // therefore we assume that is refering to superview
    // eg make.left.equalTo(@10)
    if (!self.firstViewAttribute.isSizeAttribute && !self.secondViewAttribute) {
        secondLayoutItem = self.firstViewAttribute.view.superview;
        secondLayoutAttribute = firstLayoutAttribute;
    }
    
    MASLayoutConstraint *layoutConstraint
        = [MASLayoutConstraint constraintWithItem:firstLayoutItem
                                        attribute:firstLayoutAttribute
                                        relatedBy:self.layoutRelation
                                           toItem:secondLayoutItem
                                        attribute:secondLayoutAttribute
                                       multiplier:self.layoutMultiplier
                                         constant:self.layoutConstant];
    
    layoutConstraint.priority = self.layoutPriority;
    layoutConstraint.mas_key = self.mas_key;
    
   if (self.secondViewAttribute.view) {
        MAS_VIEW *closestCommonSuperview = [self.firstViewAttribute.view mas_closestCommonSuperview:self.secondViewAttribute.view];
        NSAssert(closestCommonSuperview,
                 @"couldn't find a common superview for %@ and %@",
                 self.firstViewAttribute.view, self.secondViewAttribute.view);
        self.installedView = closestCommonSuperview;
    } else if (self.firstViewAttribute.isSizeAttribute) {
        self.installedView = self.firstViewAttribute.view;
    } else {
        self.installedView = self.firstViewAttribute.view.superview;
    }


    MASLayoutConstraint *existingConstraint = nil;
    ...
    else {
        [self.installedView addConstraint:layoutConstraint];
        self.layoutConstraint = layoutConstraint;
        [firstLayoutItem.mas_installedConstraints addObject:self];
    }
}
複製程式碼

自動佈局是一種相對佈局,所以,絕大部分情況下,需要兩個UIView(約束方與參照方)。在上面的方法中:

  • firstLayoutItem是約束方,secondLayoutItem是參照方
  • firstLayoutAttribute是約束方的屬性,secondLayoutAttribute是參照方的屬性。
  • MASLayoutConstraint就是NSLayoutConstraint的子類,只是新增了mas_key屬性。到這裡,我們就與系統提供的API對應上了。
    NSLayoutConstraint *leftLc = [NSLayoutConstraint constraintWithItem:blueView attribute:NSLayoutAttributeLeft relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeLeft multiplier:1.0 constant:20];
    [self.view addConstraint:leftLc];
複製程式碼

再看看我們之前用系統API完成的例子,是不是格外熟悉?

那麼接下來,我們就是要閱讀

            make.left.equalTo(self).with.offset(20);
            make.right.equalTo(self).with.offset(-20);
            make.height.mas_equalTo(200);
            make.bottom.equalTo(self);
複製程式碼

是如何變成firstLayoutItem, secondLayoutItem, firstLayoutAttribute, secondLayoutAttributelayoutRelation的。

約束條件的設定

回到前面的:

- (NSArray *)mas_makeConstraints:(void(^)(MASConstraintMaker *))block {
    self.translatesAutoresizingMaskIntoConstraints = NO;
    MASConstraintMaker *constraintMaker = [[MASConstraintMaker alloc] initWithView:self];
    block(constraintMaker);
    return [constraintMaker install];
}
複製程式碼

我們接下來,就要看block的實現:

block其實是一個函式指標。此處真正呼叫的方法是:

            make.left.equalTo(self).with.offset(20);
            make.right.equalTo(self).with.offset(-20);
            make.height.mas_equalTo(200);
            make.bottom.equalTo(self);
複製程式碼

我們挑選其中一個,來看看原始碼實現:

left
- (MASConstraint *)addConstraintWithLayoutAttribute:(NSLayoutAttribute)layoutAttribute {
    return [self constraint:nil addConstraintWithLayoutAttribute:layoutAttribute];
}

- (MASConstraint *)left {
    return [self addConstraintWithLayoutAttribute:NSLayoutAttributeLeft];
}

- (MASConstraint *)constraint:(MASConstraint *)constraint addConstraintWithLayoutAttribute:(NSLayoutAttribute)layoutAttribute {
    MASViewAttribute *viewAttribute = [[MASViewAttribute alloc] initWithView:self.view layoutAttribute:layoutAttribute];
    MASViewConstraint *newConstraint = [[MASViewConstraint alloc] initWithFirstViewAttribute:viewAttribute];
    if ([constraint isKindOfClass:MASViewConstraint.class]) {
        //replace with composite constraint
        NSArray *children = @[constraint, newConstraint];
        MASCompositeConstraint *compositeConstraint = [[MASCompositeConstraint alloc] initWithChildren:children];
        compositeConstraint.delegate = self;
        [self constraint:constraint shouldBeReplacedWithConstraint:compositeConstraint];
        return compositeConstraint;
    }
    if (!constraint) {
        newConstraint.delegate = self;
        [self.constraints addObject:newConstraint];
    }
    return newConstraint;
}
複製程式碼

在對單個view新增約束時,constraint為nil。我們直接生成了一個新約束newConstraint。它的firstViewAttribute就是我們傳入的NSLayoutAttributeLeft

equalTo

- (MASConstraint * (^)(id))equalTo {
    return ^id(id attribute) {
        return self.equalToWithRelation(attribute, NSLayoutRelationEqual);
    };
}

- (MASConstraint * (^)(id, NSLayoutRelation))equalToWithRelation {
    return ^id(id attribute, NSLayoutRelation relation) {
        if ([attribute isKindOfClass:NSArray.class]) {
            NSAssert(!self.hasLayoutRelation, @"Redefinition of constraint relation");
            NSMutableArray *children = NSMutableArray.new;
            for (id attr in attribute) {
                MASViewConstraint *viewConstraint = [self copy];
                viewConstraint.layoutRelation = relation;
                viewConstraint.secondViewAttribute = attr;
                [children addObject:viewConstraint];
            }
            MASCompositeConstraint *compositeConstraint = [[MASCompositeConstraint alloc] initWithChildren:children];
            compositeConstraint.delegate = self.delegate;
            [self.delegate constraint:self shouldBeReplacedWithConstraint:compositeConstraint];
            return compositeConstraint;
        } else {
            NSAssert(!self.hasLayoutRelation || self.layoutRelation == relation && [attribute isKindOfClass:NSValue.class], @"Redefinition of constraint relation");
            self.layoutRelation = relation;
            self.secondViewAttribute = attribute;
            return self;
        }
    };
}

複製程式碼

此處,我們依然先看attribute不是NSArray的情況。這裡在單個屬性的約束中,就比較簡單了,將relationattribue傳入MASConstraint對應的成員。

在上面介紹install方法時,我們就曾提到過:

  MASLayoutConstraint *layoutConstraint
        = [MASLayoutConstraint constraintWithItem:firstLayoutItem
                                        attribute:firstLayoutAttribute
                                        relatedBy:self.layoutRelation
                                           toItem:secondLayoutItem
                                        attribute:secondLayoutAttribute
                                       multiplier:self.layoutMultiplier
                                         constant:self.layoutConstant];
複製程式碼

firstLayoutItemsecondLayoutIteminstall方法中已收集完成,此時,經過leftequalTo我們又收集到了:firstViewAttributesecondViewAttributelayoutRelation勝利即在眼前。

- (MASConstraint * (^)(CGFloat))offset {
    return ^id(CGFloat offset){
        self.offset = offset;
        return self;
    };
}

- (void)setOffset:(CGFloat)offset {
    self.layoutConstant = offset;
}

複製程式碼

通過OC的set語法,Masonryoffset傳入layoutConstant。

至此,layoutConstraint就完成了全部的元素收集,可以使用新增約束的方式,只需要解決最後一個問題,約束新增到哪裡呢?我們似乎在呼叫時,並不需要關心這件事情,那說明框架幫我們完成了這個工作。

closestCommonSuperview

我們在MASViewConstraint中,可以找到這樣一段:

    if (self.secondViewAttribute.view) {
        MAS_VIEW *closestCommonSuperview = [self.firstViewAttribute.view mas_closestCommonSuperview:self.secondViewAttribute.view];
        NSAssert(closestCommonSuperview,
                 @"couldn't find a common superview for %@ and %@",
                 self.firstViewAttribute.view, self.secondViewAttribute.view);
        self.installedView = closestCommonSuperview;
    } else if (self.firstViewAttribute.isSizeAttribute) {
        self.installedView = self.firstViewAttribute.view;
    } else {
        self.installedView = self.firstViewAttribute.view.superview;
    }
複製程式碼

注意到,closetCommonSuperview就是Masonry為我們找到的最近公共父控制元件。

- (instancetype)mas_closestCommonSuperview:(MAS_VIEW *)view {
    MAS_VIEW *closestCommonSuperview = nil;

    MAS_VIEW *secondViewSuperview = view;
    while (!closestCommonSuperview && secondViewSuperview) {
        MAS_VIEW *firstViewSuperview = self;
        while (!closestCommonSuperview && firstViewSuperview) {
            if (secondViewSuperview == firstViewSuperview) {
                closestCommonSuperview = secondViewSuperview;
            }
            firstViewSuperview = firstViewSuperview.superview;
        }
        secondViewSuperview = secondViewSuperview.superview;
    }
    return closestCommonSuperview;
}
複製程式碼

實現也比較簡單。

至此,我們完成了所有準備,就可以開始愉快的自動佈局啦。

以上就是Masonry對iOS自動佈局封裝的解讀。

如有問題,歡迎指正。

問答

iOS:如何使用自動佈局約束?

相關閱讀

走進 Masonry

iOS自動佈局框架之Masonry

iOS學習——佈局利器Masonry框架原始碼深度剖析

【每日課程推薦】機器學習實戰!快速入門線上廣告業務及CTR相應知識

此文已由作者授權騰訊雲+社群釋出,更多原文請點選

搜尋關注公眾號「雲加社群」,第一時間獲取技術乾貨,關注後回覆1024 送你一份技術課程大禮包!

海量技術實踐經驗,盡在雲加社群

相關文章