在上一篇中我提到了如何使用stackpanel和gridpanel來實現自動布局。而在這一篇中我着重講解下其中的原理。
在(UIPanel UIStackPanel UIGridPanel)中主要是使用了NSLayoutConstraint這個類來實現的,因此為了看懂下面的代碼請務必先了解NSLayoutConstraint的使用方法。
先考慮下這樣一個場景,現在有一個自上而下垂直的布局,水平方向的寬度跟屏幕分辨率的寬度保持一致,垂直方向高度不變,各個視圖間的間距不變,在用戶切換橫屏和豎屏的時候只有視圖的寬度是改變的,而高度和視圖間的間距不變。這樣一個場景也能模擬我們的應用在不同分辨率上適配。
針對上面這個場景,那么我們勢必要給UIView兩個屬性,就是描述UIView高寬和UIView之間間距的屬性,這里定義為size和margin屬性,size的類型是CGSize,而margin的數據類型是UIEdgeInsets(描述該UIView的四個方向的間距)。這兩個屬性是以擴展屬性實現的。
代碼如下:
@interface UIView(UIPanelSubView) //設置view的大小 @property (nonatomic,assign)CGSize size; //view距離左上右下的間距 @property (nonatomic,assign)UIEdgeInsets margin; @end
既然有了這兩個屬性,那么意味着只要我修改了兩個屬性的任何一個屬性,都能實時的改變UIView的外觀,那么我們這里就需要有一個方法來充值UIView的實現,這里添加一個方法resetConstraints,用來重置約束。
這樣完整的class定義是這樣的
@interface UIView(UIPanelSubView) //設置view的大小 @property (nonatomic,assign)CGSize size; //view距離左上右下的間距 @property (nonatomic,assign)UIEdgeInsets margin; //重置約束 -(void)resetConstraints; @end
完整的實現代碼如下:
@implementation UIView(UIPanelSubView) char* const uiviewSize_str = "UIViewSize"; -(void)setSize:(CGSize)size{ objc_setAssociatedObject(self, uiviewSize_str, NSStringFromCGSize(size), OBJC_ASSOCIATION_RETAIN); //先將原來的高寬約束刪除 for(NSLayoutConstraint *l in self.constraints){ switch (l.firstAttribute) { case NSLayoutAttributeHeight: case NSLayoutAttributeWidth: [self removeConstraint:l]; break; default: break; } } //添加高度約束 [self addConstraint:[NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0f constant:size.height]]; //添加寬度約束 [self addConstraint:[NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0f constant:size.width]]; } -(CGSize)size{ return CGSizeFromString(objc_getAssociatedObject(self, uiviewSize_str)); } char* const uiviewMargin_str = "UIViewMargin"; -(void)setMargin:(UIEdgeInsets)margin{ objc_setAssociatedObject(self, uiviewMargin_str, NSStringFromUIEdgeInsets(margin), OBJC_ASSOCIATION_RETAIN); if(self.superview){//只有在有父視圖的情況下,才能更新約束 [self.superview updateConstraints]; } } -(UIEdgeInsets)margin{ return UIEdgeInsetsFromString(objc_getAssociatedObject(self, uiviewMargin_str)); } -(void)resetConstraints{ [self removeConstraints:self.constraints]; } @end
現在有了這個擴展類就可以繼續上面的布局需求了。我們希望當把UIView添加到superview的時候對該UIView添加各種約束信息。代碼如下:
-(void)didAddSubview:(UIView *)subview{ [super didAddSubview:subview]; subview.translatesAutoresizingMaskIntoConstraints=NO;//要實現自動布局,必須把該屬性設置為no [self removeConstraintsWithSubView:subview];//先把subview的原來的約束信息刪除掉 [self updateSubViewConstraints:subview];//添加新的約束信息 }
上面提到布局是垂直自上而下的,而且寬度需要隨着屏幕的寬度改變而改變。從這里我們可以得出兩個結論。
- 寬度上要有一個約束,約束的具體信息是寬度隨着父視圖的寬度變化,還要把間距考慮進去。
- 所有添加到同一個父視圖中的subviews按照順序自上而下依序排列。
具體代碼如下
-(void)updateSubViewConstraints:(UIView *)subView{ UIEdgeInsets margin=subView.margin; NSArray *constraints = [NSLayoutConstraint constraintsWithVisualFormat:@"|-left-[view]-right-|" options:0 metrics:@{ @"left" : @(margin.left),@"right":@(margin.right)} views:@{ @"view" : subView}];//添加寬度的約束 [self addConstraints:constraints]; //獲取同級下的上一個視圖的,以便做垂直的自上而下排列 NSInteger index=[self.subviews indexOfObject:subView]; UIView *preView=index==0?nil:[self.subviews objectAtIndex:index-1]; if(preView){//如果該subview有排序比它更靠前的視圖 //該subview的頂部緊靠上一個視圖的底部 [self addConstraint:[NSLayoutConstraint constraintWithItem:subView attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:preView attribute:NSLayoutAttributeBottom multiplier:1.0f constant:(margin.top+preView.margin.bottom)]]; }else{ //該subview的頂部緊靠父視圖的頂部 [self addConstraint:[NSLayoutConstraint constraintWithItem:subView attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self attribute:NSLayoutAttributeTop multiplier:1.0f constant:margin.top]]; } }
至此,我們已經實現了一個可以自動自上而下排列的stackpanel。繼續考慮一個問題,如果我們動態的刪除其中的一個子視圖,我們會發現所有的約束機會都失效了,為什么?因為從上面的代碼中可以看出,我們之所以能夠實現自上而下的布局,完全是依賴余有序的前后視圖的各種"依賴約束",也就是NSLayoutConstraint中的relatedBy是上一個視圖,這就好比一條鏈子上的各個有序節點,一旦你把鏈子上的一個節點拿掉,那么原來的前后關系就改變了,因此約束也就失效了。
而為了能夠實現在UIView從superview中移除的時候不影響整個的約束信息,那么我們必須重置約束信息,也就是我們應該在superview的didRemoveSubview這個方法中來重置,但是很遺憾,沒有這個方法,蘋果只給了我們willRemoveSubview方法,我目前沒有想到其他方法,只能在willRemoveSubview這個方法上考慮去實現。現在問題又來了,willRemoveSubview這個方法被調用的時候該subview事實上還沒有被刪掉,只是告訴你將要被刪除了。這里我采用了一個取巧的方法,說實話這樣的代碼不應該出現的,但是沒辦法,只能先將就用下。也就是在willRemoveSubview的方法里面,再調一次subview的removeFromSuperview的方法,這樣當removeFromSuperview調用完畢的時候就表明該subview已經被移除了,但是這樣一來就會造成循環調用了,因此我們還需要一個bool參數來標記該subview是有已經被刪除了,因此我們需要在上面提到的UIPanelSubView類中添加一個不公開的屬性isRemoved,該屬性在UIVIew被添加到superview中的時候設置為no,被remove的時候設置為yes。
具體代碼如下:
-(void)didAddSubview:(UIView *)subview{ [super didAddSubview:subview]; [subview setIsRemoved:NO];//標記為未刪除 subview.translatesAutoresizingMaskIntoConstraints=NO;//要實現自動布局,必須把該屬性設置為no [self removeConstraintsWithSubView:subview];//先把subview的原來的約束信息刪除掉 [self updateSubViewConstraints:subview];//添加新的約束信息 } -(void)willRemoveSubview:(UIView *)subview{ if(![subview isRemoved]){//因為沒有didRemoveSubView方法,所以只能采用這樣的方式來達到目的了 [subview setIsRemoved:YES];//標記為已刪除 [subview removeFromSuperview];//再調用一次removeFromSuperview,這樣調用完畢該方法,那么表明該subview已經被移除了 [self updateConstraints];//重置約束 } }
-(void)updateConstraints{ [super updateConstraints]; for(UIView * v in self.subviews) { [self updateSubViewConstraints:v]; } }
這樣就實現了subview被移除的時候仍然能有效約束。
現在當我們把UIStackPanel添加ViewController的view中的時候,發現旋轉屏幕的時候里面的布局沒有跟着變。這是因為我們以上的約束信息都是UIStackPanel和它的子視圖的,但是UIStackPanel沒有建立起跟它的父視圖的約束,這樣當然不能實現自動布局啦。要解決這個問題,也很簡單。對UIStackPanel添加一個屬性isBindSizeToSuperView,是否把UIStackPanel的高寬跟父視圖的高寬綁定。
-(void)setIsBindSizeToSuperView:(BOOL)isBindSizeToSuperView{ if(_isBindSizeToSuperView!=isBindSizeToSuperView){ _isBindSizeToSuperView=isBindSizeToSuperView; if(isBindSizeToSuperView){ self.translatesAutoresizingMaskIntoConstraints=NO; if(self.superview){ [self bindSizeToSuperView]; } }else{ self.translatesAutoresizingMaskIntoConstraints=YES; } } } -(void)bindSizeToSuperView{ UIEdgeInsets margin=self.margin; NSArray *constraints = [NSLayoutConstraint constraintsWithVisualFormat:@"|-left-[view]-right-|" options:0 metrics:@{ @"left" : @(margin.left),@"right":@(margin.right)} views:@{ @"view" : self}]; [self.superview addConstraints:constraints]; constraints = [NSLayoutConstraint constraintsWithVisualFormat:@"V:|-top-[view]-bottom-|" options:0 metrics:@{ @"top" : @(margin.top),@"bottom":@(margin.bottom)} views:@{ @"view" : self}]; [self.superview addConstraints:constraints]; }
這樣我們已經完全實現了開頭提到的布局要求。
下面貼出完整的代碼
@interface UIView(UIPanelSubView) //設置view的大小 @property (nonatomic,assign)CGSize size; //view距離左上右下的間距 @property (nonatomic,assign)UIEdgeInsets margin; //重置約束 -(void)resetConstraints; @end @interface UIPanel : UIView @property (nonatomic,assign)BOOL isBindSizeToSuperView;//是否把高寬綁定到父視圖 //更新某個字視圖的約束信息 -(void)updateSubViewConstraints:(UIView *)subView; //刪除屬於subView的NSLayoutConstraint -(void)removeConstraintsWithSubView:(UIView *)subView; @end @interface UIStackPanel : UIPanel @property (nonatomic,assign)BOOL isHorizontal;//是否水平布局 @end
@implementation UIView(UIPanelSubView) char* const uiviewSize_str = "UIViewSize"; -(void)setSize:(CGSize)size{ objc_setAssociatedObject(self, uiviewSize_str, NSStringFromCGSize(size), OBJC_ASSOCIATION_RETAIN); //先將原來的高寬約束刪除 for(NSLayoutConstraint *l in self.constraints){ switch (l.firstAttribute) { case NSLayoutAttributeHeight: case NSLayoutAttributeWidth: [self removeConstraint:l]; break; default: break; } } //添加高度約束 [self addConstraint:[NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0f constant:size.height]]; //添加寬度約束 [self addConstraint:[NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0f constant:size.width]]; } -(CGSize)size{ return CGSizeFromString(objc_getAssociatedObject(self, uiviewSize_str)); } char* const uiviewMargin_str = "UIViewMargin"; -(void)setMargin:(UIEdgeInsets)margin{ objc_setAssociatedObject(self, uiviewMargin_str, NSStringFromUIEdgeInsets(margin), OBJC_ASSOCIATION_RETAIN); if(self.superview){//只有在有父視圖的情況下,才能更新約束 [self.superview updateConstraints]; } } -(UIEdgeInsets)margin{ return UIEdgeInsetsFromString(objc_getAssociatedObject(self, uiviewMargin_str)); } //用來標記該視圖一否已經被刪除 char* const uiviewIsRemoved_str = "UIViewIsRemoved"; -(void)setIsRemoved:(BOOL)isRemoved{ objc_setAssociatedObject(self, uiviewIsRemoved_str, @(isRemoved), OBJC_ASSOCIATION_RETAIN); } -(BOOL)isRemoved{ return [objc_getAssociatedObject(self, uiviewIsRemoved_str) boolValue]; } -(void)resetConstraints{ [self removeConstraints:self.constraints]; if(self.superview && [self.superview respondsToSelector:@selector(updateSubViewConstraints:)]){ [self.superview performSelector:@selector(removeConstraintsWithSubView:) withObject:self]; [self.superview performSelector:@selector(updateSubViewConstraints:) withObject:self]; [self updateConstraints]; } } @end @implementation UIPanel -(void)setIsBindSizeToSuperView:(BOOL)isBindSizeToSuperView{ if(_isBindSizeToSuperView!=isBindSizeToSuperView){ _isBindSizeToSuperView=isBindSizeToSuperView; if(isBindSizeToSuperView){ self.translatesAutoresizingMaskIntoConstraints=NO; if(self.superview){ [self bindSizeToSuperView]; } }else{ self.translatesAutoresizingMaskIntoConstraints=YES; } } } -(void)didAddSubview:(UIView *)subview{ [super didAddSubview:subview]; [subview setIsRemoved:NO];//標記為未刪除 subview.translatesAutoresizingMaskIntoConstraints=NO;//要實現自動布局,必須把該屬性設置為no [self removeConstraintsWithSubView:subview];//先把subview的原來的約束信息刪除掉 [self updateSubViewConstraints:subview];//添加新的約束信息 } -(void)updateSubViewConstraints:(UIView *)subView{ UIEdgeInsets margin=subView.margin; NSArray *constraints = [NSLayoutConstraint constraintsWithVisualFormat:@"|-left-[view]-right-|" options:0 metrics:@{ @"left" : @(margin.left),@"right":@(margin.right)} views:@{ @"view" : subView}]; [self addConstraints:constraints]; constraints = [NSLayoutConstraint constraintsWithVisualFormat:@"V:|-top-[view]-bottom-|" options:0 metrics:@{ @"top" : @(margin.top),@"bottom":@(margin.bottom)} views:@{ @"view" : subView}]; [self addConstraints:constraints]; } -(void)willRemoveSubview:(UIView *)subview{ if(![subview isRemoved]){//因為沒有didRemoveSubView方法,所以只能采用這樣的方式來達到目的了 [subview setIsRemoved:YES];//標記為已刪除 [subview removeFromSuperview];//再調用一次removeFromSuperview,這樣調用完畢該方法,那么表明該subview已經被移除了 [self updateConstraints];//重置約束 } } -(void)removeConstraintsWithSubView:(UIView *)subView{ for(NSLayoutConstraint *l in self.constraints){ if(l.firstItem==subView){ [self removeConstraint:l]; } } } -(void)updateConstraints{ [super updateConstraints]; for(UIView * v in self.subviews) { [self updateSubViewConstraints:v]; } } -(void)bindSizeToSuperView{ UIEdgeInsets margin=self.margin; NSArray *constraints = [NSLayoutConstraint constraintsWithVisualFormat:@"|-left-[view]-right-|" options:0 metrics:@{ @"left" : @(margin.left),@"right":@(margin.right)} views:@{ @"view" : self}]; [self.superview addConstraints:constraints]; constraints = [NSLayoutConstraint constraintsWithVisualFormat:@"V:|-top-[view]-bottom-|" options:0 metrics:@{ @"top" : @(margin.top),@"bottom":@(margin.bottom)} views:@{ @"view" : self}]; [self.superview addConstraints:constraints]; } -(void)didMoveToSuperview{ [super didMoveToSuperview]; if(self.isBindSizeToSuperView){ [self bindSizeToSuperView]; } } @end @implementation UIStackPanel -(void)setIsHorizontal:(BOOL)isHorizontal{ if(_isHorizontal!=isHorizontal){ _isHorizontal=isHorizontal; [self updateConstraints]; } } -(void)updateSubViewConstraints:(UIView *)subView{ UIEdgeInsets margin=subView.margin; if(self.isHorizontal){ NSArray *constraints = [NSLayoutConstraint constraintsWithVisualFormat:@"V:|-top-[view]-bottom-|" options:0 metrics:@{ @"top" : @(margin.top),@"bottom":@(margin.bottom)} views:@{ @"view" : subView}]; [self addConstraints:constraints]; NSInteger index=[self.subviews indexOfObject:subView]; UIView *preView=index==0?nil:[self.subviews objectAtIndex:index-1]; if(preView){ [self addConstraint:[NSLayoutConstraint constraintWithItem:subView attribute:NSLayoutAttributeLeft relatedBy:NSLayoutRelationEqual toItem:preView attribute:NSLayoutAttributeRight multiplier:1.0f constant:(margin.left+preView.margin.left)]]; }else{ [self addConstraint:[NSLayoutConstraint constraintWithItem:subView attribute:NSLayoutAttributeLeft relatedBy:NSLayoutRelationEqual toItem:self attribute:NSLayoutAttributeLeft multiplier:1.0f constant:margin.left]]; } }else{ NSArray *constraints = [NSLayoutConstraint constraintsWithVisualFormat:@"|-left-[view]-right-|" options:0 metrics:@{ @"left" : @(margin.left),@"right":@(margin.right)} views:@{ @"view" : subView}];//添加寬度的約束 [self addConstraints:constraints]; //獲取同級下的上一個視圖的,以便做垂直的自上而下排列 NSInteger index=[self.subviews indexOfObject:subView]; UIView *preView=index==0?nil:[self.subviews objectAtIndex:index-1]; if(preView){//如果該subview有排序比它更靠前的視圖 //該subview的頂部緊靠上一個視圖的底部 [self addConstraint:[NSLayoutConstraint constraintWithItem:subView attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:preView attribute:NSLayoutAttributeBottom multiplier:1.0f constant:(margin.top+preView.margin.bottom)]]; }else{ //該subview的頂部緊靠父視圖的頂部 [self addConstraint:[NSLayoutConstraint constraintWithItem:subView attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self attribute:NSLayoutAttributeTop multiplier:1.0f constant:margin.top]]; } } } @end
下一篇介紹uigridpanel的原理