如何設(shè)計(jì)一個(gè) iOS 控件?(iOS 控件完全解析)

http://www.cocoachina.com/cms/wap.php?action=article&id=14394

http://www.iosfly.com/2013/10/21/initwithnibname-initwithcoder-awakefromnib-loadnibnamed/

前言

一個(gè)控件從外在特征來說,主要是封裝這幾點(diǎn):

交互方式

顯示樣式

數(shù)據(jù)使用

對(duì)外在特征的封裝,能讓我們?cè)诙喾N環(huán)境下達(dá)到 PM 對(duì)產(chǎn)品的要求惫叛,并且提到代碼復(fù)用率,使維護(hù)工作保持在一個(gè)相對(duì)較小的范圍內(nèi)山害;而一個(gè)好的控件除了有對(duì)外一致的體驗(yàn)之外纠俭,還有其內(nèi)在特征:

靈活性

低耦合

易拓展

易維護(hù)

通常特征之間需要做一些取舍,比如靈活性與耦合度浪慌,有時(shí)候接口越多越能適應(yīng)各種環(huán)境冤荆,但是接口越少對(duì)外產(chǎn)生的依賴就越少,維護(hù)起來也更容易权纤。通常一些前期看起來還不錯(cuò)的代碼钓简,往往也會(huì)隨著時(shí)間加深慢慢“成長(zhǎng)”,功能的增加也會(huì)帶來新的接口妖碉,很不自覺地就加深了耦合度涌庭,在開發(fā)中時(shí)不時(shí)地進(jìn)行一些重構(gòu)工作很有必要芥被∨芬耍總之,盡量減少接口的數(shù)量拴魄,但有足夠的定制空間冗茸,可以在一開始把接口全部隱藏起來,再根據(jù)實(shí)際需要慢慢放開匹中。

自定義控件在 iOS 項(xiàng)目里很常見夏漱,通常頁面之間入口很多,而且使用場(chǎng)景極有可能大不相同顶捷,比如一個(gè) UIView 既可以以代碼初始化挂绰,也可以以 xib 的形式初始化,而我們是需要保證這兩種操作都能產(chǎn)生同樣的行為服赎。本文將會(huì)討論到以下幾點(diǎn):

選擇正確的初始化方式

調(diào)整布局的時(shí)機(jī)

正確的處理 touches 方法

drawRectCALayer 與動(dòng)畫

UIControl 與 UIButton

更友好的支持 xib

不規(guī)則圖形和事件觸發(fā)范圍(事件鏈的簡(jiǎn)單介紹以及處理)

合理使用 KVO

如果這些問題你一看就懂的話就不用繼續(xù)往下看了葵蒂。

設(shè)計(jì)方針

選擇正確的初始化方式

UIView 的首要問題就是既能從代碼中初始化,也能從 xib 中初始化重虑,兩者有何不同? UIView 是支持 NSCoding 協(xié)議的践付,當(dāng)在 xib 或 storyboard 里存在一個(gè) UIView 的時(shí)候,其實(shí)是將 UIView 序列化到文件里(xib 和 storyboard 都是以 XML 格式來保存的)缺厉,加載的時(shí)候反序列化出來永高,所以:

當(dāng)從代碼實(shí)例化 UIView 的時(shí)候,initWithFrame 會(huì)執(zhí)行提针;

當(dāng)從文件加載 UIView 的時(shí)候命爬,initWithCoder 會(huì)執(zhí)行。

從代碼中加載

雖然 initWithFrame 是 UIView 的Designated Initializer辐脖,理論上來講你繼承自 UIView 的任何子類饲宛,該方法最終都會(huì)被調(diào)用,但是有一些類在初始化的時(shí)候沒有遵守這個(gè)約定揖曾,如 UIImageView 的 initWithImage 和 UITableViewCell 的 initWithStyle:reuseIdentifier: 的構(gòu)造器等落萎,所以我們?cè)趯懽远x控件的時(shí)候亥啦,最好只假設(shè)父視圖的 Designated Initializer 被調(diào)用。

如果控件在初始化或者在使用之前必須有一些參數(shù)要設(shè)置练链,那我們可以寫自己的 Designated Initializer 構(gòu)造器翔脱,如:

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

在實(shí)現(xiàn)中一定要調(diào)用父類的 Designated Initializer,而且如果你有多個(gè)自定義的 Designated Initializer媒鼓,最終都應(yīng)該指向一個(gè)全能的初始化構(gòu)造器:

- (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;

}

并且你要考慮到届吁,因?yàn)槟愕目丶抢^承自 UIView 或 UIControl 的,那么用戶完全可以不使用你提供的構(gòu)造器绿鸣,而直接調(diào)用基類的構(gòu)造器疚沐,所以最好重寫父類的 Designated Initializer,使它調(diào)用你提供的 Designated Initializer 潮模,比如父類是個(gè) UIView:

- (instancetype)initWithFrame:(CGRect)frame {

self = [self initWithName:nil frame:frame];

return self;

}

這樣當(dāng)用戶從代碼里初始化你的控件的時(shí)候亮蛔,就總是逃脫不了你需要執(zhí)行的初始化代碼了,哪怕用戶直接調(diào)用 init 方法擎厢,最終還是會(huì)回到父類的 Designated Initializer 上究流。

從 xib 或 storyboard 中加載

當(dāng)控件從 xib 或 storyboard 中加載的時(shí)候,情況就變得復(fù)雜了动遭,首先我們知道有 initWithCoder 方法芬探,該方法會(huì)在對(duì)象被反序列化的時(shí)候調(diào)用,比如從文件加載一個(gè) UIView 的時(shí)候:

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);

執(zhí)行 unarchiveObjectWithData 的時(shí)候厘惦, initWithCoder 會(huì)被調(diào)用偷仿,那么你有可能會(huì)在這個(gè)方法里做一些初始化工作,比如恢復(fù)到保存之前的狀態(tài)宵蕉,當(dāng)然前提是需要在 encodeWithCoder 中預(yù)先保存下來酝静。

不過我們很少會(huì)自己直接把一個(gè) View 保存到文件中,一般是在 xib 或 storyboard 中寫一個(gè) View国裳,然后讓系統(tǒng)來完成反序列化的工作形入,此時(shí)在 initWithCoder 調(diào)用之后,awakeFromNib 方法也會(huì)被執(zhí)行缝左,既然在 awakeFromNib 方法里也能做初始化操作,那我們?nèi)绾尉駬?

一般來說要盡量在 initWithCoder 中做初始化操作渺杉,畢竟這是最合理的地方蛇数,只要你的控件支持序列化,那么它就能在任何被反序列化的時(shí)候執(zhí)行初始化操作是越,這里適合做全局?jǐn)?shù)據(jù)耳舅、狀態(tài)的初始化工作,也適合手動(dòng)添加子視圖。

awakeFromNib 相較于 initWithCoder 的優(yōu)勢(shì)是:當(dāng) awakeFromNib 執(zhí)行的時(shí)候浦徊,各種 IBOutlet 也都連接好了馏予;而 initWithCoder 調(diào)用的時(shí)候,雖然子視圖已經(jīng)被添加到視圖層級(jí)中盔性,但是還沒有引用霞丧。如果你是基于 xib 或 storyboard 創(chuàng)建的控件,那么你可能需要對(duì) IBOutlet 連接的子控件進(jìn)行初始化工作冕香,這種情況下蛹尝,你只能在 awakeFromNib 里進(jìn)行處理。同時(shí) xib 或 storyboard 對(duì)靈活性是有打折的悉尾,因?yàn)樗鼈儎?chuàng)建的代碼無法被繼承突那,所以當(dāng)你選擇用 xib 或 storyboard 來實(shí)現(xiàn)一個(gè)控件的時(shí)候,你已經(jīng)不需要對(duì)靈活性有很高的要求了构眯,唯一要做的是要保證用戶一定是通過 xib 創(chuàng)建的此控件愕难,否則可能是一個(gè)空的視圖,可以在 initWithFrame 里放置一個(gè) 斷言 或者異常來通知控件的用戶鸵赖。

最后還要注意視圖層級(jí)的問題务漩,比如你要給 View 放置一個(gè)背景,你可能會(huì)在 initWithCoder 或 awakeFromNib 中這樣寫:

[self addSubview:self.backgroundView]; // 通過懶加載一個(gè)背景 View它褪,然后添加到視圖層級(jí)上

你的本意是在控件的最下面放置一個(gè)背景,卻有可能將這個(gè)背景覆蓋到控件的最上方翘悉,原因是用戶可能會(huì)在 xib 里寫入這個(gè)控件茫打,然后往它上面添加一些子視圖,這樣一來妖混,用戶添加的這些子視圖會(huì)在你添加背景之前先進(jìn)入視圖層級(jí)老赤,你的背景被添加后就擋住了用戶的子視圖。如果你想支持用戶的這種操作制市,可以把 addSubview 替換成 insertSubview:atIndex:抬旺。

同時(shí)支持從代碼和文件中加載

如果你要同時(shí)支持 initWithFrame 和 initWithCoder ,那么你可以提供一個(gè) commonInit 方法來做統(tǒng)一的初始化:

- (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 方法里就不要再去調(diào)用 commonInit 了祥楣。

調(diào)整布局的時(shí)機(jī)

當(dāng)一個(gè)控件被初始化以及開始使用之后开财,它的 frame 仍然可能發(fā)生變化,我們也需要接受這些變化误褪,因?yàn)槟闾峁┑氖?UIView 的接口责鳍,UIView 有很多種初始化方式:initWithFrame、initWithCoder兽间、init 和類方法 new历葛,用戶完全可以在初始化之后再設(shè)置 frame 屬性,而且用戶就算使用 initWithFrame 來初始化也避免不了 frame 的改變嘀略,比如在橫豎屏切換的時(shí)候恤溶。為了確保當(dāng)它的 Size 發(fā)生變化后其子視圖也能同步更新乓诽,我們不能一開始就把布局寫死(使用約束除外)。

基于 frame

如果你是直接基于 frame 來布局的咒程,你應(yīng)該確保在初始化的時(shí)候只添加視圖问裕,而不去設(shè)置它們的frame,把設(shè)置子視圖 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 總是出現(xiàn)在正確的位置上孵坚。

使用 layoutSubviews 方法有幾點(diǎn)需要注意:

不要依賴前一次的計(jì)算結(jié)果粮宛,應(yīng)該總是根據(jù)當(dāng)前最新值來計(jì)算

由于 layoutSubviews 方法是在自身的 bounds 發(fā)生改變的時(shí)候調(diào)用, 因此 UIScrollView 會(huì)在滾動(dòng)時(shí)不停地調(diào)用卖宠,當(dāng)你只關(guān)心 Size 有沒有變化的時(shí)候巍杈,可以把前一次的 Size 保存起來,通過與最新的 Size 比較來判斷是否需要更新扛伍,在大多數(shù)情況下都能改善性能

基于 Auto Layout 約束

如果你是基于 Auto Layout 約束來進(jìn)行布局筷畦,那么可以在 commonInit 調(diào)用的時(shí)候就把約束添加上去,不要重寫 layoutSubviews 方法刺洒,因?yàn)檫@種情況下它的默認(rèn)實(shí)現(xiàn)就是根據(jù)約束來計(jì)算 frame鳖宾。最重要的一點(diǎn),把 translatesAutoresizingMaskIntoConstraints 屬性設(shè)為 NO逆航,以免產(chǎn)生 NSAutoresizingMaskLayoutConstraint 約束鼎文,如果你使用 Masonry 框架的話,則不用擔(dān)心這個(gè)問題因俐,mas_makeConstraints 方法會(huì)首先設(shè)置這個(gè)屬性為 NO:

- (void)commonInit {

...

[self setupConstraintsForSubviews];

}

- (void)setupConstraintsForSubviews {

[self.label mas_makeConstraints:^(MASConstraintMaker *make) {

...

}];

}

支持 sizeToFit

如果你的控件對(duì)尺寸有嚴(yán)格的限定拇惋,比如有一個(gè)統(tǒng)一的寬高比或者是固定尺寸,那么最好能實(shí)現(xiàn)系統(tǒng)給出的約定成俗的接口抹剩。

sizeToFit 用在基于 frame 布局的情況下撑帖,由你的控件去實(shí)現(xiàn) sizeThatFits: 方法:

- (CGSize)sizeThatFits:(CGSize)size {

CGSize fitSize = [super sizeThatFits:size];

fitSize.height += self.label.frame.size.height;

// 如果是固定尺寸,就像 UISwtich 那樣返回一個(gè)固定 Size 就 OK 了

return fitSize;

}

然后在外部調(diào)用該控件的 sizeToFit 方法澳眷,這個(gè)方法內(nèi)部會(huì)自動(dòng)調(diào)用 sizeThatFits 并更新自身的 Size:

[self.customView sizeToFit];

在 ViewController 里調(diào)整視圖布局

當(dāng)執(zhí)行 viewDidLoad 方法時(shí)胡嘿,不要依賴 self.view 的 Size。很多人會(huì)這樣寫:

- (void)viewDidLoad {

...

self.label.width = self.view.width;

}

這樣是不對(duì)的钳踊,哪怕看上去沒問題也只是碰巧沒問題而已衷敌。當(dāng) viewDidLoad 方法被調(diào)用的時(shí)候,self.view 才剛剛被初始化箍土,此時(shí)它的容器還沒有對(duì)它的 frame 進(jìn)行設(shè)置逢享,如果 view 是從 xib 加載的,那么它的 Size 就是 xib 中設(shè)置的值吴藻;如果它是從代碼加載的瞒爬,那么它的 Size 和屏幕大小有關(guān)系,除了 Size 以外,Origin 也不會(huì)準(zhǔn)確侧但。整個(gè)過程看起來像這樣:

當(dāng)訪問 ViewController 的 view 的時(shí)候矢空,ViewController 會(huì)先執(zhí)行 loadViewIfRequired 方法,如果 view 還沒有加載禀横,則調(diào)用 loadView屁药,然后是 viewDidLoad 這個(gè)鉤子方法,最后是返回 view柏锄,容器拿到 view 后酿箭,根據(jù)自身的屬性(如 edgesForExtendedLayout、判斷是否存在 tabBar趾娃、判斷 navigationBar 是否透明等)添加約束或者設(shè)置 frame缭嫡。

你至少應(yīng)該設(shè)置 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 方法妇蛀,那么應(yīng)該完整的重寫這四個(gè)方法:

- (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;

當(dāng)你的視圖在這四個(gè)方法執(zhí)行的時(shí)候,如果已經(jīng)對(duì)事件進(jìn)行了處理笤成,就不要再調(diào)用 super 的 touches 方法评架,super 的 touches 方法默認(rèn)實(shí)現(xiàn)是在響應(yīng)鏈里繼續(xù)轉(zhuǎn)發(fā)事件(UIView 的默認(rèn)實(shí)現(xiàn))。如果你的基類是 UIScrollView 或者 UIButton 這些已經(jīng)重寫了事件處理的類炕泳,那么當(dāng)你不想處理事件的時(shí)候可以調(diào)用 self.nextResponder 的 touches 方法來轉(zhuǎn)發(fā)事件纵诞,其他的情況就調(diào)用 super 的 touches 方法來轉(zhuǎn)發(fā),比如 UIScrollView 可以這樣來轉(zhuǎn)發(fā) 觸摸 事件:

- (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...

這么實(shí)現(xiàn)以后喊崖,當(dāng)你僅僅只是“碰”一個(gè) UIScrollView 的時(shí)候挣磨,該事件就有可能被 nextResponder 處理。

如果你沒有實(shí)現(xiàn)自己的事件處理荤懂,也沒有調(diào)用 nextResponder 和 super,那么響應(yīng)鏈就會(huì)斷掉塘砸。另外节仿,盡量用手勢(shì)識(shí)別器去處理自定義事件,它的好處是你不需要關(guān)心響應(yīng)鏈掉蔬,邏輯處理起來也更加清晰廊宪,事實(shí)上,UIScrollView 也是通過手勢(shì)識(shí)別器實(shí)現(xiàn)的:

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

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

drawRect女轿、CALayer 與動(dòng)畫

drawRect 方法很適合做自定義的控件箭启,當(dāng)你需要更新 UI 的時(shí)候,只要用 setNeedsDisplay 標(biāo)記一下就行了蛉迹,這么做又簡(jiǎn)單又方便傅寡;控件也常常用于封裝動(dòng)畫,但是動(dòng)畫卻有可能被移除掉。

需要注意的地方:

1. 在 drawRect 里盡量用 CGContext 繪制 UI荐操。如果你用 addSubview 插入了其他的視圖芜抒,那么當(dāng)系統(tǒng)在每次進(jìn)入繪制的時(shí)候,會(huì)先把當(dāng)前的上下文清除掉(此處不考慮 clearsContextBeforeDrawing 的影響)托启,然后你也要清除掉已有的 subviews宅倒,以免重復(fù)添加視圖;用戶可能會(huì)往你的控件上添加他自己的子視圖屯耸,然后在某個(gè)情況下清除所有的子視圖(我就喜歡這么做):

[subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];

2. 用 CALayer 代替 UIView拐迁。CALayer 節(jié)省內(nèi)存,而且更適合去做一個(gè)“圖層”疗绣,因?yàn)樗粫?huì)接收事件线召、也不會(huì)成為響應(yīng)鏈中的一員,但是它能夠響應(yīng)父視圖(或 layer)的尺寸變化持痰,這種特性很適合做單純的數(shù)據(jù)展示:

CALayer *imageLayer = [CALayer layer];

imageLayer.frame = rect;

imageLayer.contents = (id)image;

[self.view.layer addSublayer:imageLayer];

3. 如果有可能的話使用 setNeedsDisplayInRect 代替 setNeedsDisplay 以優(yōu)化性能灶搜,但是遇到性能問題的時(shí)候應(yīng)該先檢查自己的繪圖算法和繪圖時(shí)機(jī),我個(gè)人其實(shí)從來沒有使用過 setNeedsDisplayInRect工窍。

4. 當(dāng)你想做一個(gè)無限循環(huán)播放的動(dòng)畫的時(shí)候割卖,可能會(huì)創(chuàng)建幾個(gè)封裝了動(dòng)畫的 CALayer,然后把它們添加到視圖層級(jí)上患雏,就像我在 iOS 實(shí)現(xiàn)脈沖雷達(dá)以及動(dòng)態(tài)增減元素 By Swift 中這么做的:

37.gif

效果還不錯(cuò)鹏溯,實(shí)現(xiàn)又簡(jiǎn)單,但是當(dāng)你按下 Home 鍵并再次返回到 app 的時(shí)候淹仑,原本好看的動(dòng)畫就變成了一灘死水:

這是因?yàn)樵诎聪?Home 鍵的時(shí)候丙挽,所有的動(dòng)畫被移除了,具體的匀借,每個(gè) layer 都調(diào)用了 removeAllAnimations 方法颜阐。

如果你想重新播放動(dòng)畫,可以監(jiān)聽 UIApplicationDidBecomeActiveNotification 通知吓肋,就像我在 上述博客 中做的那樣凳怨。

5. UIImageView 的 drawRect 永遠(yuǎn)不會(huì)被調(diào)用:

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 也不一定會(huì)調(diào)用,我在 12 年的博客:定制UINavigationBar 中曾經(jīng)提到過 UIKit 框架的實(shí)現(xiàn)機(jī)制:

眾所周知一個(gè)視圖如何顯示是取決于它的 drawRect 方法是鬼,因?yàn)檎{(diào)這個(gè)方法之前 UIKit 也不知道如何顯示它肤舞,但其實(shí) drawRect 方法的目的也是畫圖(顯示內(nèi)容),而且我們?nèi)绻云渌姆绞浇o出了內(nèi)容(圖)的話均蜜, drawRect 方法就不會(huì)被調(diào)用了李剖。

注:實(shí)際上 UIView 是 CALayer 的delegate,如果 CALayer 沒有內(nèi)容的話囤耳,會(huì)回調(diào)給 UIView 的 displayLayer: 或者 drawLayer:inContext: 方法篙顺,UIView 在其中調(diào)用 drawRect 偶芍,draw 完后的圖會(huì)緩存起來,除非使用 setNeedsDisplay 或是一些必要情況慰安,否則都是使用緩存的圖腋寨。

UIView 和 CALayer 都是模型對(duì)象,如果我們以這種方式給出內(nèi)容的話化焕,drawRect 也就不會(huì)被調(diào)用了:

self.customView.layer.contents = (id)[UIImage imageNamed:@"AppIcon"];

// 哪怕是給它一個(gè) nil萄窜,這兩句等價(jià)

self.customView.layer.contents = nil;

我猜測(cè)是在 CALayer 的 setContents 方法里有個(gè)標(biāo)記,無論傳入的對(duì)象是什么都會(huì)將該標(biāo)記打開撒桨,但是調(diào)用 setNeedsDisplay 的時(shí)候會(huì)將該標(biāo)記去除查刻。

UIControl 與 UIButton

如果要做一個(gè)可交互的控件,那么把 UIControl 作為基類就是首選凤类,這個(gè)完美的基類支持各種狀態(tài):

enabled

selected

highlighted

tracking

……

還支持多狀態(tài)下的觀察者模式:

@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;

這個(gè)基類可以很方便地為視圖添加各種點(diǎn)擊狀態(tài)穗泵,最常見的用法就是將 UIViewController 的 view 改成 UIControl,然后就能快速實(shí)現(xiàn) resignFirstResponder谜疤。

UIButton 自帶圖文接口佃延,支持更強(qiáng)大的狀態(tài)切換,titleEdgeInsets 和 imageEdgeInsets 也比較好用夷磕,配合兩個(gè)基類的屬性更好履肃,先設(shè)置對(duì)齊規(guī)則,再設(shè)置 insets:

@property(nonatomic) UIControlContentVerticalAlignment contentVerticalAlignment;

@property(nonatomic) UIControlContentHorizontalAlignment contentHorizontalAlignment;

UIControl 和 UIButton 都能很好的支持 xib坐桩,可以設(shè)置各種狀態(tài)下的顯示和 Selector尺棋,但是對(duì) UIButton 來說這些并不夠,因?yàn)?Normal 绵跷、Highlighted 和 Normal | Highlighted 是三種不同的狀態(tài)膘螟,如果你需要實(shí)現(xiàn)根據(jù)當(dāng)前狀態(tài)顯示不同高亮的圖片,可以參考我下面的代碼:

blob.png

- (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];

}

或者使用初始化設(shè)置:

- (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];

}

總之盡量使用原生類的接口碾局,或者模仿原生類的接口荆残。

大多數(shù)情況下根據(jù)你所需要的特性來選擇現(xiàn)有的基類就夠了,或者用 UIView + 手勢(shì)識(shí)別器 的組合也是一個(gè)好方案净当,盡量不要用 touches 方法(userInteractionEnabled 屬性對(duì) touches 和手勢(shì)識(shí)別器的作用一樣)脊阴,這是我在 DKCarouselView 中內(nèi)置的一個(gè)可點(diǎn)擊的 ImageView,也可以繼承 UIButton蚯瞧,不過 UIButton 更側(cè)重于狀態(tài),ImageView 側(cè)重于圖片本身:

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

你的控件現(xiàn)在應(yīng)該可以正確的從文件品擎、代碼中初始化了埋合,但是從 xib 中初始化以后可能還需要通過代碼來進(jìn)行一些設(shè)置,你或許覺得像上面那樣設(shè)置 Button 的狀態(tài)很惡心而且不夠直觀萄传,但是也沒辦法甚颂,這是由于 xib 雖然對(duì)原生控件蜜猾,如 UIView、UIImageView振诬、UIScrollView 等支持較好(想設(shè)置圓角蹭睡、邊框等屬性也沒辦法,只能通過 layer 來設(shè)置)赶么,但是對(duì)自定義控件卻沒有什么辦法肩豁,當(dāng)你拖一個(gè) UIView 到 xib 中,然后把它的 Class 改成你自己的子類后辫呻,xib 如同一個(gè)瞎子一樣清钥,不會(huì)有任何變化》殴耄————好在這些都成了過去祟昭。

Xcode 6 引入了兩個(gè)新的宏:IBInspectable 和 IBDesignable。

IBInspectable

該宏會(huì)讓 xib 識(shí)別屬性怖侦,它支持這些數(shù)據(jù)類型:布爾篡悟、字符串、數(shù)字(NSNumber)匾寝、 CGPoint搬葬、CGSize、CGRect旗吁、UIColor 踩萎、 NSRange 和 UIImage。

比如我們要讓自定義的 Button 能在 xib 中設(shè)置 UIControlStateSelected | UIControlStateHighlighted 狀態(tài)的圖片很钓,就可以這么做:

// CustomButton

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

- (void)setHighlightSelectedImage:(UIImage *)highlightSelectedImage {

_highlightSelectedImage = highlightSelectedImage;

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

}

只需要在屬性上加個(gè) IBInspectable 宏即可香府,然后 xib 中就能顯示這個(gè)自定義的屬性:

blob.png

xib 會(huì)把屬性名以大駝峰樣式顯示,如果有多個(gè)屬性码倦,xib 也會(huì)自動(dòng)按屬性名的第一個(gè)單詞分組顯示企孩,如:

blob.png

通過使用 IBInspectable 宏,你可以把原本只能通過代碼來設(shè)置的屬性袁稽,也放到 xib 里來勿璃,代碼就顯得更加簡(jiǎn)潔了。

IBDesignable

xib 配合 IBInspectable 宏雖然可以讓屬性設(shè)置變得簡(jiǎn)單化推汽,但是只有在運(yùn)行期間你才能看到控件的真正效果补疑,而使用 IBDesignable 可以讓 Interface Builder 實(shí)時(shí)渲染控件,這一切只需要在類名加上 IBDesignable 宏即可:

IB_DESIGNABLE

@interface CustomButton : UIButton

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

@end

這樣一來歹撒,當(dāng)你在 xib 中調(diào)整屬性的時(shí)候莲组,畫布也會(huì)實(shí)時(shí)更新。

關(guān)于對(duì) IBInspectable / IBDesignable 的詳細(xì)介紹可以看這里:http://nshipster.cn/ibinspectable-ibdesignable/

這是 Twitter 上其他開發(fā)者做出的效果:

blob.png

blob.png

相信通過使用 IBInspectable / IBDesignable 暖夭,會(huì)讓控件使用起來更加方便锹杈、也更加有趣撵孤。

不規(guī)則圖形和事件觸發(fā)范圍

不規(guī)則圖形在 iOS 上并不多見,想來設(shè)計(jì)師也怕麻煩竭望。不過 iOS 上的控件說到底都是各式各樣的矩形邪码,就算你修改 cornerRadius,讓它看起來像這樣:

blob.png

也只是看起來像這樣罷了咬清,它的實(shí)際事件觸發(fā)范圍還是一個(gè)矩形闭专。

問題描述

想象一個(gè)復(fù)雜的可交互的控件,它并不是單獨(dú)工作的枫振,可能需要和另一個(gè)控件交互喻圃,而且它們的事件觸發(fā)范圍可能會(huì)重疊,像這個(gè)選擇聯(lián)系人的列表:

38.gif

在設(shè)計(jì)的時(shí)候讓上面二級(jí)菜單在最大的范圍內(nèi)可以被點(diǎn)擊粪滤,下面的一級(jí)菜單也能在自己的范圍內(nèi)很好的工作斧拍,正常情況下它們的觸發(fā)范圍是這樣的:

blob.png

我們想要的是這樣的:

blob.png

想要實(shí)現(xiàn)這樣的效果需要對(duì)事件分發(fā)有一定的了解。首先我們來想想杖小,當(dāng)觸摸屏幕的時(shí)候發(fā)生了什么?

當(dāng)觸摸屏幕的時(shí)候發(fā)生了什么?

當(dāng)屏幕接收到一個(gè) touch 的時(shí)候肆汹,iOS 需要找到一個(gè)合適的對(duì)象來處理事件( touch 或者手勢(shì)),要尋找這個(gè)對(duì)象予权,需要用到這個(gè)方法:

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

該方法會(huì)首先在 application 的 keyWindow 上調(diào)用(UIWindow 也是 UIView 的子類)昂勉,并且該方法的返回值將被用來處理事件。如果這個(gè) view(無論是 window 還是普通的 UIView) 的 userInteractionEnabled 屬性被設(shè)置為 NO扫腺,則它的 hitTest: 永遠(yuǎn)返回 nil岗照,這意味著它和它的子視圖沒有機(jī)會(huì)去接收和處理事件。如果 userInteractionEnabled 屬性為 YES笆环,則會(huì)先判斷產(chǎn)生觸摸的 point 是否發(fā)生在自己的 bounds 內(nèi)攒至,如果沒有也將返回 nil;如果 point 在自己的范圍內(nèi)躁劣,則會(huì)為自己的每個(gè)子視圖調(diào)用 hitTest: 方法迫吐,只要有一個(gè)子視圖通過這個(gè)方法返回一個(gè) UIView 對(duì)象,那么整個(gè)方法就一層一層地往上返回账忘;如果沒有子視圖返回 UIView 對(duì)象志膀,則父視圖將會(huì)把自己返回。

所以鳖擒,在事件分發(fā)中溉浙,有這么幾個(gè)關(guān)鍵點(diǎn):

如果父視圖不能響應(yīng)事件(userInteractionEnabled 為 NO),則其子視圖也將無法響應(yīng)事件蒋荚。

如果子視圖的 frame 有一半在外面放航,就像這樣:

blob.png

則在外面的部分是無法響應(yīng)事件的,因?yàn)樗隽烁敢晥D的范圍圆裕。

整個(gè)事件鏈只會(huì)返回一個(gè) Hit-Test View 來處理事件广鳍。

子視圖的順序會(huì)影響到 Hit-Test View 的選擇:最先通過 hitTest: 方法返回的 UIView 才會(huì)被返回,假如有兩個(gè)子視圖平級(jí)吓妆,并且它們的 frame 一樣赊时,但是誰是后添加的誰就優(yōu)先返回。

了解了事件分發(fā)的這些特點(diǎn)后行拢,還需要知道最后一件事:UIView 如何判斷產(chǎn)生事件的 point 是否在自己的范圍內(nèi)? 答案是通過 pointInside 方法祖秒,這個(gè)方法的默認(rèn)實(shí)現(xiàn)類似于這樣:

// point 被轉(zhuǎn)化為對(duì)應(yīng)視圖的坐標(biāo)系統(tǒng)

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {

return CGRectContainsPoint(self.bounds, point);

}

所以,當(dāng)我們想改變一個(gè) View 的事件觸發(fā)范圍的時(shí)候舟奠,重寫 pointInside 方法就可以了竭缝。

回到問題

針對(duì)這種視圖一定要處理它們的事件觸發(fā)范圍,也就是 pointInside 方法沼瘫,一般來說抬纸,我們先判斷 point 是不是在自己的范圍內(nèi)(通過調(diào)用 super 來判斷),然后再判斷該 point 符不符合我們的處理要求:

這個(gè)例子我用 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

}

如果你要實(shí)現(xiàn)非矩形的控件耿戚,那么請(qǐng)?jiān)陂_發(fā)時(shí)處理好這類問題湿故。

這里附上一個(gè)很容易測(cè)試的小 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 的控件皂股,你既不能設(shè)置它的 delegate墅茉,又沒有其他的替代方法可以使用,對(duì)于像以下這種需要根據(jù)某些屬性實(shí)時(shí)更新的控件來說呜呐,KVO 真是極好的:

這是一個(gè)動(dòng)態(tài)高度 Header 的例子(DKStickyHeaderView):

兩者都是基于 UIScrollView就斤、基于 KVO ,不依賴外部參數(shù):

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)

}

}

對(duì)容器類的 ViewController 來說也一樣有用卵史。在 iOS8 之前沒有 UIContentContainer 這個(gè)正式協(xié)議战转,如果你要實(shí)現(xiàn)一個(gè)很長(zhǎng)的、非列表以躯、可滾動(dòng)的 ViewController槐秧,那么你可能會(huì)將其中的功能分散到幾個(gè) ChildViewController 里,然后把它們組合起來忧设,這樣一來刁标,這些 ChildViewController 既能被單獨(dú)作為一個(gè) ViewController 展示,也可以被組合到一起址晕。作為組合到一起的前提膀懈,就是需要一個(gè)至少有以下兩個(gè)方法的協(xié)議:

提供一個(gè)統(tǒng)一的輸入源,大多是一個(gè) Model 或者像 userId 這樣的

能夠返回你所需要的高度谨垃,比如設(shè)置 preferredContentSize 屬性

ChildViewController 動(dòng)態(tài)地設(shè)置 contentSize启搂,容器監(jiān)聽 contentSize 的變化動(dòng)態(tài)地設(shè)置約束或者 frame硼控。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市胳赌,隨后出現(xiàn)的幾起案子牢撼,更是在濱河造成了極大的恐慌,老刑警劉巖疑苫,帶你破解...
    沈念sama閱讀 216,372評(píng)論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件熏版,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡捍掺,警方通過查閱死者的電腦和手機(jī)撼短,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,368評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來挺勿,“玉大人曲横,你說我怎么就攤上這事÷樱” “怎么了胜榔?”我有些...
    開封第一講書人閱讀 162,415評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)湃番。 經(jīng)常有香客問我夭织,道長(zhǎng),這世上最難降的妖魔是什么吠撮? 我笑而不...
    開封第一講書人閱讀 58,157評(píng)論 1 292
  • 正文 為了忘掉前任尊惰,我火速辦了婚禮,結(jié)果婚禮上泥兰,老公的妹妹穿的比我還像新娘弄屡。我一直安慰自己,他們只是感情好鞋诗,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,171評(píng)論 6 388
  • 文/花漫 我一把揭開白布膀捷。 她就那樣靜靜地躺著,像睡著了一般削彬。 火紅的嫁衣襯著肌膚如雪全庸。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,125評(píng)論 1 297
  • 那天融痛,我揣著相機(jī)與錄音壶笼,去河邊找鬼。 笑死雁刷,一個(gè)胖子當(dāng)著我的面吹牛覆劈,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 40,028評(píng)論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼责语,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼炮障!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起鹦筹,我...
    開封第一講書人閱讀 38,887評(píng)論 0 274
  • 序言:老撾萬榮一對(duì)情侶失蹤铝阐,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后铐拐,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,310評(píng)論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡练对,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,533評(píng)論 2 332
  • 正文 我和宋清朗相戀三年遍蟋,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片螟凭。...
    茶點(diǎn)故事閱讀 39,690評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡虚青,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出螺男,到底是詐尸還是另有隱情棒厘,我是刑警寧澤,帶...
    沈念sama閱讀 35,411評(píng)論 5 343
  • 正文 年R本政府宣布下隧,位于F島的核電站奢人,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏淆院。R本人自食惡果不足惜何乎,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,004評(píng)論 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望土辩。 院中可真熱鬧支救,春花似錦、人聲如沸拷淘。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽启涯。三九已至贬堵,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間逝嚎,已是汗流浹背扁瓢。 一陣腳步聲響...
    開封第一講書人閱讀 32,812評(píng)論 1 268
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留补君,地道東北人引几。 一個(gè)月前我還...
    沈念sama閱讀 47,693評(píng)論 2 368
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親伟桅。 傳聞我的和親對(duì)象是個(gè)殘疾皇子敞掘,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,577評(píng)論 2 353

推薦閱讀更多精彩內(nèi)容