如何设计一个 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。


欢迎补充和讨论

相关文章
相关标签/搜索