如何設計一個 iOS 控件?(iOS 控件徹底解析)


目錄(?)[+] git

代碼的等級:可編譯、可運行、可測試、可讀、可維護、可複用 github


前言

一個控件從外在特徵來講,主要是封裝這幾點: 算法

  • 交互方式
  • 顯示樣式
  • 數據使用

對外在特徵的封裝,能讓咱們在多種環境下達到 PM 對產品的要求,而且提到代碼複用率,使維護工做保持在一個相對較小的範圍內;而一個好的控件除了有對外一致的體驗以外,還有其內在特徵: 緩存

  • 靈活性
  • 低耦合
  • 易拓展
  • 易維護

一般特徵之間須要作一些取捨,好比靈活性與耦合度,有時候接口越多越能適應各類環境,可是接口越少對外產生的依賴就越少,維護起來也更容易。一般一些前期看起來還不錯的代碼,每每也會隨着時間加深慢慢「成長」,功能的增長也會帶來新的接口,很不自覺地就加深了耦合度,在開發中時不時地進行一些重構工做頗有必要。總之,儘可能減小接口的數量,但有足夠的定製空間,能夠在一開始把接口所有隱藏起來,再根據實際須要慢慢放開。 app

自定義控件在 iOS 項目裏很常見,一般頁面之間入口不少,並且使用場景極有可能大不相同,好比一個 UIView 既能夠以代碼初始化,也能夠以 xib的形式初始化,而咱們是須要保證這兩種操做都能產生一樣的行爲。本文將會討論到如下幾點: 框架

  • 選擇正確的初始化方式
  • 調整佈局的時機
  • 正確的處理 touches 方法
  • drawRectCALayer 與動畫
  • UIControl 與 UIButton
  • 更友好的支持 xib
  • 不規則圖形和事件觸發範圍(事件鏈的簡單介紹以及處理)
  • 合理使用 KVO

若是這些問題你一看就懂的話就不用繼續往下看了。 ide

設計方針


選擇正確的初始化方式

UIView 的首要問題就是既能從代碼中初始化,也能從 xib 中初始化,二者有何不一樣? UIView 是支持 NSCoding 協議的,當在 xib 或 storyboard 裏存在一個 UIView 的時候,實際上是將 UIView 序列化到文件裏(xib 和 storyboard 都是以 XML 格式來保存的),加載的時候反序列化出來,因此: 佈局

  • 當從代碼實例化 UIView 的時候,initWithFrame 會執行;
  • 當從文件加載 UIView 的時候,initWithCoder 會執行。

從代碼中加載

雖然 initWithFrame 是 UIView 的Designated Initializer,理論上來說你繼承自 UIView 的任何子類,該方法最終都會被調用,可是有一些類在初始化的時候沒有遵照這個約定,如 UIImageView 的 initWithImage 和 UITableViewCell 的 initWithStyle:reuseIdentifier: 的構造器等,因此咱們在寫自定義控件的時候,最好只假設父視圖的 Designated Initializer 被調用。 性能

若是控件在初始化或者在使用以前必須有一些參數要設置,那咱們能夠寫本身的 Designated Initializer 構造器,如: 測試

- (instancetype)initWithName:(NSString *)name;


   

   

    
    
    
    
    

   
  • 1

在實現中必定要調用父類的 Designated Initializer,並且若是你有多個自定義的 Designated Initializer,最終都應該指向一個全能的初始化構造器:

- (instancetype)initWithName:(NSString *)name { self = [self initWithName:name frame:CGRectZero]; return self;
}

- (instancetype)initWithName:(NSString *)name frame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { self.name = name;
    } return self;
}


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

而且你要考慮到,由於你的控件是繼承自 UIView 或 UIControl 的,那麼用戶徹底能夠不使用你提供的構造器,而直接調用基類的構造器,因此最好重寫父類的 Designated Initializer,使它調用你提供的 Designated Initializer ,好比父類是個 UIView:

- (instancetype)initWithFrame:(CGRect)frame { self = [self initWithName:nil frame:frame]; return self;
}


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4

這樣當用戶從代碼裏初始化你的控件的時候,就老是逃脫不了你須要執行的初始化代碼了,哪怕用戶直接調用 init 方法,最終仍是會回到父類的 Designated Initializer 上。

從 xib 或 storyboard 中加載

當控件從 xib 或 storyboard 中加載的時候,狀況就變得複雜了,首先咱們知道有 initWithCoder 方法,該方法會在對象被反序列化的時候調用,好比從文件加載一個 UIView 的時候:

UIView *view = [[UIView alloc] init]; NSData *data = [NSKeyedArchiver archivedDataWithRootObject:view]; [[NSUserDefaults standardUserDefaults] setObject:data forKey:@"KeyView"]; [[NSUserDefaults standardUserDefaults] synchronize]; data = [[NSUserDefaults standardUserDefaults] objectForKey:@"KeyView"]; view = [NSKeyedUnarchiver unarchiveObjectWithData:data]; NSLog(@"%@", view);


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

執行 unarchiveObjectWithData 的時候, initWithCoder 會被調用,那麼你有可能會在這個方法裏作一些初始化工做,好比恢復到保存以前的狀態,固然前提是須要在 encodeWithCoder 中預先保存下來。

不過咱們不多會本身直接把一個 View 保存到文件中,通常是在 xib 或 storyboard 中寫一個 View,而後讓系統來完成反序列化的工做,此時在 initWithCoder 調用以後,awakeFromNib 方法也會被執行,既然在 awakeFromNib 方法裏也能作初始化操做,那咱們如何抉擇?

通常來講要儘可能在 initWithCoder 中作初始化操做,畢竟這是最合理的地方,只要你的控件支持序列化,那麼它就能在任何被反序列化的時候執行初始化操做,這裏適合作全局數據、狀態的初始化工做,也適合手動添加子視圖。

awakeFromNib 相較於 initWithCoder 的優點是:當 awakeFromNib 執行的時候,各類 IBOutlet 也都鏈接好了;而 initWithCoder 調用的時候,雖然子視圖已經被添加到視圖層級中,可是尚未引用。若是你是基於 xib 或 storyboard 建立的控件,那麼你可能須要對 IBOutlet 鏈接的子控件進行初始化工做,這種狀況下,你只能在 awakeFromNib 裏進行處理。同時 xib 或 storyboard 對靈活性是有打折的,由於它們建立的代碼沒法被繼承,因此當你選擇用 xib 或 storyboard 來實現一個控件的時候,你已經不須要對靈活性有很高的要求了,惟一要作的是要保證用戶必定是經過 xib 建立的此控件,不然多是一個空的視圖,能夠在 initWithFrame 裏放置一個 斷言 或者異常來通知控件的用戶。

最後還要注意視圖層級的問題,好比你要給 View 放置一個背景,你可能會在 initWithCoder 或 awakeFromNib 中這樣寫:

[self addSubview:self.backgroundView]; // 經過懶加載一個背景 View,而後添加到視圖層級上


   

   

    
    
    
    
    

   
  • 1

你的本意是在控件的最下面放置一個背景,卻有可能將這個背景覆蓋到控件的最上方,緣由是用戶可能會在 xib 裏寫入這個控件,而後往它上面添加一些子視圖,這樣一來,用戶添加的這些子視圖會在你添加背景以前先進入視圖層級,你的背景被添加後就擋住了用戶的子視圖。若是你想支持用戶的這種操做,能夠把 addSubview 替換成 insertSubview:atIndex:。

同時支持從代碼和文件中加載

若是你要同時支持 initWithFrame 和 initWithCoder ,那麼你能夠提供一個 commonInit 方法來作統一的初始化:

- (id)initWithCoder:(NSCoder *)aDecoder { self = [super initWithCoder:aDecoder]; if (self) {
        [self commonInit];
    } return self;
}

- (id)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) {
        [self commonInit];
    } return self;
}

- (void)commonInit { // do something ... }


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

awakeFromNib 方法裏就不要再去調用 commonInit 了。


調整佈局的時機

當一個控件被初始化以及開始使用以後,它的 frame 仍然可能發生變化,咱們也須要接受這些變化,由於你提供的是 UIView 的接口,UIView 有不少種初始化方式:initWithFrame、initWithCoder、init 和類方法 new,用戶徹底能夠在初始化以後再設置 frame 屬性,並且用戶就算使用 initWithFrame 來初始化也避免不了 frame 的改變,好比在橫豎屏切換的時候。爲了確保當它的 Size 發生變化後其子視圖也能同步更新,咱們不能一開始就把佈局寫死(使用約束除外)。

基於 frame

若是你是直接基於 frame 來佈局的,你應該確保在初始化的時候只添加視圖,而不去設置它們的frame,把設置子視圖 frame 的過程所有放到 layoutSubviews 方法裏:

- (instancetype)initWithCoder:(NSCoder *)aDecoder { self = [super initWithCoder:aDecoder]; if (self) {
        [self commonInit];
    } return self;
}

- (instancetype)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) {
        [self commonInit];
    } return self;
}

- (void)layoutSubviews {
    [super layoutSubviews]; self.label.frame = CGRectInset(self.bounds, 20, 0);
}

- (void)commonInit {
    [self addSubview:self.label];
}

- (UILabel *)label { if (_label == nil) {
        _label = [UILabel new];
        _label.textColor = [UIColor grayColor];
    } return _label;
}


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33

這麼作就能保證 label 老是出如今正確的位置上。 
使用 layoutSubviews 方法有幾點須要注意:

  1. 不要依賴前一次的計算結果,應該老是根據當前最新值來計算
  2. 因爲 layoutSubviews 方法是在自身的 bounds 發生改變的時候調用, 所以 UIScrollView 會在滾動時不停地調用,當你只關心 Size 有沒有變化的時候,能夠把前一次的 Size 保存起來,經過與最新的 Size 比較來判斷是否須要更新,在大多數狀況下都能改善性能

基於 Auto Layout 約束

若是你是基於 Auto Layout 約束來進行佈局,那麼能夠在 commonInit 調用的時候就把約束添加上去,不要重寫 layoutSubviews 方法,由於這種狀況下它的默認實現就是根據約束來計算 frame。最重要的一點,把 translatesAutoresizingMaskIntoConstraints 屬性設爲 NO,以避免產生 NSAutoresizingMaskLayoutConstraint 約束,若是你使用 Masonry 框架的話,則不用擔憂這個問題,mas_makeConstraints 方法會首先設置這個屬性爲 NO:

- (void)commonInit {
    ...
    [self setupConstraintsForSubviews];
}

- (void)setupConstraintsForSubviews {
    [self.label mas_makeConstraints:^(MASConstraintMaker *make) {
        ...
    }];
}


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

支持 sizeToFit

若是你的控件對尺寸有嚴格的限定,好比有一個統一的寬高比或者是固定尺寸,那麼最好能實現系統給出的約定成俗的接口。

sizeToFit 用在基於 frame 佈局的狀況下,由你的控件去實現 sizeThatFits: 方法:

- (CGSize)sizeThatFits:(CGSize)size { CGSize fitSize = [super sizeThatFits:size];
    fitSize.height += self.label.frame.size.height; // 若是是固定尺寸,就像 UISwtich 那樣返回一個固定 Size 就 OK 了 return fitSize;
}


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

而後在外部調用該控件的 sizeToFit 方法,這個方法內部會自動調用 sizeThatFits 並更新自身的 Size:

[self.customView sizeToFit];


   

   

    
    
    
    
    

   
  • 1

在 ViewController 裏調整視圖佈局

當執行 viewDidLoad 方法時,不要依賴 self.view 的 Size。不少人會這樣寫:

- (void)viewDidLoad {
    ... self.label.width = self.view.width;
}


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4

這樣是不對的,哪怕看上去沒問題也只是碰巧沒問題而已。當 viewDidLoad 方法被調用的時候,self.view 纔剛剛被初始化,此時它的容器尚未對它的 frame 進行設置,若是 view 是從 xib 加載的,那麼它的 Size 就是 xib 中設置的值;若是它是從代碼加載的,那麼它的 Size 和屏幕大小有關係,除了 Size 之外,Origin 也不會準確。整個過程看起來像這樣:

當訪問 ViewController 的 view 的時候,ViewController 會先執行 loadViewIfRequired 方法,若是 view 尚未加載,則調用 loadView,而後是 viewDidLoad 這個鉤子方法,最後是返回 view,容器拿到 view 後,根據自身的屬性(如 edgesForExtendedLayout、判斷是否存在 tabBar、判斷 navigationBar 是否透明等)添加約束或者設置 frame。

你至少應該設置 autoresizingMask 屬性:

- (void)viewDidLoad {
    ... self.label.width = self.view.width; self.label.autoresizingMask = UIViewAutoresizingFlexibleWidth;
}


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4
  • 5

或者在 viewDidLayoutSubviews 裏處理:

- (void)viewDidLayoutSubviews {
    [super viewDidLayoutSubviews]; self.label.width = self.view.width;
}


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4
  • 5

若是是基於 Auto Layout 來佈局,則在 viewDidLoad 裏添加約束便可。


正確的處理 touches 方法

若是你須要重寫 touches 方法,那麼應該完整的重寫這四個方法:

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event; - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event; - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event; - (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4

當你的視圖在這四個方法執行的時候,若是已經對事件進行了處理,就不要再調用 super 的 touches 方法,super 的 touches 方法默認實現是在響應鏈裏繼續轉發事件(UIView 的默認實現)。若是你的基類是 UIScrollView 或者 UIButton 這些已經重寫了事件處理的類,那麼當你不想處理事件的時候能夠調用 self.nextResponder 的 touches 方法來轉發事件,其餘的狀況就調用 super 的 touches 方法來轉發,好比 UIScrollView 能夠這樣來轉發 觸摸 事件:

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { if (!self.dragging) {
        [self.nextResponder touchesBegan: touches withEvent:event]; 
    }       

    [super touchesBegan: touches withEvent: event];
}

- (void)touchesMoved...

- (void)touchesEnded...

- (void)touchesCancelled...


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

這麼實現之後,當你僅僅只是「碰」一個 UIScrollView 的時候,該事件就有可能被 nextResponder 處理。 
若是你沒有實現本身的事件處理,也沒有調用 nextResponder 和 super,那麼響應鏈就會斷掉。另外,儘可能用手勢識別器去處理自定義事件,它的好處是你不須要關心響應鏈,邏輯處理起來也更加清晰,事實上,UIScrollView 也是經過手勢識別器實現的:

@property(nonatomic, readonly) UIPanGestureRecognizer *panGestureRecognizer NS_AVAILABLE_IOS(5_0); 
@property(nonatomic, readonly) UIPinchGestureRecognizer *pinchGestureRecognizer NS_AVAILABLE_IOS(5_0);


drawRect、CALayer 與動畫

drawRect 方法很適合作自定義的控件,當你須要更新 UI 的時候,只要用 setNeedsDisplay 標記一下就好了,這麼作又簡單又方便;控件也經常用於封裝動畫,可是動畫卻有可能被移除掉。 
須要注意的地方:

  1. 在 drawRect 裏儘可能用 CGContext 繪製 UI。若是你用 addSubview 插入了其餘的視圖,那麼當系統在每次進入繪製的時候,會先把當前的上下文清除掉(此處不考慮 clearsContextBeforeDrawing 的影響),而後你也要清除掉已有的 subviews,以避免重複添加視圖;用戶可能會往你的控件上添加他本身的子視圖,而後在某個狀況下清除全部的子視圖(我就喜歡這麼作):

    [subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
    
    
         
    
         
    
          
          
          
          
          
    
         
    • 1
  2. 用 CALayer 代替 UIView。CALayer 節省內存,並且更適合去作一個「圖層」,由於它不會接收事件、也不會成爲響應鏈中的一員,可是它可以響應父視圖(或 layer)的尺寸變化,這種特性很適合作單純的數據展現:

    CALayer *imageLayer = [CALayer layer];
    imageLayer.frame = rect;
    imageLayer.contents = (id)image;
    [self.view.layer addSublayer:imageLayer];
    
    
         
    
         
    
          
          
          
          
          
    
         
    • 1
    • 2
    • 3
    • 4
  3. 若是有可能的話使用 setNeedsDisplayInRect 代替 setNeedsDisplay 以優化性能,可是遇到性能問題的時候應該先檢查本身的繪圖算法和繪圖時機,我我的其實歷來沒有使用過 setNeedsDisplayInRect。

  4. 當你想作一個無限循環播放的動畫的時候,可能會建立幾個封裝了動畫的 CALayer,而後把它們添加到視圖層級上,就像我在 iOS 實現脈衝雷達以及動態增減元素 By Swift 中這麼作的: 
     
    效果還不錯,實現又簡單,可是當你按下 Home 鍵並再次返回到 app 的時候,本來好看的動畫就變成了一灘死水:

    這是由於在按下 Home 鍵的時候,全部的動畫被移除了,具體的,每一個 layer 都調用了 removeAllAnimations 方法。

    若是你想從新播放動畫,能夠監聽 UIApplicationDidBecomeActiveNotification 通知,就像我在 上述博客 中作的那樣。

  5. UIImageView 的 drawRect 永遠不會被調用:

    Special Considerations

    The UIImageView class is optimized to draw its images to the display. UIImageView will not call drawRect: in a subclass. If your subclass needs custom drawing code, it is recommended you use UIView as the base class.

  6. UIView 的 drawRect 也不必定會調用,我在 12 年的博客:定製UINavigationBar 中曾經提到過 UIKit 框架的實現機制:

    衆所周知一個視圖如何顯示是取決於它的 drawRect 方法,由於調這個方法以前 UIKit 也不知道如何顯示它,但其實 drawRect 方法的目的也是畫圖(顯示內容),並且咱們若是以其餘的方式給出了內容(圖)的話, drawRect 方法就不會被調用了。

    注:實際上 UIView 是 CALayer 的delegate,若是 CALayer 沒有內容的話,會回調給 UIView 的 displayLayer: 或者 drawLayer:inContext: 方法,UIView 在其中調用 drawRect ,draw 完後的圖會緩存起來,除非使用 setNeedsDisplay 或是一些必要狀況,不然都是使用緩存的圖。

    UIView 和 CALayer 都是模型對象,若是咱們以這種方式給出內容的話,drawRect 也就不會被調用了:

    self.customView.layer.contents = (id)[UIImage imageNamed:@"AppIcon"]; // 哪怕是給它一個 nil,這兩句等價 self.customView.layer.contents = nil;
    
    
         
    
         
    
          
          
          
          
          
    
         
    • 1
    • 2
    • 3

    我猜想是在 CALayer 的 setContents 方法裏有個標記,不管傳入的對象是什麼都會將該標記打開,可是調用 setNeedsDisplay 的時候會將該標記去除。


UIControl 與 UIButton

若是要作一個可交互的控件,那麼把 UIControl 做爲基類就是首選,這個完美的基類支持各類狀態:

  • enabled
  • selected
  • highlighted
  • tracking 
  • ……

還支持多狀態下的觀察者模式:

@property(nonatomic,readonly) UIControlState state;

- (void)addTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents;
- (void)removeTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents;


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4

這個基類能夠很方便地爲視圖添加各類點擊狀態,最多見的用法就是將 UIViewController 的 view 改爲 UIControl,而後就能快速實現 resignFirstResponder。

UIButton 自帶圖文接口,支持更強大的狀態切換,titleEdgeInsets 和 imageEdgeInsets 也比較好用,配合兩個基類的屬性更好,先設置對齊規則,再設置 insets:

@property(nonatomic) UIControlContentVerticalAlignment contentVerticalAlignment; @property(nonatomic) UIControlContentHorizontalAlignment contentHorizontalAlignment;


   

   

    
    
    
    
    

   
  • 1
  • 2

UIControl 和 UIButton 都能很好的支持 xib,能夠設置各類狀態下的顯示和 Selector,可是對 UIButton 來講這些並不夠,由於 Normal、Highlighted 和 Normal | Highlighted 是三種不一樣的狀態,若是你須要實現根據當前狀態顯示不一樣高亮的圖片,能夠參考我下面的代碼: 
這裏寫圖片描述這裏寫圖片描述

- (void)updateStates {
    [super setTitle:[self titleForState:UIControlStateNormal] forState:UIControlStateNormal | UIControlStateHighlighted];
    [super setImage:[self imageForState:UIControlStateNormal] forState:UIControlStateNormal | UIControlStateHighlighted];

    [super setTitle:[self titleForState:UIControlStateSelected] forState:UIControlStateSelected | UIControlStateHighlighted];
    [super setImage:[self imageForState:UIControlStateSelected] forState:UIControlStateSelected | UIControlStateHighlighted];
}


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

或者使用初始化設置:

- (void)commonInit {
    [self setImage:[UIImage imageNamed:@"Normal"] forState:UIControlStateNormal];
    [self setImage:[UIImage imageNamed:@"Selected"] forState:UIControlStateSelected];
    [self setImage:[UIImage imageNamed:@"Highlighted"] forState:UIControlStateHighlighted];
    [self setImage:[UIImage imageNamed:@"Selected_Highlighted"] forState:UIControlStateSelected | UIControlStateHighlighted];
}


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

總之儘可能使用原生類的接口,或者模仿原生類的接口。

大多數狀況下根據你所須要的特性來選擇現有的基類就夠了,或者用 UIView + 手勢識別器 的組合也是一個好方案,儘可能不要用 touches 方法(userInteractionEnabled 屬性對 touches 和手勢識別器的做用同樣),這是我在 DKCarouselView 中內置的一個可點擊的 ImageView,也能夠繼承 UIButton,不過 UIButton 更側重於狀態,ImageView 側重於圖片自己:

typedef void(^DKCarouselViewTapBlock)(); @interface DKClickableImageView : UIImageView @property (nonatomic, assign) BOOL enable; @property (nonatomic, copy) DKCarouselViewTapBlock tapBlock; @end @implementation DKClickableImageView - (instancetype)initWithFrame:(CGRect)frame { if ((self = [super initWithFrame:frame])) {
        [self commonInit];
    } return self;
}

- (instancetype)initWithCoder:(NSCoder *)aDecoder { if ((self = [super initWithCoder:aDecoder])) {
        [self commonInit];
    } return self;
}

- (void)commonInit { self.userInteractionEnabled = YES; self.enable = YES;

    UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onTap:)];
    [self addGestureRecognizer:tapGesture];
}

- (IBAction)onTap:(id)sender { if (!self.enable) return; if (self.tapBlock) { self.tapBlock();
    }
} @end


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42

更友好的支持 xib

你的控件如今應該能夠正確的從文件、代碼中初始化了,可是從 xib 中初始化之後可能還須要經過代碼來進行一些設置,你或許以爲像上面那樣設置 Button 的狀態很噁心並且不夠直觀,可是也沒辦法,這是因爲 xib 雖然對原生控件,如 UIView、UIImageView、UIScrollView 等支持較好(想設置圓角、邊框等屬性也沒辦法,只能經過 layer 來設置),可是對自定義控件卻沒有什麼辦法,當你拖一個 UIView 到 xib 中,而後把它的 Class 改爲你本身的子類後,xib 如同一個瞎子同樣,不會有任何變化。————好在這些都成了過去。

Xcode 6 引入了兩個新的宏:IBInspectable 和 IBDesignable。

IBInspectable

該宏會讓 xib 識別屬性,它支持這些數據類型:布爾、字符串、數字(NSNumber)、 CGPoint、CGSize、CGRect、UIColor 、 NSRange 和 UIImage。 
好比咱們要讓自定義的 Button 能在 xib 中設置 UIControlStateSelected | UIControlStateHighlighted 狀態的圖片,就能夠這麼作:

// CustomButton @property (nonatomic, strong) IBInspectable UIImage *highlightSelectedImage;

- (void)setHighlightSelectedImage:(UIImage *)highlightSelectedImage {
    _highlightSelectedImage = highlightSelectedImage;

    [self setImage:highlightSelectedImage forState:UIControlStateHighlighted | UIControlStateSelected];
}


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

只須要在屬性上加個 IBInspectable 宏便可,而後 xib 中就能顯示這個自定義的屬性: 
這裏寫圖片描述

xib 會把屬性名以大駝峯樣式顯示,若是有多個屬性,xib 也會自動按屬性名的第一個單詞分組顯示,如: 
這裏寫圖片描述

經過使用 IBInspectable 宏,你能夠把本來只能經過代碼來設置的屬性,也放到 xib 裏來,代碼就顯得更加簡潔了。

IBDesignable

xib 配合 IBInspectable 宏雖然可讓屬性設置變得簡單化,可是隻有在運行期間你才能看到控件的真正效果,而使用 IBDesignable 可讓 Interface Builder 實時渲染控件,這一切只須要在類名加上 IBDesignable 宏便可:

IB_DESIGNABLE @interface CustomButton : UIButton @property (nonatomic, strong) IBInspectable UIImage *highlightSelectedImage; @end


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

這樣一來,當你在 xib 中調整屬性的時候,畫布也會實時更新。

關於對 IBInspectable / IBDesignable 的詳細介紹能夠看這裏:http://nshipster.cn/ibinspectable-ibdesignable/ 
這是 Twitter 上其餘開發者作出的效果: 
這裏寫圖片描述 
這裏寫圖片描述

相信經過使用 IBInspectable / IBDesignable ,會讓控件使用起來更加方便、也更加有趣。


不規則圖形和事件觸發範圍

不規則圖形在 iOS 上並很少見,想來設計師也怕麻煩。不過 iOS 上的控件說到底都是各式各樣的矩形,就算你修改 cornerRadius,讓它看起來像這樣: 
這裏寫圖片描述

也只是看起來像這樣罷了,它的實際事件觸發範圍仍是一個矩形。

問題描述

想象一個複雜的可交互的控件,它並非單獨工做的,可能須要和另外一個控件交互,並且它們的事件觸發範圍可能會重疊,像這個選擇聯繫人的列表:

這裏寫圖片描述

在設計的時候讓上面二級菜單在最大的範圍內能夠被點擊,下面的一級菜單也能在本身的範圍內很好的工做,正常狀況下它們的觸發範圍是這樣的:

這裏寫圖片描述

咱們想要的是這樣的:

這裏寫圖片描述

想要實現這樣的效果須要對事件分發有必定的瞭解。首先咱們來想一想,當觸摸屏幕的時候發生了什麼?

當觸摸屏幕的時候發生了什麼?

當屏幕接收到一個 touch 的時候,iOS 須要找到一個合適的對象來處理事件( touch 或者手勢),要尋找這個對象,須要用到這個方法:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;


   

   

    
    
    
    
    

   
  • 1

該方法會首先在 application 的 keyWindow 上調用(UIWindow 也是 UIView 的子類),而且該方法的返回值將被用來處理事件。若是這個 view(不管是 window 仍是普通的 UIView) 的 userInteractionEnabled 屬性被設置爲 NO,則它的 hitTest: 永遠返回 nil,這意味着它和它的子視圖沒有機會去接收和處理事件。若是 userInteractionEnabled 屬性爲 YES,則會先判斷產生觸摸的 point 是否發生在本身的 bounds內,若是沒有也將返回 nil;若是 point 在本身的範圍內,則會爲本身的每一個子視圖調用 hitTest: 方法,只要有一個子視圖經過這個方法返回一個 UIView 對象,那麼整個方法就一層一層地往上返回;若是沒有子視圖返回 UIView 對象,則父視圖將會把本身返回。

因此,在事件分發中,有這麼幾個關鍵點:

  1. 若是父視圖不能響應事件(userInteractionEnabled 爲 NO),則其子視圖也將沒法響應事件。
  2. 若是子視圖的 frame 有一半在外面,就像這樣: 
    這裏寫圖片描述 
    則在外面的部分是沒法響應事件的,由於它超出了父視圖的範圍。
  3. 整個事件鏈只會返回一個 Hit-Test View 來處理事件。
  4. 子視圖的順序會影響到 Hit-Test View 的選擇:最早經過 hitTest: 方法返回的 UIView 纔會被返回,假若有兩個子視圖平級,而且它們的 frame 同樣,可是誰是後添加的誰就優先返回。

瞭解了事件分發的這些特色後,還須要知道最後一件事:UIView 如何判斷產生事件的 point 是否在本身的範圍內? 答案是經過 pointInside 方法,這個方法的默認實現相似於這樣:

// point 被轉化爲對應視圖的座標系統 - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event { return CGRectContainsPoint(self.bounds, point);
}


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4

因此,當咱們想改變一個 View 的事件觸發範圍的時候,重寫 pointInside 方法就能夠了。

回到問題

針對這種視圖必定要處理它們的事件觸發範圍,也就是 pointInside 方法,通常來講,咱們先判斷 point 是否是在本身的範圍內(經過調用 super 來判斷),而後再判斷該 point 符不符合咱們的處理要求:

這個例子我用 Swift 來寫

override func pointInside(point: CGPoint, withEvent event: UIEvent?) -> Bool { let inside = super.pointInside(point, withEvent: event) if inside { let radius = self.layer.cornerRadius let dx = point.x - self.bounds.size.width / 2 let dy = point.y - radius let distace = sqrt(dx * dx + dy * dy) return distace < radius
    } return inside
}


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

若是你要實現非矩形的控件,那麼請在開發時處理好這類問題。 
這裏附上一個很容易測試的小 Demo:

class CustomView: UIControl { override init(frame: CGRect) { super.init(frame: frame) self.backgroundColor = UIColor.redColor() } required init(coder aDecoder: NSCoder) { super.init(coder: aDecoder) self.backgroundColor = UIColor.redColor() } override func layoutSubviews() { super.layoutSubviews() self.layer.cornerRadius = self.bounds.size.width / 2 } override func beginTrackingWithTouch(touch: UITouch, withEvent event: UIEvent) -> Bool { self.backgroundColor = UIColor.grayColor() return super.beginTrackingWithTouch(touch, withEvent: event) } override func endTrackingWithTouch(touch: UITouch, withEvent event: UIEvent) { super.endTrackingWithTouch(touch, withEvent: event) self.backgroundColor = UIColor.redColor() } override func pointInside(point: CGPoint, withEvent event: UIEvent?) -> Bool { let inside = super.pointInside(point, withEvent: event) if inside { let radius = self.layer.cornerRadius let dx = point.x - self.bounds.size.width / 2 let dy = point.y - radius let distace = sqrt(dx * dx + dy * dy) return distace < radius } return inside } }


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48

合理使用 KVO

某些視圖的接口比較寶貴,被你用掉後外部的使用者就沒法使用了,好比 UITextField 的 delegate,好在 UITextField 還提供了通知和 UITextInput 方法可使用;像 UIScrollView 或者基於 UIScrollView 的控件,你既不能設置它的 delegate,又沒有其餘的替代方法可使用,對於像如下這種須要根據某些屬性實時更新的控件來講,KVO 真是極好的:

這是一個動態高度 Header 的例子(DKStickyHeaderView): 

這個是一個固定在 Bottom 的例子(DKStickyFooterView): 

二者都是基於 UIScrollView、基於 KVO ,不依賴外部參數:

override func observeValueForKeyPath(keyPath: String, ofObject object: AnyObject, change: [NSObject : AnyObject], context: UnsafeMutablePointer<Void>) {
    if keyPath == KEY_PATH_CONTENTOFFSET {
        let scrollView = self.superview as! UIScrollView

        var delta: CGFloat = 0.0 if scrollView.contentOffset.y < 0.0 {
            delta = fabs(min(0.0, scrollView.contentOffset.y))
        }

        var newFrame = self.frame newFrame.origin.y = -delta
        newFrame.size.height = self.minHeight + delta
        self.frame = newFrame
    } else {
        super.observeValueForKeyPath(keyPath, ofObject: object, change: change, context: context)
    }
}


   

   

    
    
    
    
    

   
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

對容器類的 ViewController 來講也同樣有用。在 iOS8 以前沒有 UIContentContainer 這個正式協議,若是你要實現一個很長的、非列表、可滾動的 ViewController,那麼你可能會將其中的功能分散到幾個 ChildViewController 裏,而後把它們組合起來,這樣一來,這些 ChildViewController 既能被單獨做爲一個 ViewController 展現,也能夠被組合到一塊兒。做爲組合到一塊兒的前提,就是須要一個至少有如下兩個方法的協議:

  1. 提供一個統一的輸入源,大可能是一個 Model 或者像 userId 這樣的
  2. 可以返回你所須要的高度,好比設置 preferredContentSize 屬性

ChildViewController 動態地設置 contentSize,容器監聽 contentSize 的變化動態地設置約束或者 frame。


歡迎補充和討論

相關文章
相關標籤/搜索