代碼的等級:可編譯、可運行、可測試、可讀、可維護、可複用 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;
在實現中必定要調用父類的 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; }
而且你要考慮到,由於你的控件是繼承自 UIView 或 UIControl 的,那麼用戶徹底能夠不使用你提供的構造器,而直接調用基類的構造器,因此最好重寫父類的 Designated Initializer,使它調用你提供的 Designated Initializer ,好比父類是個 UIView:
- (instancetype)initWithFrame:(CGRect)frame { self = [self initWithName:nil frame:frame]; return self; }
這樣當用戶從代碼裏初始化你的控件的時候,就老是逃脫不了你須要執行的初始化代碼了,哪怕用戶直接調用 init 方法,最終仍是會回到父類的 Designated Initializer 上。
當控件從 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);
執行 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,而後添加到視圖層級上
你的本意是在控件的最下面放置一個背景,卻有可能將這個背景覆蓋到控件的最上方,緣由是用戶可能會在 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 ... }
awakeFromNib 方法裏就不要再去調用 commonInit 了。
當一個控件被初始化以及開始使用以後,它的 frame 仍然可能發生變化,咱們也須要接受這些變化,由於你提供的是 UIView 的接口,UIView 有不少種初始化方式:initWithFrame、initWithCoder、init 和類方法 new,用戶徹底能夠在初始化以後再設置 frame 屬性,並且用戶就算使用 initWithFrame 來初始化也避免不了 frame 的改變,好比在橫豎屏切換的時候。爲了確保當它的 Size 發生變化後其子視圖也能同步更新,咱們不能一開始就把佈局寫死(使用約束除外)。
若是你是直接基於 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; }
這麼作就能保證 label 老是出如今正確的位置上。
使用 layoutSubviews 方法有幾點須要注意:
- 不要依賴前一次的計算結果,應該老是根據當前最新值來計算
- 因爲 layoutSubviews 方法是在自身的 bounds 發生改變的時候調用, 所以 UIScrollView 會在滾動時不停地調用,當你只關心 Size 有沒有變化的時候,能夠把前一次的 Size 保存起來,經過與最新的 Size 比較來判斷是否須要更新,在大多數狀況下都能改善性能
若是你是基於 Auto Layout 約束來進行佈局,那麼能夠在 commonInit 調用的時候就把約束添加上去,不要重寫 layoutSubviews 方法,由於這種狀況下它的默認實現就是根據約束來計算 frame。最重要的一點,把 translatesAutoresizingMaskIntoConstraints 屬性設爲 NO,以避免產生 NSAutoresizingMaskLayoutConstraint 約束,若是你使用 Masonry 框架的話,則不用擔憂這個問題,mas_makeConstraints 方法會首先設置這個屬性爲 NO:
- (void)commonInit { ... [self setupConstraintsForSubviews]; } - (void)setupConstraintsForSubviews { [self.label mas_makeConstraints:^(MASConstraintMaker *make) { ... }]; }
若是你的控件對尺寸有嚴格的限定,好比有一個統一的寬高比或者是固定尺寸,那麼最好能實現系統給出的約定成俗的接口。
sizeToFit 用在基於 frame 佈局的狀況下,由你的控件去實現 sizeThatFits: 方法:
- (CGSize)sizeThatFits:(CGSize)size { CGSize fitSize = [super sizeThatFits:size]; fitSize.height += self.label.frame.size.height; // 若是是固定尺寸,就像 UISwtich 那樣返回一個固定 Size 就 OK 了 return fitSize; }
而後在外部調用該控件的 sizeToFit 方法,這個方法內部會自動調用 sizeThatFits 並更新自身的 Size:
[self.customView sizeToFit];
當執行 viewDidLoad 方法時,不要依賴 self.view 的 Size。不少人會這樣寫:
- (void)viewDidLoad { ... self.label.width = self.view.width; }
這樣是不對的,哪怕看上去沒問題也只是碰巧沒問題而已。當 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; }
或者在 viewDidLayoutSubviews 裏處理:
- (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; self.label.width = self.view.width; }
若是是基於 Auto Layout 來佈局,則在 viewDidLoad 裏添加約束便可。
若是你須要重寫 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;
當你的視圖在這四個方法執行的時候,若是已經對事件進行了處理,就不要再調用 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...
這麼實現之後,當你僅僅只是「碰」一個 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 方法很適合作自定義的控件,當你須要更新 UI 的時候,只要用 setNeedsDisplay 標記一下就好了,這麼作又簡單又方便;控件也經常用於封裝動畫,可是動畫卻有可能被移除掉。
須要注意的地方:
在 drawRect 裏儘可能用 CGContext 繪製 UI。若是你用 addSubview 插入了其餘的視圖,那麼當系統在每次進入繪製的時候,會先把當前的上下文清除掉(此處不考慮 clearsContextBeforeDrawing 的影響),而後你也要清除掉已有的 subviews,以避免重複添加視圖;用戶可能會往你的控件上添加他本身的子視圖,而後在某個狀況下清除全部的子視圖(我就喜歡這麼作):
[subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
用 CALayer 代替 UIView。CALayer 節省內存,並且更適合去作一個「圖層」,由於它不會接收事件、也不會成爲響應鏈中的一員,可是它可以響應父視圖(或 layer)的尺寸變化,這種特性很適合作單純的數據展現:
CALayer *imageLayer = [CALayer layer]; imageLayer.frame = rect; imageLayer.contents = (id)image; [self.view.layer addSublayer:imageLayer];
若是有可能的話使用 setNeedsDisplayInRect 代替 setNeedsDisplay 以優化性能,可是遇到性能問題的時候應該先檢查本身的繪圖算法和繪圖時機,我我的其實歷來沒有使用過 setNeedsDisplayInRect。
當你想作一個無限循環播放的動畫的時候,可能會建立幾個封裝了動畫的 CALayer,而後把它們添加到視圖層級上,就像我在 iOS 實現脈衝雷達以及動態增減元素 By Swift 中這麼作的:
效果還不錯,實現又簡單,可是當你按下 Home 鍵並再次返回到 app 的時候,本來好看的動畫就變成了一灘死水:
這是由於在按下 Home 鍵的時候,全部的動畫被移除了,具體的,每一個 layer 都調用了 removeAllAnimations 方法。
若是你想從新播放動畫,能夠監聽 UIApplicationDidBecomeActiveNotification 通知,就像我在 上述博客 中作的那樣。
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.
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;
我猜想是在 CALayer 的 setContents 方法裏有個標記,不管傳入的對象是什麼都會將該標記打開,可是調用 setNeedsDisplay 的時候會將該標記去除。
若是要作一個可交互的控件,那麼把 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;
這個基類能夠很方便地爲視圖添加各類點擊狀態,最多見的用法就是將 UIViewController 的 view 改爲 UIControl,而後就能快速實現 resignFirstResponder。
UIButton 自帶圖文接口,支持更強大的狀態切換,titleEdgeInsets 和 imageEdgeInsets 也比較好用,配合兩個基類的屬性更好,先設置對齊規則,再設置 insets:
@property(nonatomic) UIControlContentVerticalAlignment contentVerticalAlignment; @property(nonatomic) UIControlContentHorizontalAlignment contentHorizontalAlignment;
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]; }
或者使用初始化設置:
- (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]; }
總之儘可能使用原生類的接口,或者模仿原生類的接口。
大多數狀況下根據你所須要的特性來選擇現有的基類就夠了,或者用 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
你的控件如今應該能夠正確的從文件、代碼中初始化了,可是從 xib 中初始化之後可能還須要經過代碼來進行一些設置,你或許以爲像上面那樣設置 Button 的狀態很噁心並且不夠直觀,可是也沒辦法,這是因爲 xib 雖然對原生控件,如 UIView、UIImageView、UIScrollView 等支持較好(想設置圓角、邊框等屬性也沒辦法,只能經過 layer 來設置),可是對自定義控件卻沒有什麼辦法,當你拖一個 UIView 到 xib 中,而後把它的 Class 改爲你本身的子類後,xib 如同一個瞎子同樣,不會有任何變化。————好在這些都成了過去。
Xcode 6 引入了兩個新的宏:IBInspectable 和 IBDesignable。
該宏會讓 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]; }
只須要在屬性上加個 IBInspectable 宏便可,而後 xib 中就能顯示這個自定義的屬性:
xib 會把屬性名以大駝峯樣式顯示,若是有多個屬性,xib 也會自動按屬性名的第一個單詞分組顯示,如:
經過使用 IBInspectable 宏,你能夠把本來只能經過代碼來設置的屬性,也放到 xib 裏來,代碼就顯得更加簡潔了。
xib 配合 IBInspectable 宏雖然可讓屬性設置變得簡單化,可是隻有在運行期間你才能看到控件的真正效果,而使用 IBDesignable 可讓 Interface Builder 實時渲染控件,這一切只須要在類名加上 IBDesignable 宏便可:
IB_DESIGNABLE @interface CustomButton : UIButton @property (nonatomic, strong) IBInspectable UIImage *highlightSelectedImage; @end
這樣一來,當你在 xib 中調整屬性的時候,畫布也會實時更新。
關於對 IBInspectable / IBDesignable 的詳細介紹能夠看這裏:http://nshipster.cn/ibinspectable-ibdesignable/
這是 Twitter 上其餘開發者作出的效果:
相信經過使用 IBInspectable / IBDesignable ,會讓控件使用起來更加方便、也更加有趣。
不規則圖形在 iOS 上並很少見,想來設計師也怕麻煩。不過 iOS 上的控件說到底都是各式各樣的矩形,就算你修改 cornerRadius,讓它看起來像這樣:
也只是看起來像這樣罷了,它的實際事件觸發範圍仍是一個矩形。
想象一個複雜的可交互的控件,它並非單獨工做的,可能須要和另外一個控件交互,並且它們的事件觸發範圍可能會重疊,像這個選擇聯繫人的列表:
在設計的時候讓上面二級菜單在最大的範圍內能夠被點擊,下面的一級菜單也能在本身的範圍內很好的工做,正常狀況下它們的觸發範圍是這樣的:
咱們想要的是這樣的:
想要實現這樣的效果須要對事件分發有必定的瞭解。首先咱們來想一想,當觸摸屏幕的時候發生了什麼?
當屏幕接收到一個 touch 的時候,iOS 須要找到一個合適的對象來處理事件( touch 或者手勢),要尋找這個對象,須要用到這個方法:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;
該方法會首先在 application 的 keyWindow 上調用(UIWindow 也是 UIView 的子類),而且該方法的返回值將被用來處理事件。若是這個 view(不管是 window 仍是普通的 UIView) 的 userInteractionEnabled 屬性被設置爲 NO,則它的 hitTest: 永遠返回 nil,這意味着它和它的子視圖沒有機會去接收和處理事件。若是 userInteractionEnabled 屬性爲 YES,則會先判斷產生觸摸的 point 是否發生在本身的 bounds內,若是沒有也將返回 nil;若是 point 在本身的範圍內,則會爲本身的每一個子視圖調用 hitTest: 方法,只要有一個子視圖經過這個方法返回一個 UIView 對象,那麼整個方法就一層一層地往上返回;若是沒有子視圖返回 UIView 對象,則父視圖將會把本身返回。
因此,在事件分發中,有這麼幾個關鍵點:
- 若是父視圖不能響應事件(userInteractionEnabled 爲 NO),則其子視圖也將沒法響應事件。
- 若是子視圖的 frame 有一半在外面,就像這樣:
則在外面的部分是沒法響應事件的,由於它超出了父視圖的範圍。- 整個事件鏈只會返回一個 Hit-Test View 來處理事件。
- 子視圖的順序會影響到 Hit-Test View 的選擇:最早經過 hitTest: 方法返回的 UIView 纔會被返回,假若有兩個子視圖平級,而且它們的 frame 同樣,可是誰是後添加的誰就優先返回。
瞭解了事件分發的這些特色後,還須要知道最後一件事:UIView 如何判斷產生事件的 point 是否在本身的範圍內? 答案是經過 pointInside 方法,這個方法的默認實現相似於這樣:
// point 被轉化爲對應視圖的座標系統 - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event { return CGRectContainsPoint(self.bounds, point); }
因此,當咱們想改變一個 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 }
若是你要實現非矩形的控件,那麼請在開發時處理好這類問題。
這裏附上一個很容易測試的小 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 } }
某些視圖的接口比較寶貴,被你用掉後外部的使用者就沒法使用了,好比 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) } }
對容器類的 ViewController 來講也同樣有用。在 iOS8 以前沒有 UIContentContainer 這個正式協議,若是你要實現一個很長的、非列表、可滾動的 ViewController,那麼你可能會將其中的功能分散到幾個 ChildViewController 裏,而後把它們組合起來,這樣一來,這些 ChildViewController 既能被單獨做爲一個 ViewController 展現,也能夠被組合到一塊兒。做爲組合到一塊兒的前提,就是須要一個至少有如下兩個方法的協議:
- 提供一個統一的輸入源,大可能是一個 Model 或者像 userId 這樣的
- 可以返回你所須要的高度,好比設置 preferredContentSize 屬性
ChildViewController 動態地設置 contentSize,容器監聽 contentSize 的變化動態地設置約束或者 frame。
歡迎補充和討論