前言
一個控件從外在特征來說,主要是封裝這幾點:
交互方式
顯示樣式
數據使用
對外在特征的封裝,能讓我們在多種環境下達到 PM 對產品的要求,並且提到代碼復用率,使維護工作保持在一個相對較小的范圍內;而一個好的控件除了有對外一致的體驗之外,還有其內在特征:
靈活性
低耦合
易拓展
易維護
通常特征之間需要做一些取捨,比如靈活性與耦合度,有時候接口越多越能適應各種環境,但是接口越少對外產生的依賴就越少,維護起來也更容易。通常一些前期看起來還不錯的代碼,往往也會隨著時間加深慢慢“成長”,功能的增加也會帶來新的接口,很不自覺地就加深了耦合度,在開發中時不時地進行一些重構工作很有必要。總之,盡量減少接口的數量,但有足夠的定制空間,可以在一開始把接口全部隱藏起來,再根據實際需要慢慢放開。
自定義控件在 iOS 項目裡很常見,通常頁面之間入口很多,而且使用場景極有可能大不相同,比如一個 UIView 既可以以代碼初始化,也可以以 xib 的形式初始化,而我們是需要保證這兩種操作都能產生同樣的行為。本文將會討論到以下幾點:
選擇正確的初始化方式
調整布局的時機
正確的處理 touches 方法
drawRectCALayer 與動畫
UIControl 與 UIButton
更友好的支持 xib
不規則圖形和事件觸發范圍(事件鏈的簡單介紹以及處理)
合理使用 KVO
如果這些問題你一看就懂的話就不用繼續往下看了。
設計方針
選擇正確的初始化方式
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 中加載
當控件從 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,把設置子視圖 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 約束
如果你是基於 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
如果你的控件對尺寸有嚴格的限定,比如有一個統一的寬高比或者是固定尺寸,那麼最好能實現系統給出的約定成俗的接口。
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];
在 ViewController 裡調整視圖布局
當執行 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 方法
如果你需要重寫 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、CALayer 與動畫
drawRect 方法很適合做自定義的控件,當你需要更新 UI 的時候,只要用 setNeedsDisplay 標記一下就行了,這麼做又簡單又方便;控件也常常用於封裝動畫,但是動畫卻有可能被移除掉。
需要注意的地方:
1. 在 drawRect 裡盡量用 CGContext 繪制 UI。如果你用 addSubview 插入了其他的視圖,那麼當系統在每次進入繪制的時候,會先把當前的上下文清除掉(此處不考慮 clearsContextBeforeDrawing 的影響),然後你也要清除掉已有的 subviews,以免重復添加視圖;用戶可能會往你的控件上添加他自己的子視圖,然後在某個情況下清除所有的子視圖(我就喜歡這麼做):
[subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
2. 用 CALayer 代替 UIView。CALayer 節省內存,而且更適合去做一個“圖層”,因為它不會接收事件、也不會成為響應鏈中的一員,但是它能夠響應父視圖(或 layer)的尺寸變化,這種特性很適合做單純的數據展示:
CALayer *imageLayer = [CALayer layer]; imageLayer.frame = rect; imageLayer.contents = (id)image; [self.view.layer addSublayer:imageLayer];
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;
我猜測是在 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;
這個基類可以很方便地為視圖添加各種點擊狀態,最常見的用法就是將 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
你的控件現在應該可以正確的從文件、代碼中初始化了,但是從 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]; }
只需要在屬性上加個 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
這樣一來,當你在 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 } }
合理使用 KVO
某些視圖的接口比較寶貴,被你用掉後外部的使用者就無法使用了,比如 UITextField 的 delegate,好在 UITextField 還提供了通知和 UITextInput 方法可以使用;像 UIScrollView 或者基於 UIScrollView 的控件,你既不能設置它的 delegate,又沒有其他的替代方法可以使用,對於像以下這種需要根據某些屬性實時更新的控件來說,KVO 真是極好的:
這是一個動態高度 Header 的例子(DKStickyHeaderView):
兩者都是基於 UIScrollView、基於 KVO ,不依賴外部參數:
override func observeValueForKeyPath(keyPath: String, ofObject object: AnyObject, change: [NSObject : AnyObject], context: UnsafeMutablePointer) { 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。
歡迎補充和討論