代码的等级:可编译、可运行、可测试、可读、可维护、可复用 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。
欢迎补充和讨论