如何设计一个 iOS 控件?(iOS 控件完全解析)

最后更新于:2022-04-01 11:20:03

如何设计一个 iOS 控件?(iOS 控件完全解析)

代码的等级:可编译、可运行、可测试、可读、可维护、可复用


前言

一个控件从外在特征来说,主要是封装这几点:

  • 交互方式
  • 显示样式
  • 数据使用

对外在特征的封装,能让我们在多种环境下达到 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 有很多种初始化方式:initWithFrameinitWithCoderinit 和类方法 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 方法有几点需要注意:

  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) {
        ...
    }];
}

支持 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 代替 UIViewCALayer 节省内存,而且更适合去做一个“图层”,因为它不会接收事件、也不会成为响应链中的一员,但是它能够响应父视图(或 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 来说这些并不够,因为 NormalHighlighted 和 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 虽然对原生控件,如 UIViewUIImageViewUIScrollView 等支持较好(想设置圆角、边框等属性也没办法,只能通过 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 对象,则父视图将会把自己返回。

所以,在事件分发中,有这么几个关键点:

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

所以,当我们想改变一个 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): 

这个是一个固定在 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 展示,也可以被组合到一起。作为组合到一起的前提,就是需要一个至少有以下两个方法的协议:

  1. 提供一个统一的输入源,大多是一个 Model 或者像 userId 这样的
  2. 能够返回你所需要的高度,比如设置 preferredContentSize 属性

ChildViewController 动态地设置 contentSize,容器监听 contentSize 的变化动态地设置约束或者 frame。


欢迎补充和讨论

';

Swift 绘图板功能完善以及终极优化

最后更新于:2022-04-01 12:34:58

Swift 绘图板功能完善以及终极优化

前文总结

接着这篇:Swift 全功能的绘图板开发,虽然在上一篇中我们已经完成了这些功能:

  • 支持铅笔绘图(画点)
  • 支持画直线
  • 支持一些简单的图形(矩形、圆形等)
  • 做一个真正的橡皮擦
  • 能设置画笔的粗细
  • 能设置画笔的颜色
  • 能设置背景色或者背景图

但是还有一个非常重要的功能没有实现,没错,那就是 Undo/Redo!我之所以把这个功能单独放出来是有原因的,一是因为上一篇已经篇幅太长,不适合继续往上加内容;二是因为为了实现 Undo/Redo 功能,我们需要对 DrawingBoard 进行一些重构,在这篇文章中,你能看到用另一种方式实现的绘图板。

实现的效果:


更新 ViewController

先添加两张按钮图:   黑底、50%的透明度,箭头用白色。 (PS:这可是我自己做的,别嫌弃) 图片放到 Images.xcasserts 里: (再次PS:图嫌小的话,就放在2x上) 然后在 Storyboard 里添加两个 Button: 注意里面的红框,Button 与 Board 平级,并且在 Board 的上方。 Button 的约束如下:

  1. 分别为左、右的 Button 设置 Undo、Redo 的 Image
  2. 左边的 Undo 按钮离左 10px,顶部距离父视图 74px
  3. 右边的 Redo 按钮离右 10px,顶部与 Undo 相同
  4. 不要设置宽、高约束,应与 Image 一致

两个按钮的点击事件连接到 VC 里:

@IBAction func undo(sender: UIButton) {
self.board.undo()
}

@IBAction func redo(sneder: UIButton) {
self.board.redo()
}

(此时的 Board 还没有 undo/redo 方法,你可以自行添加或者稍后再添加) 两个按钮本身也连接到 VC 里:

@IBOutlet var undoButton: UIButton!
@IBOutlet var redoButton: UIButton!

更新我们原viewDidLoad中的动画方法,使两个 Button 也适时的隐藏及显示:

...
self.board.drawingStateChangedBlock = {(state: DrawingState) -> () in
if state != .Moved {
UIView.beginAnimations(nil, context: nil)
if state == .Began {
self.topViewConstraintY.constant = -self.topView.frame.size.height
self.toolbarConstraintBottom.constant = -self.toolbar.frame.size.height

self.topView.layoutIfNeeded()
self.toolbar.layoutIfNeeded()

self.undoButton.alpha = 0 // 新增
self.redoButton.alpha = 0 // 新增
} else if state == .Ended {
UIView.setAnimationDelay(1.0)
self.topViewConstraintY.constant = 0
self.toolbarConstraintBottom.constant = 0

self.topView.layoutIfNeeded()
self.toolbar.layoutIfNeeded()

self.undoButton.alpha = 1 // 新增
self.redoButton.alpha = 1 // 新增
}
UIView.commitAnimations()
}
}
...

更新 Board

Undo/Redo 真正的逻辑都在Board 里面,我打算用图片栈保存 DrawingBoard 的每一张图,当 Undo/Redo 的时候直接把前一个状态取出并显示,为了分别存储 Undo/Redo 操作所用的图片,我们要建立两个图片栈:

private var undoImages = [UIImage]()
private var redoImages = [UIImage]()

然后加两个工具方法:canUndo 和 canRedo :

var canUndo: Bool {
get {
return self.undoImages.count > 0 || self.image != nil
}
}

var canRedo: Bool {
get {
return self.redoImages.count > 0
}
}

然后是 undo/redo 这两个主要方法:

func undo() {
if self.canUndo == false {
return
}

if self.undoImages.count > 0 {
self.redoImages.append(self.image!)

let lastImage = self.undoImages.removeLast()
self.image = lastImage

} else if self.image != nil {
self.redoImages.append(self.image!)
self.image = nil
}

self.realImage = self.image
}

func redo() {
if self.canRedo == false {
return
}

if self.redoImages.count > 0 {
if self.image != nil {
self.undoImages.append(self.image!)
}

let lastImage = self.redoImages.removeLast()
self.image = lastImage

self.realImage = self.image
}
}

然后在每次画新图的时候保存下当前状态:

private func drawingImage() {
if let brush = self.brush {
// hook
if let drawingStateChangedBlock = self.drawingStateChangedBlock {
drawingStateChangedBlock(state: self.drawingState)
}

UIGraphicsBeginImageContext(self.bounds.size)

let context = UIGraphicsGetCurrentContext()

UIColor.clearColor().setFill()
UIRectFill(self.bounds)

CGContextSetLineCap(context, kCGLineCapRound)
CGContextSetLineWidth(context, self.strokeWidth)
CGContextSetStrokeColorWithColor(context, self.strokeColor.CGColor)

if let realImage = self.realImage {
realImage.drawInRect(self.bounds)
}

brush.strokeWidth = self.strokeWidth
brush.drawInContext(context)
CGContextStrokePath(context)

let previewImage = UIGraphicsGetImageFromCurrentImageContext()
if self.drawingState == .Ended || brush.supportedContinuousDrawing() {
self.realImage = previewImage
}

UIGraphicsEndImageContext()
// === 新增 ===
if self.drawingState == .Began {
self.redoImages = []

if self.image != nil {
self.undoImages.append(self.image!)
}
}
// ======

self.image = previewImage

brush.lastPoint = brush.endPoint
}
}

这里面都有对 self.image 进行非空处理,其实原来不用这么麻烦,如果Swift 的数组支持插入Optional类型的话,我们直接把self.image插入到数组中,用的时候再取出来即可,因为 UIImageView 的 UIImage 是 Optional 类型的,赋一个 nil 给它没有问题,就当是 undo 到初始化状态了,但是偏偏Swift的数组不支持插入Optional类型,这就导致我们不能记住 UIImageView 的初始化状态,只能通过判断它的image是否为 nil 来处理。

完成的逻辑很简单:当画图开始的时候,保存当前 image 到 undo 栈中,并清空 redo 栈,进行 undo 操作的时候,能一直 undo,并将 undo 的 image 存进 redo 栈中,直到 self.image 为 nil。从这个逻辑可以看出两点:redo 功能非常依赖 undo,毕竟没有撤消就没有重做;除此之外,当用户开始绘制新图的时候,我们也要清空 redo 栈,因为用户已经“回不去”了。

完成这些工作后,就能测试 Undo/Redo 功能了~


关于内存的使用

我们很快地就加上了 Undo/Redo 功能,是吧? 通过维护两个图片栈,在进行相应的操作的时候,直接对 self.image 进行赋值,但是这么做有一个很明显的弊端,就是内存使用毫无上限! 你可以很轻松地在 5s 上使内存使用达到 50M 甚至 100M,虽然我们做了一些处理,如当用户绘制新图时,清空 Redo 的图片栈,但是这并不能从根本上解决问题。

要从根本上解决问题有两种方式。


1. 用 CGPath 画图

假设换一种实现方式,不缓存图片,而是保存每一步,这样无疑会使内存使用量降低很多,取而代之的是在每次画图的时候需要有一个循环来重新画每一步(可以尝试用 clearsContextBeforeDrawing 属性来优化),我个人觉得这种方式可能会比较恶心,因为画的越多,性能就越差,我在前一篇里说过【为什么不用drawRect方法】:

为什么不用drawRect方法

其实我最开始也是使用drawRect方法来完成绘制,但是感觉限制很多,比如context无法保存,还是要每次重画(虽然可以保存到一个BitMapContext里,但是这样与保存到image里有什么区别呢?);后来用CALayer保存每一条CGPath,但是这样仍然不能避免每次重绘,因为需要考虑到橡皮擦和画笔属性之类的影响,这么一来还不如采用image的方式来保存最新绘图板。 既然定下了以image来保存绘图板,那么drawRect就不方便了,因为不能用UIGraphicsBeginImageContext方法来创建一个ImageContext。

如果决定要用 CGPath 来画图的话,你除了要暴露一个CGPathCGContext以外,你还需要用一个自定义的对象保存当前的绘图状态,如画笔颜色画笔粗细混合模式(Blend Mode)等(还会在后期遇到由于前期考虑不足的属性没有设置,然后才加上,这就破坏了“封闭-开放原则”),然后在每一个循环体中恢复当前的上下文,类似于这样:

CGContextSaveGState...
for path in paths {
CGContextSetLineCap(context, kCGLineCapRound)
CGContextSetLineWidth(context, self.strokeWidth)
CGContextSetStrokeColorWithColor(context, self.strokeColor.CGColor)

/* Add path and drawing... */

CGContextRestoreGState...
}

从代码上来说,想换成用CGPath实现也很容易,只需要改两个地方:

  1. PaintBrush 协议,这个协议更新后,其所有的子类同步更新下即可
  2. Board 的drawingImage方法实现

我在 GitHub 里 DrawingBoard 工程里提交了这个分支: DrawingBoard CGPath 分支 协议和drawingImage进行了适当的更新,绘图是以CGPath来实现的,但是依然采用的是图片栈的方式,感兴趣的同学可以尝试自己实现。


2. 优化图片所占用的内存

除了用CGPath来优化以外,我们还可以直接优化图片栈,用一个缓存或Undo控制器来控制所有的一切,在这个控制器里,将直接管理图片缓存(内存和文件)、Undo、Redo操作,使 Board 的逻辑进一步的封装。 不得不说,这才是我想要实现的方式,模块之间可以达到真正的解耦,我将 Board的代码去掉没有改动的方法和属性后贴在这里:

class Board: UIImageView {

// UndoManager,用于实现 Undo 操作和维护图片栈的内存
private class DBUndoManager {
class DBImageFault: UIImage {} // 一个 Fault 对象,与 Core Data 中的 Fault 设计类似

private static let INVALID_INDEX = -1
private var images = [UIImage]() // 图片栈
private var index = INVALID_INDEX // 一个指针,指向 images 中的某一张图

var canUndo: Bool {
get {
return index != DBUndoManager.INVALID_INDEX
}
}

var canRedo: Bool {
get {
return index + 1 < images.count
}
}

func addImage(image: UIImage) {
// 当往这个 Manager 中增加图片的时候,先把指针后面的图片全部清掉,
// 这与我们之前在 drawingImage 方法中对 redoImages 的处理是一样的
if index < images.count - 1 {
images[index + 1 ... images.count - 1] = []
}

images.append(image)

// 更新 index 的指向
index = images.count - 1

setNeedsCache()
}

func imageForUndo() -> UIImage? {
if self.canUndo {
--index
if self.canUndo == false {
return nil
} else {
setNeedsCache()
return images[index]
}
} else {
return nil
}
}

func imageForRedo() -> UIImage? {
var image: UIImage? = nil
if self.canRedo {
image = images[++index]
}
setNeedsCache()
return image
}

// MARK: - Cache

private static let cahcesLength = 3 // 在内存中保存图片的张数,以 index 为中心点计算:cahcesLength * 2 + 1
private func setNeedsCache() {
if images.count >= DBUndoManager.cahcesLength {
let location = max(0, index - DBUndoManager.cahcesLength)
let length = min(images.count - 1, index + DBUndoManager.cahcesLength)
for i in location ... length {
autoreleasepool {
var image = images[i]

if i > index - DBUndoManager.cahcesLength && i < index + DBUndoManager.cahcesLength {
setRealImage(image, forIndex: i) // 如果在缓存区域中,则从文件加载
} else {
setFaultImage(image, forIndex: i) // 如果不在缓存区域中,则置成 Fault 对象
}
}
}
}
}

private static var basePath: String = NSSearchPathForDirectoriesInDomains(.DocumentDirectory, .UserDomainMask, true).first as! String
private func setFaultImage(image: UIImage, forIndex: Int) {
if !image.isKindOfClass(DBImageFault.self) {
let imagePath = DBUndoManager.basePath.stringByAppendingPathComponent("\(forIndex)")
UIImagePNGRepresentation(image).writeToFile(imagePath, atomically: false)
images[forIndex] = DBImageFault()
}
}

private func setRealImage(image: UIImage, forIndex: Int) {
if image.isKindOfClass(DBImageFault.self) {
let imagePath = DBUndoManager.basePath.stringByAppendingPathComponent("\(forIndex)")
images[forIndex] = UIImage(data: NSData(contentsOfFile: imagePath)!)!
}
}
}

private var boardUndoManager = DBUndoManager() // 缓存或Undo控制器

// MARK: - Public methods

var canUndo: Bool {
get {
return self.boardUndoManager.canUndo
}
}

var canRedo: Bool {
get {
return self.boardUndoManager.canRedo
}
}

// undo 和 redo 的逻辑都有所简化
func undo() {
if self.canUndo == false {
return
}

self.image = self.boardUndoManager.imageForUndo()

self.realImage = self.image
}

func redo() {
if self.canRedo == false {
return
}

self.image = self.boardUndoManager.imageForRedo()

self.realImage = self.image
}

// MARK: - drawing

private func drawingImage() {
if let brush = self.brush {
// hook
if let drawingStateChangedBlock = self.drawingStateChangedBlock {
drawingStateChangedBlock(state: self.drawingState)
}

UIGraphicsBeginImageContext(self.bounds.size)

let context = UIGraphicsGetCurrentContext()

UIColor.clearColor().setFill()
UIRectFill(self.bounds)

CGContextSetLineCap(context, kCGLineCapRound)
CGContextSetLineWidth(context, self.strokeWidth)
CGContextSetStrokeColorWithColor(context, self.strokeColor.CGColor)

if let realImage = self.realImage {
realImage.drawInRect(self.bounds)
}

brush.strokeWidth = self.strokeWidth
brush.drawInContext(context)
CGContextStrokePath(context)

let previewImage = UIGraphicsGetImageFromCurrentImageContext()
if self.drawingState == .Ended || brush.supportedContinuousDrawing() {
self.realImage = previewImage
}

UIGraphicsEndImageContext()

// 用 Ended 事件代替原先的 Began 事件
if self.drawingState == .Ended {
self.boardUndoManager.addImage(self.image!)
}

self.image = previewImage

brush.lastPoint = brush.endPoint
}
}
}

以磁盘代替了内存,这里有一些关键点:

  1. Board不需要在对 self.image 的取值进行逻辑判断,DBUndoManager会在适当的时候返回nil,这无疑简化了逻辑
  2. 不需要维护两个图片栈:undoImages、redoImages,drawingImage方法不再需要在 Began 事件里做特殊处理,直接将刚画完的图“扔到” UndoManager 中即可
  3. undo、redo 方便得到了简化,适时调用UndoManager即可
  4. 由于在 UndoManager中只有一个图片栈,所以需要一个额外的指针来指向当前的状态,当前指针的取值(index)对应下图中的 i,两边的箭头分别是 undo、redo 对应的图以及索引:
  5. Fault 对象是一种不错的设计模式,拿来做占位符挺合适的
  6. UndoManager会在三种情况下:addImage、undo、redo 对图片栈进行维护,使 images 里只有 index 两边的元素才真正加载 image到内存中,其他的元素用 Fault 对象代替
  7. 我为什么要用一个 cahcesLength 变量?这里其实还有进一步优化的余地,如只在读取到 Fault 对象时才更新图片栈

那么效果如何呢?我在 4s、Plus 都有进行测试,由于 4s 性能相对较差,我以 4s 为主要测试对象,在内存较少的 4s 上:

在反复绘图的情况下,内存也是毫无压力的~!那么读写文件的时候是否会有卡顿呢?在 4s 上我发现远未达到瓶颈:

(PS:4s 的闪存是C10级别) cahcesLength 变量配合 index 可以进一步优化性能,在这里就不多做介绍了。

至此,DrawingBoard 就可以告一段落了。


GitHub

';

Swift Core Data 图片存储与读取Demo

最后更新于:2022-04-01 12:36:24

# Swift Core Data 图片存储与读取Demo 实体的模型定义: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44d7bb0a.jpg) ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44d8bc95.jpg) 实体的class定义: ~~~ @objc(ImageEntity) class ImageEntity: NSManagedObject { @NSManaged var imageData: NSData } ~~~ 存储: ~~~ @IBAction func saveImageToCoreData() { let delegate = UIApplication.sharedApplication().delegate as AppDelegate let context = delegate.managedObjectContext let imageData = UIImagePNGRepresentation(UIImage(named: "image")) let imageEntity = NSEntityDescription.entityForName("ImageEntity", inManagedObjectContext: context!) let image = ImageEntity(entity: imageEntity!, insertIntoManagedObjectContext: context!) image.imageData = imageData var error: NSError? if context!.save(&error) == false { println("failed: \(error!.localizedDescription)") } } ~~~ 读取: ~~~ @IBAction func loadImageFromCoreData() { let delegate = UIApplication.sharedApplication().delegate as AppDelegate let context = delegate.managedObjectContext let request = NSFetchRequest(entityName: "ImageEntity") var error: NSError? let imageEntities = context?.executeFetchRequest(request, error: &error) let imageEntity = imageEntities?.first! as ImageEntity self.imageView.image = UIImage(data: imageEntity.imageData) } ~~~ ## [Demo地址](https://github.com/zhangao0086/CoreDataSaveImageDemo)
';

Swift Nullability and Objective-C

最后更新于:2022-04-01 07:20:02

# Swift Nullability and Objective-C 通过Bridging-Header文件,Swift可以与Objective-C无缝调用,但是Swift与Objective-C有一个很大的不同点:Swift支持`Optional`类型。比如`NSView`和`NSView?`,在Objective-C里对此只有一种表示,即`NSView *`,既可以用来表示该View为nil、也能表示为非nil,此时Swift编译器是无法确定这个NSView是不是`Optional`类型的,这种情况下Swift编译器会把它当作`NSView!`处理,隐式拆包。 在早期发布的Xcode版本中,苹果的一些框架针对Swift的Optional类型进行了一些专门审查,使他们的API能够适配Optional,而Xcode 6.3的发布,给我们带来了Objetive-C的一个新特性:`nullability`注解,利用该特性我们也能对自己的代码进行类似的处理。 ## 核心:__nullable 和 __nonnull 这个功能给我们带来了两个新的类型注解:`__nullable`和`__nonnull`,就像你看到的,`__nullable`可以表示一个`NULL`或者`nil`值,而`__nonnull`则刚好相反。如果你违反了这个规则,你将会收到编译器的警告: ~~~ @interface AAPLList : NSObject <NSCoding, NSCopying> //--- - (AAPLListItem * __nullable)itemWithName:(NSString * __nonnull)name; @property (copy, readonly) NSArray * __nonnull allItems; //--- @end //-------------- [self.list itemWithName:nil]; // warning! ~~~ 你能在任何地方使用`__nullable`和`__nonnull`关键字,比如和标准C的`const`一起使用,也能直接应用到指针上。但是在大多数情况下,你会以优雅的方式写下这些注解:在方法定义或声明里,只要类型是一个简单的对象或者Block指针,你就能以***不带下划线***的方式(`nullable`或`nonnull`)直接写在左括号后面: ~~~ - (nullable AAPLListItem *)itemWithName:(nonnull NSString *)name; - (NSInteger)indexOfItem:(nonnull AAPLListItem *)item; ~~~ 对于`@property`,你也能以同样的方式写在它的属性列表里: ~~~ @property (copy, nullable) NSString *name; @property (copy, readonly, nonnull) NSArray *allItems; ~~~ 不带下划线的形式比带下划线的形式看起来更简洁,但你仍然需要将它们应用到头文件的每一个类型里。如果你觉得麻烦同时想让头文件变得更加简洁,你就会使用到审查区域。 ## 审查区域(Audited Regions) 如果想更加轻松的添加这些注解,那么你可以把Objective-C头文件的某个区域标记为需要审查(for nullability),在这个区域内,所有简单的指针类型都会被当作`nonnull`,我们之前的例子会变成这样: ~~~ NS_ASSUME_NONNULL_BEGIN @interface AAPLList : NSObject <NSCoding, NSCopying> //--- - (nullable AAPLListItem *)itemWithName:(NSString *)name; - (NSInteger)indexOfItem:(AAPLListItem *)item; @property (copy, nullable) NSString *name; @property (copy, readonly) NSArray *allItems; //--- @end NS_ASSUME_NONNULL_END // -------------- self.list.name = nil; // okay AAPLListItem *matchingItem = [self.list itemWithName:nil]; // warning! ~~~ > Xcode 6.3(iOS 8.3 SDK)引入了`NS_ASSUME_NONNULL_BEGIN / END`宏  > 其中itemWithName方法的name参数没有使用Nullability特征,但是会被当作`nonnull`处理 为了安全起见,这个规则也有一些例外情况: * `typedef`定义的类型不会继承`nullability`特性—它们会轻松地根据上下文选择nullable或non-nullable,所以,就算是在审查区域内,`typedef`定义的类型也不会被当作`nonnull`。 * 像`id *`这样更复杂的指针类型必须被显式地注解,比如,你要指定一个nonnull的指针为一个nullable的对象引用,那么需要使用`__nullable id * __nonnull`。 * 像`NSError **`这些特殊的、通过方法参数返回错误对象的类型,将总是被当作是一个nullable的指针指向一个nullable的指针:`__nullable NSError ** __nullable`。 你可以通过[Error Handling Programming Guide](http://developer.apple.com/go/?id=error-handling-cocoa)了解更多详细内容。 ## 兼容性 你的Objective-C框架现有的代码写对了吗?是否能安全的改变它们的类型? *Yes, it is*. * 现有的、被*编译过*的代码还能继续使用你的框架,也就是说[ABI](https://developer.apple.com/library/ios/documentation/Xcode/Conceptual/iPhoneOSABIReference/Introduction/Introduction.html)没有变化(编译器不会报错),这也意味着现有的代码不会在运行时捕获到`nil`的不正确传值。 * 用新的Swift编译器编译现有的*源码*,并在使用你的框架的时候,可能会因为一些不安全的行为在编译时得到额外的警告。 * `nonnull`不影响优化,尤其是你还可以在运行时检查标记为`nonnull`的参数是否为`nil`,这可能需要必要的向后兼容。 大多数情况下,应该接受`nullable`和`nonnull`,你当前所使用的断言或者异常太粗暴了:违反约定是程序员经常犯的错误(而`nullable`和`nonnull`能在编译时就解决问题)。特别的,返回值是你能控制的东西,永远不应该对一个non-nullable的返回类型返回一个`nil`,除非这是为了向后兼容。 ## 回到Swift 现在我们给我们的Objective-C头文件添加了nullability注解,我们在Swift中使用它:  在Objective-C中添加注解之前: ~~~ class AAPLList : NSObject, NSCoding, NSCopying { //--- func itemWithName(name: String!) -> AAPLListItem! func indexOfItem(item: AAPLListItem!) -> Int @NSCopying var name: String! { get set } @NSCopying var allItems: [AnyObject]! { get } //--- } ~~~ 添加注解之后: ~~~ class AAPLList : NSObject, NSCoding, NSCopying { //--- func itemWithName(name: String) -> AAPLListItem? func indexOfItem(item: AAPLListItem) -> Int @NSCopying var name: String? { get set } @NSCopying var allItems: [AnyObject] { get } //--- } ~~~ 这些Swift代码非常清晰。只有一些细节的变化,但是它让你的框架使用起来更爽。
';

Swift 全功能的绘图板开发

最后更新于:2022-04-01 07:20:00

# Swift 全功能的绘图板开发 要做一个全功能的绘图板,至少要支持以下这些功能: * 支持铅笔绘图(画点) * 支持画直线 * 支持一些简单的图形(矩形、圆形等) * 做一真正的橡皮擦 * 能设置画笔的粗细 * 能设置画笔的颜色 * 能设置背景色或者背景图 * 能支持撤消与重做 * … 我们先做一些基础性的工作,比如创建工程。  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44a3d6f4.png) * * * ## 工程搭建 先创建一个`Single View Application` 工程:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44a4d02c.jpg)  语言选择`Swift`:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44a67d64.jpg)  为了最大程度的利用屏幕区域,我们完全隐藏掉状态栏,在`Info.plist`里修改或添加这两个参数:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44a81d53.jpg)  然后进入到`Main.storyboard`,开始搭建我们的UI。  我们给已存在的`ViewController`的`View`添加一个`UIImageView`的子视图,背景色设为`Light Gray`,然后添加4个约束,由于要做一个全屏的画板,必须要让`Constraint to margins`保持没有选中的状态,否则左右两边会留下苹果建议的空白区域,最后把`User Interaction Enabled`打开:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44a8fdfc.jpg)  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44aa3e47.jpg)  然后我们回到`ViewController`的`View`上: * 添加一个放工具栏的容器:`UIView`,为该View设置约束:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44ab9be3.jpg)  同样的不要选择`Contraint to margins`。 * 在该View里添加一个`UISegmentedControl`,并给SegmentedControl设置6个选项,分别是: 1. 铅笔 2. 直尺 3. 虚线 4. 矩形 5. 圆形 6. 橡皮擦 * 给这个SegmentedControl添加约束:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44acc7d6.jpg)  垂直居中,两边各留20,高度固定为28。 完整的UI及结构看起来像这样:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44adcdd8.jpg)  ImageView将会作为实际的绘制区域,顶部的SegmentedControl提供工具的选择。 到目前为止我们还没有写下一行代码,至此要开始编码了。  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44af292a.png) > 你可能会注意到Board有一部分被挡住了,这只是暂时的~ * * * ## 施工… ### Board 我们创建一个`Board`类,继承自`UIImageView`,同时把这个类设置为`Main.storyboard`中`ImageView`的Class,这样当app启动的时候就会自动创建一个Board的实例了。  增加两个属性以及初始化方法: ~~~ var strokeWidth: CGFloat var strokeColor: UIColor override init() { self.strokeColor = UIColor.blackColor() self.strokeWidth = 1 super.init() } required init(coder aDecoder: NSCoder) { self.strokeColor = UIColor.blackColor() self.strokeWidth = 1 super.init(coder: aDecoder) } ~~~ 由于我们是依赖于touches方法来完成绘图过程,我们需要记录下每次touch的状态,比如`began`、`moved`、`ended`等,为此我们创建一个枚举,在touches方法中进行记录,并调用私有的绘图方法`drawingImage`: ~~~ enum DrawingState { case Began, Moved, Ended } class Board: UIImageView { private var drawingState: DrawingState! // 此处省略init方法与另外两个属性 // MARK: - touches methods override func touchesBegan(touches: NSSet, withEvent event: UIEvent) { self.drawingState = .Began self.drawingImage() } override func touchesMoved(touches: NSSet, withEvent event: UIEvent) { self.drawingState = .Moved self.drawingImage() } override func touchesEnded(touches: NSSet, withEvent event: UIEvent) { self.drawingState = .Ended self.drawingImage() } // MARK: - drawing private func drawingImage() { // 暂时为空实现 } } ~~~ 在我们实现drawingImage方法之前,我们先创建另外一个重要的组件:`BaseBrush`。 ### BaseBrush 顾名思义,`BaseBrush`将会作为一个绘图的基类而存在,我们会在它的基础上创建一系列的子类,以达到弹性的设计目的。为此,我们创建一个`BaseBrush`类,并实现一个`PaintBrush`接口: ~~~ import CoreGraphics protocol PaintBrush { func supportedContinuousDrawing() -> Bool; func drawInContext(context: CGContextRef) } class BaseBrush : NSObject, PaintBrush { var beginPoint: CGPoint! var endPoint: CGPoint! var lastPoint: CGPoint? var strokeWidth: CGFloat! func supportedContinuousDrawing() -> Bool { return false } func drawInContext(context: CGContextRef) { assert(false, "must implements in subclass.") } } ~~~ `BaseBrush`实现了`PaintBrush`接口,`PaintBrush`声明了两个方法: * supportedContinuousDrawing,表示是否是连续不断的绘图 * drawInContext,基于Context的绘图方法,子类必须实现具体的绘图 只要是实现了`PaintBrush`接口的类,我们就当作是一个绘图工具(如铅笔、直尺等),而`BaseBrush`除了实现`PaintBrush`接口以外,我们还为它增加了四个便利属性: * beginPoint,开始点的位置 * endPoint,结束点的位置 * lastPoint,最后一个点的位置(也可以称作是上一个点的位置) * strokeWidth,画笔的宽度 这么一来,子类也可以很方便的获取到当前的状态,并作一些深度定制的绘图方法。 > lastPoint的意义:beginPoint和endPoint很好理解,beginPoint是手势刚识别时的点,只要手势不结束,那么beginPoint在手势识别期间是不会变的;endPoint总是表示手势最后识别的点;除了铅笔以外,其他的图形用这两个属性就够了,但是用铅笔在移动的时候,不能每次从beginPoint画到endPoint,如果是那样的话就是画直线了,而是应该从上一次画的位置(lastPoint)画到endPoint,这样才是连贯的线。 ### 回到Board 我们实现了一个画笔的基类之后,就可以重新回到`Board`类了,毕竟我们之前的工作还没有做完,现在是时候完善`Board`类了。  我们用`Board`实际操纵`BaseBrush`,先为`Board`添加两个新的属性: ~~~ var brush: BaseBrush? private var realImage: UIImage? ~~~ `brush`对应到具体的画笔类,`realImage`保存当前的图形,重新修改touches方法,以便增加对`brush`属性的处理,完整的touches方法实现如下: ~~~ // MARK: - touches methods override func touchesBegan(touches: NSSet, withEvent event: UIEvent) { if let brush = self.brush { brush.lastPoint = nil brush.beginPoint = touches.anyObject()!.locationInView(self) brush.endPoint = brush.beginPoint self.drawingState = .Began self.drawingImage() } } override func touchesMoved(touches: NSSet, withEvent event: UIEvent) { if let brush = self.brush { brush.endPoint = touches.anyObject()!.locationInView(self) self.drawingState = .Moved self.drawingImage() } } override func touchesCancelled(touches: NSSet!, withEvent event: UIEvent!) { if let brush = self.brush { brush.endPoint = nil } } override func touchesEnded(touches: NSSet, withEvent event: UIEvent) { if let brush = self.brush { brush.endPoint = touches.anyObject()!.locationInView(self) self.drawingState = .Ended self.drawingImage() } } ~~~ 我们需要防止`brush`为`nil`的情况,以及为`brush`设置好`beginPoint`和`endPoint`,之后我们就可以完善`drawingImage`方法了,实现如下: ~~~ private func drawingImage() { if let brush = self.brush { // 1. UIGraphicsBeginImageContext(self.bounds.size) // 2. let context = UIGraphicsGetCurrentContext() UIColor.clearColor().setFill() UIRectFill(self.bounds) CGContextSetLineCap(context, kCGLineCapRound) CGContextSetLineWidth(context, self.strokeWidth) CGContextSetStrokeColorWithColor(context, self.strokeColor.CGColor) // 3. if let realImage = self.realImage { realImage.drawInRect(self.bounds) } // 4. brush.strokeWidth = self.strokeWidth brush.drawInContext(context); CGContextStrokePath(context) // 5. let previewImage = UIGraphicsGetImageFromCurrentImageContext() if self.drawingState == .Ended || brush.supportedContinuousDrawing() { self.realImage = previewImage } UIGraphicsEndImageContext() // 6. self.image = previewImage; brush.lastPoint = brush.endPoint } } ~~~ 步骤解析: 1. 开启一个新的ImageContext,为保存每次的绘图状态作准备。 2. 初始化context,进行基本设置(画笔宽度、画笔颜色、画笔的圆润度等)。 3. 把之前保存的图片绘制进context中。 4. 设置`brush`的基本属性,以便子类更方便的绘图;调用具体的绘图方法,并最终添加到context中。 5. 从当前的context中,得到Image,如果是`ended`状态或者需要支持连续不断的绘图,则将Image保存到`realImage`中。 6. 实时显示当前的绘制状态,并记录绘制的最后一个点。 这些工作完成以后,我们就可以开始写第一个工具了:铅笔工具。  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44b0c445.png) ### 铅笔工具 铅笔工具应该支持连续不断的绘图(不断的保存到realImage中),这也是我们给`PaintBrush`接口增加`supportedContinuousDrawing`方法的原因,考虑到用户的手指可能快速的移动,导致从一个点到另一个点有着跳跃性的动作,我们对铅笔工具采用画直线的方式来实现。  首先创建一个类,名为`PencilBrush`,继承自`BaseBrush`类,实现如下: ~~~ class PencilBrush: BaseBrush { override func drawInContext(context: CGContextRef) { if let lastPoint = self.lastPoint { CGContextMoveToPoint(context, lastPoint.x, lastPoint.y) CGContextAddLineToPoint(context, endPoint.x, endPoint.y) } else { CGContextMoveToPoint(context, beginPoint.x, beginPoint.y) CGContextAddLineToPoint(context, endPoint.x, endPoint.y) } } override func supportedContinuousDrawing() -> Bool { return true } } ~~~ 如果lastPoint为nil,则基于beginPoint画线,反之则基于lastPoint画线。  这样一来,一个铅笔工具就完成了,怎么样,很简单吧。  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44b2fe50.png) * * * ## 测试 到目前为止,我们的`ViewController`还保持着默认的状态,是时候先为铅笔工具写一些测试代码了。  在`ViewController`添加`board`属性,并与`Main.storyboard`中的Board关联起来;创建一个`brushes`属性,并为之赋值为: ~~~ var brushes = [PencilBrush()] ~~~ 在`ViewController`中添加`switchBrush:`方法,并把`Main.storyboard`中的SegmentedControl的`ValueChanged`连接到`ViewController`的`switchBrush:`方法上,实现如下: ~~~ @IBAction func switchBrush(sender: UISegmentedControl) { assert(sender.tag < self.brushes.count, "!!!") self.board.brush = self.brushes[sender.selectedSegmentIndex] } ~~~ 最后在`viewDidLoad`方法中做一个初始化:  `self.board.brush = brushes[0]`  编译、运行,铅笔工具可以完美运行~!  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44b42845.jpg) * * * ## 其他的工具 接下来我们把其他的绘图工具也实现了。  其他的工具不像铅笔工具,不需要支持连续不断的绘图,所以也就不用覆盖`supportedContinuousDrawing`方法了。 ### 直尺 创建一个`LineBrush`类,实现如下: ~~~ class LineBrush: BaseBrush { override func drawInContext(context: CGContextRef) { CGContextMoveToPoint(context, beginPoint.x, beginPoint.y) CGContextAddLineToPoint(context, endPoint.x, endPoint.y) } } ~~~ ### 虚线 创建一个`DashLineBrush`类,实现如下: ~~~ class DashLineBrush: BaseBrush { override func drawInContext(context: CGContextRef) { let lengths: [CGFloat] = [self.strokeWidth * 3, self.strokeWidth * 3] CGContextSetLineDash(context, 0, lengths, 2); CGContextMoveToPoint(context, beginPoint.x, beginPoint.y) CGContextAddLineToPoint(context, endPoint.x, endPoint.y) } } ~~~ 这里我们就用到了`BaseBrush`的`strokeWidth`属性,因为我们想要创建一条动态的虚线。 ### 矩形 创建一个`RectangleBrush`类,实现如下: ~~~ class RectangleBrush: BaseBrush { override func drawInContext(context: CGContextRef) { CGContextAddRect(context, CGRect(origin: CGPoint(x: min(beginPoint.x, endPoint.x), y: min(beginPoint.y, endPoint.y)), size: CGSize(width: abs(endPoint.x - beginPoint.x), height: abs(endPoint.y - beginPoint.y)))) } } ~~~ 我们用到了一些计算,因为我们希望矩形的区域不是由beginPoint定死的。 ### 圆形 创建一个`EllipseBrush`类,实现如下: ~~~ class EllipseBrush: BaseBrush { override func drawInContext(context: CGContextRef) { CGContextAddEllipseInRect(context, CGRect(origin: CGPoint(x: min(beginPoint.x, endPoint.x), y: min(beginPoint.y, endPoint.y)), size: CGSize(width: abs(endPoint.x - beginPoint.x), height: abs(endPoint.y - beginPoint.y)))) } } ~~~ 同样有一些计算,理由同上。 ### 橡皮擦 从本文一开始就说过了,我们要做一个***真正的橡皮擦***,网上有很多的橡皮擦的实现其实就是把画笔颜色设置为背景色,但是如果背景色可以动态设置,甚至设置为一个渐变的图片时,这种方法就失效了,所以有些绘图app的背景色就是固定为白色的。  其实Apple的Quartz2D框架本身就是支持橡皮擦的,只用一个方法就可以完美实现。  让我们创建一个`EraserBrush`类,实现如下: ~~~ class EraserBrush: PencilBrush { override func drawInContext(context: CGContextRef) { CGContextSetBlendMode(context, kCGBlendModeClear); super.drawInContext(context) } } ~~~ 注意,与其他的工具不同,橡皮擦是继承自`PencilBrush`的,因为橡皮擦本身也是基于点的,而`drawInContext`里也只是加了一句: ~~~ CGContextSetBlendMode(context, kCGBlendModeClear); ~~~ 加入这一句代码,一个真正的橡皮擦便实现了。 * * * ## 再次测试 现在我们的工程结构应该类似于这样:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44b550f4.jpg)  我们修改下`ViewController`中的`brushes`属性的初始值: ~~~ var brushes = [PencilBrush(), LineBrush(), DashLineBrush(), RectangleBrush(), EllipseBrush(), EraserBrush()] ~~~ 编译、运行:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44b6abd9.jpg)  除了橡皮擦擦除的范围太小以外,一切都很完美~!  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44b812ad.png) * * * ## 设计思路 在继续完成剩下的功能之前,我想先对之前的代码进行些说明。 ### 为什么不用drawRect方法 其实我最开始也是使用drawRect方法来完成绘制,但是感觉限制很多,比如context无法保存,还是要每次重画(虽然可以保存到一个BitMapContext里,但是这样与保存到image里有什么区别呢?);后来用CALayer保存每一条CGPath,但是这样仍然不能避免每次重绘,因为需要考虑到橡皮擦和画笔属性之类的影响,这么一来还不如采用image的方式来保存最新绘图板。  既然定下了以image来保存绘图板,那么drawRect就不方便了,因为不能用`UIGraphicsBeginImageContext`方法来创建一个ImageContext。 ### ViewController与Board、BaseBrush之间的关系 在`ViewController`、`Board`和`BaseBrush`这三者之间,虽然VC要知道另外两个组件,但是仅限于选择对应的工具给Board,Board本身并不知道当前的brush是哪个brush,也不需要知道其内部实现,只管调用对应的brush就行了;BaseBrush(及其子类)也并不知道自己将会被用于哪,它们只需要实现自己的算法即可。类似于这样的图:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44b99834.jpg)  实际上这里包含了两个设计模式。 #### 策略设计模式 `策略设计模式`的UML图:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44bd1665.jpg)  `策略设计模式`在iOS中也应用广泛,如`AFNetworking`的`AFHTTPRequestSerializer`和`AFHTTPResponseSerializer`的设计,通过在运行时动态的改变委托对象,变换行为,使程序模块之间解耦、提高应变能力。  以我们的绘图板为例,输出不同的图形就意味着不同的算法,用户可根据不同的需求来选择某一种算法,即BaseBrush及其子类做具体的封装,这样的好处是每一个子类只关心自己的算法,达到了高聚合的原则,高级模块(Board)不用关心具体实现。  想象一下,如果是让Board里自身来处理这些算法,那代码中无疑会充斥很多与算法选择相关的逻辑,而且每增加一个算法都需要重新修改Board类,这又与代码应该对拓展开放、对修改关闭原则有了冲突,而且每个类也应该只有一个责任。  通过采用策略模式我们实现了一个好维护、易拓展的程序(妈妈再也不用担心工具栏不够用了^^)。 策略模式的定义:***定义一个算法群,把每一个算法分别封装起来,让它们之间可以互相替换,使算法的变化独立于使用它的用户之上。*** #### 模板方法 在传统的策略模式中,每一个算法类都独自完成整个算法过程,例如一个网络解析程序,可能有一个算法用于解析`JSON`,有另一个算法用于解析`XML`等(另外一个例子是压缩程序,用`ZIP`或`RAR`算法),独自完成整个算法对灵活性更好,但免不了会有重复代码,在`DrawingBoard`里我们做一个折中,尽量保证灵活性,又最大限度地避免重复代码。  我们将`BaseBrush`的角色提升为算法的基类,并提供一些便利的属性(如`beginPoint`、`endPoint`、`strokeWidth`等),然后在`Board`的`drawingImage`方法里对`BaseBrush`的接口进行调用,而`BaseBrush`不会知道自己的接口是如何联系起来的,虽然`supportedContinuousDrawing`(这是一个“钩子”)甚至影响了算法的流程(铅笔需要实时绘图)。  我们用`drawingImage`搭建了一个算法的骨架,看起来像是模板方法的UML图:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44be3724.jpg) > 图中右边的方框代表模板方法。 `BaseBrush`通过提供抽象方法(`drawInContext`)、具体方法或钩子方法(`supportedContinuousDrawing`)来对应算法的每一个步骤,让其子类可以重定义或实现这些步骤。同时,让模板方法(即`dawingImage`)定义一个算法的骨架,模板方法不仅可以调用在抽象类中实现的基本方法,也可以调用在抽象类的子类中实现的基本方法,还可以调用其他对象中的方法。  除了对算法的封装以外,模板方法还能防止“循环依赖”,即高层组件依赖低层组件,反过来低层组件也依赖高层组件。想像一下,如果既让Board选择具体的算法子类,又让算法类直接调用drawingImage方法(不提供钩子,直接把Board的事件下发下去),那到时候就热闹了,这些类串在一起难以理解,又不好维护。 模板方法的定义:***在一个方法中定义一个算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法中的某些步骤。*** > 其实模式都很简单,很多人在工作中会思考如何让自己的代码变得更好,“情不自禁”地就会慢慢实现这些原则,了解模式的设计意图,有助于在遇到需要折中的地方更加明白如何在设计上取舍。 以上就是我设计时的思路,说完了,接下来还要完成的工作有: * 提供对画笔颜色、粗细的设置 * 背景设置 * 全屏绘图(不能让Board一直显示不全) 先从画笔开始,*Let’s go!* * * * ## 画笔设置 不管是画笔还是背景设置,我们都要有一个能提供设置的工具栏。 ### 设置工具栏 所以我们往`Board`上再盖一个`UIToolbar`,与顶部的View类似: 1. 拖一个`UIToolbar`到`Board`的父类上,与`Board`的视图层级平级。 2. 设置`UIToolbar`的约束:左、右、下间距为0,高为44:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44c01956.jpg) 3. 往`UIToolbar`上拖一个`UIBarButtonItem`,`title`就写:画笔设置。 4. 在`ViewController`里增加一个`paintingBrushSettings`方法,并把`UIBarButtonItem`的`action`连接`paintingBrushSettings`方法上。 5. 在`ViewController`里增加一个`toolar`属性,并把Xib中的`UIToolbar`连接到`toolbar`上。 UIToolbar配置好后,UI及视图层级如下:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44c12d73.jpg) ### RGBColorPicker 考虑到多个页面需要选取自定义的颜色,我们先创建一个工具类:`RGBColorPicker`,用于选择RGB颜色: ~~~ class RGBColorPicker: UIView { var colorChangedBlock: ((color: UIColor) -> Void)? private var sliders = [UISlider]() private var labels = [UILabel]() override init(frame: CGRect) { super.init(frame: frame) self.initial() } required init(coder aDecoder: NSCoder) { super.init(coder: aDecoder) self.initial() } private func initial() { self.backgroundColor = UIColor.clearColor() let trackColors = [UIColor.redColor(), UIColor.greenColor(), UIColor.blueColor()] for index in 1...3 { let slider = UISlider() slider.minimumValue = 0 slider.value = 0 slider.maximumValue = 255 slider.minimumTrackTintColor = trackColors[index - 1] slider.addTarget(self, action: "colorChanged:", forControlEvents: .ValueChanged) self.addSubview(slider) self.sliders.append(slider) let label = UILabel() label.text = "0" self.addSubview(label) self.labels.append(label) } } override func layoutSubviews() { super.layoutSubviews() let sliderHeight = CGFloat(31) let labelWidth = CGFloat(29) let yHeight = self.bounds.size.height / CGFloat(sliders.count) for index in 0..<self.sliders.count { let slider = self.sliders[index] slider.frame = CGRect(x: 0, y: CGFloat(index) * yHeight, width: self.bounds.size.width - labelWidth - 5.0, height: sliderHeight) let label = self.labels[index] label.frame = CGRect(x: CGRectGetMaxX(slider.frame) + 5, y: slider.frame.origin.y, width: labelWidth, height: sliderHeight) } } override func intrinsicContentSize() -> CGSize { return CGSize(width: UIViewNoIntrinsicMetric, height: 107) } @IBAction private func colorChanged(slider: UISlider) { let color = UIColor( red: CGFloat(self.sliders[0].value / 255.0), green: CGFloat(self.sliders[1].value / 255.0), blue: CGFloat(self.sliders[2].value / 255.0), alpha: 1) let label = self.labels[find(self.sliders, slider)!] label.text = NSString(format: "%.0f", slider.value) if let colorChangedBlock = self.colorChangedBlock { colorChangedBlock(color: color) } } func setCurrentColor(color: UIColor) { var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0 color.getRed(&red, green: &green, blue: &blue, alpha: nil) let colors = [red, green, blue] for index in 0..<self.sliders.count { let slider = self.sliders[index] slider.value = Float(colors[index]) * 255 let label = self.labels[index] label.text = NSString(format: "%.0f", slider.value) } } } ~~~ 这个工具类很简单,没有采用Auto Layout进行布局,因为`layoutSubviews`方法已经能很好的满足我们的需求了。当用户拖动任何一个`UISlider`的时候,我们能实时的通过`colorChangedBlock`回调给外部。它能展现一个这样的视图:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44c2ddc8.jpg)  不过虽然该工具类本身没有采用Auto Layout进行布局,但是它还是支持Auto Layout的,当它被添加到某个Auto Layout的视图中的时候,Auto Layout布局系统可以通过`intrinsicContentSize`知道该视图的尺寸信息。  最后它还有一个`setCurrentColor`方法从外部接收一个UIColor,可以用于初始化。 ### 画笔设置的UI 我打算在用户点击`画笔设置`的时候,从底部弹出一个控制面板(就像系统的`Control Center`那样),所以我们还要有一个像这样的设置UI:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44c3ba1b.jpg)  具体的,创建一个`PaintingBrushSettingsView`类,同时创建一个`PaintingBrushSettingsView.xib`文件,并把xib中view的`Class`设为`PaintingBrushSettingsView`,设置view的背景色为透明: 1. 放置一个title为“画笔粗细”的`UILabel`,约束设为:宽度固定为68,高度固定为21,左和上边距为8。 2. 放置一个title为“1”的`UILabel`,“1”与“画笔粗细”的垂直间距为10,宽度固定为10,高度固定为21,与`superview`的左边距为10。 3. 放置一个`UISlider`,用于调节画笔的粗细,与“1”的水平间距为5,并与“1”垂直居中,高度固定为30,宽度暂时不设,在`PaintingBrushSettingsView`中添加`strokeWidthSlider`属性,与之连接起来。 4. 放置一个title为“20”的`UILabel`,约束设为:宽度固定为20,高度固定为21,top与“1”相同,与`superview`的右间距为10。并把上一步中的`UISlider`的右间距设为与“20”相隔5。 5. 放置一个title为“画笔颜色”的`UILabel`,宽、高、left与“画笔粗细”相同,与上面`UISlider`的垂直间距设为12。 6. 放置一个`UIView`至“画笔颜色”下方(上图中被选中的那个UIView),宽度固定为50,高度固定为30,left与“画笔颜色”相同,并且与“画笔颜色”的垂直间距为5,在`PaintingBrushSettingsView`中添加`strokeColorPreview`属性,与之连接起来。 7. 放置一个`UIView`,把它的Class改为`RGBColorPicker`,约束设为:left与顶部的UISlider相同,底部与superview的间距为0,右间距为10,与上一步中的UIView的垂直间距为5。 `PaintingBrushSettingsView`类的完整代码如下: ~~~ class PaintingBrushSettingsView : UIView { var strokeWidthChangedBlock: ((strokeWidth: CGFloat) -> Void)? var strokeColorChangedBlock: ((strokeColor: UIColor) -> Void)? @IBOutlet private var strokeWidthSlider: UISlider! @IBOutlet private var strokeColorPreview: UIView! @IBOutlet private var colorPicker: RGBColorPicker! override func awakeFromNib() { super.awakeFromNib() self.strokeColorPreview.layer.borderColor = UIColor.blackColor().CGColor self.strokeColorPreview.layer.borderWidth = 1 self.colorPicker.colorChangedBlock = { [unowned self] (color: UIColor) in self.strokeColorPreview.backgroundColor = color if let strokeColorChangedBlock = self.strokeColorChangedBlock { strokeColorChangedBlock(strokeColor: color) } } self.strokeWidthSlider.addTarget(self, action: "strokeWidthChanged:", forControlEvents: .ValueChanged) } func setBackgroundColor(color: UIColor) { self.strokeColorPreview.backgroundColor = color self.colorPicker.setCurrentColor(color) } func strokeWidthChanged(slider: UISlider) { if let strokeWidthChangedBlock = self.strokeWidthChangedBlock { strokeWidthChangedBlock(strokeWidth: CGFloat(slider.value)) } } } ~~~ `strokeWidthChangedBlock`和`strokeColorChangedBlock`两个*Block*用于给外部传递状态。`setBackgroundColor`用于初始化。 #### 关于 Swift 1.2 在 Swift 1.2里,不能用 `setBackgroundColor`方法了,具体的,见Xcode 6.3的发布文档:[Xcode 6.3 Release Notes](https://developer.apple.com/library/ios/releasenotes/DeveloperTools/RN-Xcode/Chapters/xc6_release_notes.html#//apple_ref/doc/uid/TP40001051-CH4-DontLinkElementID_23),下面是用`didSet`代替原有的`setBackgroundColor`方法: ~~~ override var backgroundColor: UIColor? { didSet { self.strokeColorPreview.backgroundColor = self.backgroundColor self.colorPicker.setCurrentColor(self.backgroundColor!) super.backgroundColor = oldValue } } ~~~ #### 实现毛玻璃效果 在把`PaintingBrushSettingsView`显示出来之前,我们要先想一想以何种方式展现比较好,众所周知`Control Center`是有毛玻璃效果的,我们也想要这样的效果,而且不用自己实现。那如何产生效果? 答案是用`UIToolbar`就行了。  `UIToolbar`本身就是带有毛玻璃效果的,只要你不设置背景色,并且`translucent`属性为true,“恰好”我们页面底部就有一个`UIToolbar`,我们把它拉高就可以插入展现`PaintingBrushSettingsView`了。  只要*get*到了这一点,毛玻璃效果就算实现了~~  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44c4da62.png) ### 测试画笔设置 我们在ViewController新增加几个属性: ~~~ var toolbarEditingItems: [UIBarButtonItem]? var currentSettingsView: UIView? @IBOutlet var toolbarConstraintHeight: NSLayoutConstraint! ~~~ `toolbarConstraintHeight`连接到`Main.storyboard`中对应的约束上就行了。`toolbarEditingItems`能让我们在`UIToolbar`上显示不同的`items`,本来还需要一个`toolbarItems`属性的,因为`UIViewController`类本身就自带,我们便不用单独新增。`currentSettingsView`是用来保存当前展示的哪个设置页面,考虑到我们后面会增加`背景设置`,这个属性还是有必要的。  我们先写一个往toolbar上添加约束的工具方法: ~~~ func addConstraintsToToolbarForSettingsView(view: UIView) { view.setTranslatesAutoresizingMaskIntoConstraints(false) self.toolbar.addSubview(view) self.toolbar.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|-0-[settingsView]-0-|", options: .DirectionLeadingToTrailing, metrics: nil, views: ["settingsView" : view])) self.toolbar.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|-0-[settingsView(==height)]", options: .DirectionLeadingToTrailing, metrics: ["height" : view.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize).height], views: ["settingsView" : view])) } ~~~ 这个工具方法会把传入进来的view添加到toolbar上,同时添加相应的约束。注意高度的约束,我是通过`systemLayoutSizeFittingSize`方法计算出设置视图最佳的高度,这是为了达到更好的拓展性(背景设置与画笔设置所需要的高度很可能会不同)。  然后再增加一个`setupBrushSettingsView`方法: ~~~ func setupBrushSettingsView() { let brushSettingsView = UINib(nibName: "PaintingBrushSettingsView", bundle: nil).instantiateWithOwner(nil, options: nil).first as PaintingBrushSettingsView self.addConstraintsToToolbarForSettingsView(brushSettingsView) brushSettingsView.hidden = true brushSettingsView.tag = 1 brushSettingsView.setBackgroundColor(self.board.strokeColor) brushSettingsView.strokeWidthChangedBlock = { [unowned self] (strokeWidth: CGFloat) -> Void in self.board.strokeWidth = strokeWidth } brushSettingsView.strokeColorChangedBlock = { [unowned self] (strokeColor: UIColor) -> Void in self.board.strokeColor = strokeColor } } ~~~ 我们在这个方法里实例化了一个`PaintingBrushSettingsView`,并添加到toolbar上,增加相应的约束,以及一些初始化设置和两个Block回调的处理。  然后修改`viewDidLoad`方法,增加以下行为: ~~~ //--- self.toolbarEditingItems = [ UIBarButtonItem(barButtonSystemItem:.FlexibleSpace, target: nil, action: nil), UIBarButtonItem(title: "完成", style:.Plain, target: self, action: "endSetting") ] self.toolbarItems = self.toolbar.items self.setupBrushSettingsView() //--- ~~~ 在`paintingBrushSettings`方法里响应点击: ~~~ @IBAction func paintingBrushSettings() { self.currentSettingsView = self.toolbar.viewWithTag(1) self.currentSettingsView?.hidden = false self.updateToolbarForSettingsView() } func updateToolbarForSettingsView() { self.toolbarConstraintHeight.constant = self.currentSettingsView!.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize).height + 44 self.toolbar.setItems(self.toolbarEditingItems, animated: true) UIView.beginAnimations(nil, context: nil) self.toolbar.layoutIfNeeded() UIView.commitAnimations() self.toolbar.bringSubviewToFront(self.currentSettingsView!) } ~~~ `updateToolbarForSettingsView`也是一个工具方法,用于更新toolbar的高度。  由于我们采用了Auto Layout进行布局,动画要通过调用`layoutIfNeeded`方法来实现。  响应点击“完成”按钮的`endSetting`方法: ~~~ @IBAction func endSetting() { self.toolbarConstraintHeight.constant = 44 self.toolbar.setItems(self.toolbarItems, animated: true) UIView.beginAnimations(nil, context: nil) self.toolbar.layoutIfNeeded() UIView.commitAnimations() self.currentSettingsView?.hidden = true } ~~~ 这么一来画笔设置就做完了,代码应该还是比较好理解,编译、运行后,应该能看到:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44c6035c.jpg)![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44c7340b.jpg)![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44c83fe7.jpg)  完成度已经很高了^^!  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44c9709b.png) * * * ## 背景设置 整体的框架基本上已经在之前的工作中搭好了,我们快速过掉这一节。  在`Main.storyboard`中增加了一个title为“背景设置”的`UIBarButtonItem`,并将action连接到`ViewController`的`backgroundSettings`方法上,你可以选择在插入“背景设置”之前,先插入一个`FlexibleSpace`的`UIBarButtonItem`。  创建`BackgroundSettingsVC`类,继承自`UIViewController`,这与画笔设置继承于`UIView`不同,我们希望背景设置可以在用户的相册中选择照片,而使用`UIImagePickerController`的前提是要实现`UIImagePickerControllerDelegate`、`UINavigationControllerDelegate`两个接口,如果让UIView来实现这两个接口会很奇怪。  创建一个`BackgroundSettingsVC.xib`文件: 1. 放置一个title为“从相册中选择背景图”的UIButton,约束为:左、上边距为8,宽度固定为135,高度固定为30。 2. 放置一个RGBColorPicker,约束为:左、右边距为8,与UIButton的垂直间距为20,底部与superview齐平。 3. 把UIButton的`Touch Up Inside`事件连接到`BackgroundSettingsVC`的`pickImage`方法上;RGBColorPicker连接到`BackgroundSettingsVC`的`colorPicker`属性上。 看上去像这样:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44ca347a.jpg) `BackgroundSettingsVC`类的完整代码: ~~~ class BackgroundSettingsVC : UIViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate { var backgroundImageChangedBlock: ((backgroundImage: UIImage) -> Void)? var backgroundColorChangedBlock: ((backgroundColor: UIColor) -> Void)? @IBOutlet private var colorPicker: RGBColorPicker! lazy private var pickerController: UIImagePickerController = { [unowned self] in let pickerController = UIImagePickerController() pickerController.delegate = self return pickerController }() override func awakeFromNib() { super.awakeFromNib() self.colorPicker.colorChangedBlock = { [unowned self] (color: UIColor) in if let backgroundColorChangedBlock = self.backgroundColorChangedBlock { backgroundColorChangedBlock(backgroundColor: color) } } } func setBackgroundColor(color: UIColor) { self.colorPicker.setCurrentColor(color) } @IBAction func pickImage() { self.presentViewController(self.pickerController, animated: true, completion: nil) } // MARK: UIImagePickerControllerDelegate Methods func imagePickerController(picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [NSObject : AnyObject]) { let image = info[UIImagePickerControllerOriginalImage] as UIImage if let backgroundImageChangedBlock = self.backgroundImageChangedBlock { backgroundImageChangedBlock(backgroundImage: image) } self.dismissViewControllerAnimated(true, completion: nil) } // MARK: UINavigationControllerDelegate Methods func navigationController(navigationController: UINavigationController, willShowViewController viewController: UIViewController, animated: Bool) { UIApplication.sharedApplication().setStatusBarHidden(true, withAnimation: .None) } } ~~~ 同样用两个*Block*进行回调;`setBackgroundColor`公共方法用于设置内部的RGBColorPicker的初始颜色状态;在`UINavigationControllerDelegate`里隐藏系统默认显示的状态栏。  回到`ViewController`,我们对背景设置进行测试。  像`setupBrushSettingsView`方法一样,我们增加一个`setupBackgroundSettingsView`方法: ~~~ func setupBackgroundSettingsView() { let backgroundSettingsVC = UINib(nibName: "BackgroundSettingsVC", bundle: nil).instantiateWithOwner(nil, options: nil).first as BackgroundSettingsVC self.addConstraintsToToolbarForSettingsView(backgroundSettingsVC.view) backgroundSettingsVC.view.hidden = true backgroundSettingsVC.view.tag = 2 backgroundSettingsVC.setBackgroundColor(self.board.backgroundColor!) self.addChildViewController(backgroundSettingsVC) backgroundSettingsVC.backgroundImageChangedBlock = { [unowned self] (backgroundImage: UIImage) in self.board.backgroundColor = UIColor(patternImage: backgroundImage) } backgroundSettingsVC.backgroundColorChangedBlock = { [unowned self] (backgroundColor: UIColor) in self.board.backgroundColor = backgroundColor } } ~~~ 修改viewDidLoad方法: ~~~ self.toolbarEditingItems = [ UIBarButtonItem(barButtonSystemItem:.FlexibleSpace, target: nil, action: nil), UIBarButtonItem(title: "完成", style:.Plain, target: self, action: "endSetting") ] self.toolbarItems = self.toolbar.items self.setupBrushSettingsView() self.setupBackgroundSettingsView() // Added~!!! ~~~ 实现`backgroundSettings`方法: ~~~ @IBAction func backgroundSettings() { self.currentSettingsView = self.toolbar.viewWithTag(2) self.currentSettingsView?.hidden = false self.updateToolbarForSettingsView() } ~~~ 编译、运行,现在你可以用不同的背景色(或背景图)了!  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44cb5d9b.jpg)![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44cc9e83.jpg)![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44ce8a0d.jpg) * * * ## 全屏绘图 到目前为止,`Board`一直显示不全(事实上,我很早就实现了全屏绘图,但是优先级一直被我排在最后![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-04_5639cee14c0a0.gif)),现在是时候来解决它了。  解决思路是这样的:当用户开始绘图的时候,我们把顶部和底部两个View隐藏;当用户结束绘图的时候,再让两个View显示。  为了获取用户的绘图状态,我们需要在`Board`里加个“钩子”: ~~~ // 增加一个Block回调 var drawingStateChangedBlock: ((state: DrawingState) -> ())? private func drawingImage() { if let brush = self.brush { // hook if let drawingStateChangedBlock = self.drawingStateChangedBlock { drawingStateChangedBlock(state: self.drawingState) } UIGraphicsBeginImageContext(self.bounds.size) // ... ~~~ 这样一来用户绘图的状态就在ViewController掌握中了。  ViewController想要控制两个View的话,还需要增加几个属性: ~~~ @IBOutlet var topView: UIView! @IBOutlet var topViewConstraintY: NSLayoutConstraint! @IBOutlet var toolbarConstraintBottom: NSLayoutConstraint! ~~~ 然后在viewDidLoad方法里增加对“钩子”的处理: ~~~ self.board.drawingStateChangedBlock = {(state: DrawingState) -> () in if state != .Moved { UIView.beginAnimations(nil, context: nil) if state == .Began { self.topViewConstraintY.constant = -self.topView.frame.size.height self.toolbarConstraintBottom.constant = -self.toolbar.frame.size.height self.topView.layoutIfNeeded() self.toolbar.layoutIfNeeded() } else if state == .Ended { UIView.setAnimationDelay(1.0) self.topViewConstraintY.constant = 0 self.toolbarConstraintBottom.constant = 0 self.topView.layoutIfNeeded() self.toolbar.layoutIfNeeded() } UIView.commitAnimations() } } ~~~ 只有当状态为开始或结束的时候我们才需要更新UI状态,而且我们在结束的事件里延迟了1秒钟,这样用户可以暂时预览下全图。 > 依靠Auto Layout布局系统以及我们在钩子里对高度的处理,用户在设置页面绘图时也能完美运行。 * * * ## 保存到图库 最后一个功能:保存到图库!  在toolbar上插入一个title为“保存到图库”的`UIBarButtonItem`,还是可以先插入一个`FlexibleSpace`的`UIBarButtonItem`,然后把action连接到ViewController的`saveToAlbumy`方法上: ~~~ @IBAction func saveToAlbum() { UIImageWriteToSavedPhotosAlbum(self.board.takeImage(), self, "image:didFinishSavingWithError:contextInfo:", nil) } ~~~ 我为`Board`添加一个新的公共方法:takeImage: ~~~ func takeImage() -> UIImage { UIGraphicsBeginImageContext(self.bounds.size) self.backgroundColor?.setFill() UIRectFill(self.bounds) self.image?.drawInRect(self.bounds) let image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return image } ~~~ 然后是一个方法指针的回调: ~~~ func image(image: UIImage, didFinishSavingWithError error: NSError?, contextInfo:UnsafePointer<Void>) { if let err = error { UIAlertView(title: "错误", message: err.localizedDescription, delegate: nil, cancelButtonTitle: "确定").show() } else { UIAlertView(title: "提示", message: "保存成功", delegate: nil, cancelButtonTitle: "确定").show() } } ~~~ ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44d10882.jpg)  旅行到终点了~!  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44d67c3d.png) * * * ## 感谢一路的陪伴! 看了下,有些小长,文本+代码有2w3+,全部代码去除空行和空格有1w4+,直接贴代码会简单很多,但我始终觉得让代码完成功能并不是全部目的,代码背后隐藏的问题定义、设计、构建更有意义,毕竟软件开发完成“后”比完成“前”所花费的时间永远更多(除非是一个只有10行代码或者“一次性”的程序)。  希望与大家多多交流。 > 最后吐槽下CSDN新的Markdown编辑器,代码样式丑且不能自定义,而且有些代码高亮都无法识别。不过感觉草稿箱比以前更方便,问题主要还是集中在样式上,希望以后能不断改进,会一如既往的支持。 * * * ## 更新——撤消与重做功能 [Swift 绘图板功能完善以及终极优化](http://blog.csdn.net/zhangao0086/article/details/45289475) * * * ## GitHub地址 [DrawingBoard](https://github.com/zhangao0086/DrawingBoard)
';

让Xcode自动更新Build版本

最后更新于:2022-04-01 13:03:23

让Xcode自动更新Build版本

我们每天都要打包给测试,每天都要改Build比较麻烦,幸运的是可以通过Shell脚本来省略这个过程。

在Xcode工程里选择对应的Target,在Build Phases里点击“New Run Script Phases”:

在下面的窗口中写入类似于如下脚本:

buildNumber=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "${PROJECT_DIR}/${INFOPLIST_FILE}")
shortVersion=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "${PROJECT_DIR}/${INFOPLIST_FILE}")  

buildNumber=`date +"%m%d"`
buildNumber="$shortVersion.$buildNumber"  

/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "${PROJECT_DIR}/${INFOPLIST_FILE}"

前两行是将工程当前的Build、Version读取出来。

我的情况是基于Version加上日期以形成Build,如下:

关于date的格式化,传送门

如果Build是一个整形,可以这样做递增:

buildNumber=$(($buildNumber + 1))

如果要Build是类似于这样的字符串:1.0.0,可以用awf命令取值,参考:http://stackoverflow.com/questions/9258344/better-way-of-incrementing-build-number

如果仅仅只想在Release(如Archive等)的时候更新Build,可以尝试加入判断:


if [ "${CONFIGURATION}" = "Release" ]; then  

fi  
';

iOS8 Core Image In Swift:自动改善图像以及内置滤镜的使用

最后更新于:2022-04-01 07:19:55

  # iOS8 用UITableViewRowAction实现Cell自定义滑动操作 在iOS 8以前,如果想自定义一个UITableViewCell的滑动操作是一件比较麻烦的事情,系统只支持删除,如果我们想加上一个类似于“置顶”的操作需要处理不少逻辑,而进入iOS 8以后,系统提供了UITableViewRowAction以及新的delegate方法,使得自定义一些操作变得非常容易,如果想加上一个置顶,只需要这样: ~~~ override func tableView(tableView: UITableView, editActionsForRowAtIndexPath indexPath: NSIndexPath) -> [AnyObject]? {     let topAction = UITableViewRowAction(style: .Default, title: "置顶") {         (action: UITableViewRowAction!, indexPath: NSIndexPath!) -> Void in         tableView.editing = false     }     return [topAction] } ~~~ 在这里可以添加任意多个操作。要确保这个代码生效,还是需要实现commitEditingStyle这个delegate方法,哪怕里面什么也不处理:  ~~~ override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) { } ~~~
';

iOS GCD使用指南

最后更新于:2022-04-01 07:19:53

  # iOS GCD使用指南 Grand Central Dispatch(GCD)是异步执行任务的技术之一。一般将应用程序中记述的线程管理用的代码在系统级中实现。开发者只需要定义想执行的任务并追加到适当的Dispatch Queue中,GCD就能生成必要的线程并计划执行任务。由于线程管理是作为系统的一部分来实现的,因此可统一管理,也可执行任务,这样就比以前的线程更有效率。 ## Dispatch Queue Dispatch Queue是用来执行任务的队列,是GCD中最基本的元素之一。 Dispatch Queue分为两种: * Serial Dispatch Queue,按添加进队列的顺序(先进先出)一个接一个的执行 * Concurrent Dispatch Queue,并发执行队列里的任务 简而言之,Serial Dispatch Queue只使用了一个线程,Concurrent Dispatch Queue使用了多个线程(具体使用了多少个,由系统决定)。  可以通过两种方式来获得Dispatch Queue,第一种方式是自己创建一个: let myQueue: dispatch_queue_t = dispatch_queue_create("com.xxx", nil)  第一个参数是队列的名称,一般是使用倒序的全域名。虽然可以不给队列指定一个名称,但是有名称的队列可以让我们在遇到问题时更好调试;当第二个参数为nil时返回Serial Dispatch Queue,如上面那个例子,当指定为**DISPATCH_QUEUE_CONCURRENT**时返回Concurrent Dispatch Queue。 需要注意一点,如果是在**OS X 10.8或iOS 6以及之后版本**中使用,Dispatch Queue将会由ARC自动管理,如果是在此之前的版本,需要自己手动释放,如下:  ~~~ let myQueue: dispatch_queue_t = dispatch_queue_create("com.xxx", nil) dispatch_async(myQueue, { () -> Void in     println("in Block") }) dispatch_release(myQueue)  ~~~ 以上是通过手动创建的方式来获取Dispatch Queue,第二种方式是直接获取系统提供的Dispatch Queue。 要获取的Dispatch Queue无非就是两种类型: * Main Dispatch Queue * Global Dispatch Queue / Concurrent Dispatch Queue 一般只在需要更新UI时我们才获取Main Dispatch Queue,其他情况下用Global Dispatch Queue就满足需求了: ~~~ //获取Main Dispatch Queue let mainQueue = dispatch_get_main_queue() //获取Global Dispatch Queue let globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0) ~~~ 得到的Global Dispatch Queue实际上是一个Concurrent Dispatch Queue,Main Dispatch Queue实际上就是Serial Dispatch Queue(并且只有一个)。 获取Global Dispatch Queue的时候可以指定优先级,可以根据自己的实际情况来决定使用哪种优先级。 一般情况下,我们通过第二种方式获取Dispatch Queue就行了。 ## dispatch_after dispatch_after能让我们添加进队列的任务延时执行,比如想让一个Block在10秒后执行:  ~~~ var time = dispatch_time(DISPATCH_TIME_NOW, (Int64)(10 * NSEC_PER_SEC)) dispatch_after(time, globalQueue) { () -> Void in     println("在10秒后执行") }  ~~~ NSEC_PER_SEC表示的是秒数,它还提供了NSEC_PER_MSEC表示毫秒。 上面这句dispatch_after的真正含义是在10秒后把任务添加进队列中,并不是表示在10秒后执行,大部分情况该函数能达到我们的预期,只有在对时间要求非常精准的情况下才可能会出现问题。 获取一个dispatch_time_t类型的值可以通过两种方式来获取,以上是第一种方式,即通过dispatch_time函数,另一种是通过dispatch_walltime函数来获取,dispatch_walltime需要使用一个timespec的结构体来得到dispatch_time_t。通常dispatch_time用于计算相对时间,dispatch_walltime用于计算绝对时间,我写了一个把NSDate转成dispatch_time_t的Swift方法:  ~~~ func getDispatchTimeByDate(date: NSDate) -> dispatch_time_t {     let interval = date.timeIntervalSince1970     var second = 0.0     let subsecond = modf(interval, &second)     var time = timespec(tv_sec: __darwin_time_t(second), tv_nsec: (Int)(subsecond * (Double)(NSEC_PER_SEC)))     return dispatch_walltime(&time, 0) }  ~~~ 这个方法接收一个NSDate对象,然后把NSDate转成dispatch_walltime需要的timespec结构体,最后再把dispatch_time_t返回,同样是在10秒后执行,之前的代码在调用部分需要修改成:  ~~~ var time = getDispatchTimeByDate(NSDate(timeIntervalSinceNow: 10)) dispatch_after(time, globalQueue) { () -> Void in     println("在10秒后执行") } ~~~ 这就是通过绝对时间来使用dispatch_after的例子。 ## dispatch_group 可能经常会有这样一种情况:我们现在有3个Block要执行,我们不在乎它们执行的顺序,我们只希望在这3个Block执行完之后再执行某个操作。这个时候就需要使用dispatch_group了: let globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0) let group = dispatch_group_create() dispatch_group_async(group, globalQueue) { () -> Void in     println("1") } dispatch_group_async(group, globalQueue) { () -> Void in     println("2") } dispatch_group_async(group, globalQueue) { () -> Void in     println("3") } dispatch_group_notify(group, globalQueue) ~~~ { () -> Void in     println("completed") } ~~~ 输出的顺序与添加进队列的顺序无关,因为队列是Concurrent Dispatch Queue,但“completed”的输出一定是在最后的: ~~~ 312   completed   ~~~ 除了使用dispatch_group_notify函数可以得到最后执行完的通知外,还可以使用 ~~~ let globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0) let group = dispatch_group_create() dispatch_group_async(group, globalQueue) { () -> Void in     println("1") } dispatch_group_async(group, globalQueue) { () -> Void in     println("2") } dispatch_group_async(group, globalQueue) { () -> Void in     println("3") } //使用dispatch_group_wait函数 dispatch_group_wait(group, DISPATCH_TIME_FOREVER) println("completed") ~~~ 需要注意的是,dispatch_group_wait实际上会使当前的线程处于等待的状态,也就是说如果是在主线程执行dispatch_group_wait,在上面的Block执行完之前,主线程会处于卡死的状态。可以注意到dispatch_group_wait的第二个参数是指定超时的时间,如果指定为DISPATCH_TIME_FOREVER(如上面这个例子)则表示会永久等待,直到上面的Block全部执行完,除此之外,还可以指定为具体的等待时间,根据dispatch_group_wait的返回值来判断是上面block执行完了还是等待超时了。 最后,同之前创建dispatch_queue一样,如果是在**OS X 10.8或iOS 6以及之后版本**中使用,Dispatch Group将会由ARC自动管理,如果是在此之前的版本,需要自己手动释放。 ## dispatch_barrier_async dispatch_barrier_async就如同它的名字一样,在队列执行的任务中增加“栅栏”,在增加“栅栏”之前已经开始执行的block将会继续执行,当dispatch_barrier_async开始执行的时候其他的block处于等待状态,dispatch_barrier_async的任务执行完后,其后的block才会执行。我们简单的写个例子,假设这个例子有读文件和写文件的部分: ~~~ func writeFile() {     NSUserDefaults.standardUserDefaults().setInteger(7, forKey: "Integer_Key") } func readFile(){     print(NSUserDefaults.standardUserDefaults().integerForKey("Integer_Key")) }  ~~~ 写文件只是在NSUserDefaults写入一个数字7,读只是将这个数字打印出来而已。我们要避免在写文件时候正好有线程来读取,就使用dispatch_barrier_async函数:  ~~~ NSUserDefaults.standardUserDefaults().setInteger(9, forKey: "Integer_Key") let globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0) dispatch_async(globalQueue) {self.readFile()} dispatch_async(globalQueue) {self.readFile()} dispatch_async(globalQueue) {self.readFile()} dispatch_async(globalQueue) {self.readFile()} dispatch_barrier_async(globalQueue) {self.writeFile() ; self.readFile()} dispatch_async(globalQueue) {self.readFile()} dispatch_async(globalQueue) {self.readFile()} dispatch_async(globalQueue) {self.readFile()}  ~~~ 我们先将一个9初始化到NSUserDefaults的Integer_Key中,然后在中间执行dispatch_barrier_async函数,由于这个队列是一个Concurrent Dispatch Queue,能同时并发多少线程是由系统决定的,如果添加dispatch_barrier_async的时候,其他的block(包括上面4个block)还没有开始执行,那么会先执行dispatch_barrier_async里的任务,其他block全部处于等待状态。如果添加dispatch_barrier_async的时候,已经有block在执行了,那么dispatch_barrier_async会等这些block执行完后再执行。 ## dispatch_apply dispatch_apply会将一个指定的block执行指定的次数。如果要对某个数组中的所有元素执行同样的block的时候,这个函数就显得很有用了,用法很简单,指定执行的次数以及Dispatch Queue,在block回调中会带一个索引,然后就可以根据这个索引来判断当前是对哪个元素进行操作:  ~~~ let globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0) dispatch_apply(10, globalQueue) { (index) -> Void in     print(index) } print("completed")  ~~~ 由于是Concurrent Dispatch Queue,不能保证哪个索引的元素是先执行的,但是“completed”一定是在最后打印,因为dispatch_apply函数是同步的,执行过程中会使线程在此处等待,所以一般的,我们应该在一个异步线程里使用dispatch_apply函数: ~~~ let globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0) dispatch_async(globalQueue, { () -> Void in     dispatch_apply(10, globalQueue) { (index) -> Void in         print(index)     }     print("completed") }) ~~~ print("在dispatch_apply之前")  ## dispatch_suspend / dispatch_resume 某些情况下,我们可能会想让Dispatch Queue暂时停止一下,然后在某个时刻恢复处理,这时就可以使用dispatch_suspend以及dispatch_resume函数:  ~~~ //暂停 dispatch_suspend(globalQueue) //恢复 dispatch_resume(globalQueue) ~~~ 暂停时,如果已经有block正在执行,那么不会对该block的执行产生影响。dispatch_suspend只会对还未开始执行的block产生影响。 ## Dispatch Semaphore 信号量在多线程开发中被广泛使用,当一个线程在进入一段关键代码之前,线程必须获取一个信号量,一旦该关键代码段完成了,那么该线程必须释放信号量。其它想进入该关键代码段的线程必须等待前面的线程释放信号量。 信号量的具体做法是:当信号计数大于0时,每条进来的线程使计数减1,直到变为0,变为0后其他的线程将进不来,处于等待状态;执行完任务的线程释放信号,使计数加1,如此循环下去。 下面这个例子中使用了10条线程,但是同时只执行一条,其他的线程处于等待状态: ~~~ let globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0) let semaphore =  dispatch_semaphore_create(1) for i in 0 ... 9 {     dispatch_async(globalQueue, { () -> Void in         dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER)         let time = dispatch_time(DISPATCH_TIME_NOW, (Int64)(2 * NSEC_PER_SEC))         dispatch_after(time, globalQueue) { () -> Void in             print("2秒后执行")             dispatch_semaphore_signal(semaphore)         }     }) } ~~~ 取得信号量的线程在2秒后释放了信息量,相当于是每2秒执行一次。 通过上面的例子可以看到,在GCD中,用dispatch_semaphore_create函数能初始化一个信号量,同时需要指定信号量的初始值;使用dispatch_semaphore_wait函数分配信号量并使计数减1,为0时处于等待状态;使用dispatch_semaphore_signal函数释放信号量,并使计数加1。 另外dispatch_semaphore_wait同样也支持超时,只需要给其第二个参数指定超时的时候即可,同Dispatch Group的dispatch_group_wait函数类似,可以通过返回值来判断。 这个函数也需要注意,如果是在**OS X 10.8或iOS 6以及之后版本**中使用,Dispatch Semaphore将会由ARC自动管理,如果是在此之前的版本,需要自己手动释放。 ## dispatch_once dispatch_once函数通常用在单例模式上,它可以保证在程序运行期间某段代码只执行一次,如果我们要通过dispatch_once创建一个单例类,在Swift可以这样: ~~~ class SingletonObject {     class var sharedInstance : SingletonObject {         struct Static {             static var onceToken : dispatch_once_t = 0             static var instance : SingletonObject? = nil         }         dispatch_once(&Static.onceToken) {             Static.instance = SingletonObject()         }         return Static.instance!     } } ~~~ 这样就能通过GCD的安全机制保证这段代码只执行一次。
';

Swift 柯里化(Currying)

最后更新于:2022-04-01 07:19:51

# Swift 柯里化(Currying) 在计算机科学中,柯里化(英语:Currying),又譯為卡瑞化或加里化,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。这个技术由 Christopher Strachey 以逻辑学家哈斯凱爾·加里命名的,尽管它是 Moses Schönfinkel 和 戈特洛布·弗雷格 发明的。 ------[维基百科](http://zh.wikipedia.org/wiki/Currying) Swift支持将方法柯里化,类似于批量创建某个带有固定参数的方法,就像下面这个例子,用Swift做个简单的加法运算: ~~~ func sum(a: Int,b: Int) -> Int {     return a + b } sum(1, 2)   //输出3 ~~~ 创建一个柯里化的方法很容易,虽然看起来似乎和我们以前写的方法不太一样,但大体来说是相似的: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca449f1db1.jpg) 参数列表的每个参数都用单独的圆括号括起来,其他部分都和以前一样,把上面那个简单的Swift标准方法改成柯里化之后就像这样: ~~~ func sum(a: Int)(b: Int) -> Int {     return a + b } var sumByFirst = sum(1) sumByFirst(b: 4) //输出5 ~~~ 只需要传入第一个参数,返回的sumByFirst是一个函数,它包含剩余的其他参数,以及刚刚传入进去的那个1,接着以它自己作为方法传入第二个参数b就行了。 如果是三个参数就像这样:  ~~~ func sum(a: Int)(b: Int)(c: Int) -> Int {     return a + b + c } var sumByFirst = sum(1) var sumBySecond = sumByFirst(b: 4) sumBySecond(c: 10)  //输出15 ~~~ 除了第一个参数以外,其他的参数都要显式地写上参数标签,并且要按参数排列的顺序调用。 一个括号中放两个参数也可以:  ~~~ func sum(a: Int)(b: Int, c: Int) -> Int {     return a + b + c } var sumByFirst = sum(1) sumByFirst(b: 4,c: 10)  //输出15 ~~~ 在某些情况下,你可能会用某个相同的参数重复调用某个方法,那么利用柯里化会使代码更易于维护: ~~~ func sum(a: Int)(b: Int) -> Int {     return a + b } var sumWithFive = sum(5) sumWithFive(b: 5) sumWithFive(b: 10) sumWithFive(b: 15) ~~~
';

Swift 值类型和引用类型

最后更新于:2022-04-01 07:19:48

  # Swift 值类型和引用类型 原文地址:[https://developer.apple.com/swift/blog/?id=10](https://developer.apple.com/swift/blog/?id=10) Swift中的类型分为两类:一,值类型(value types),每个值类型的实例都拥有各自唯一的数据,通常它们是结构体,枚举或元组;二,引用类型(reference types),引用类型的实例共享它们的数据,通常是一个类。在这篇文章中我们将会探索值类型和引用类型的价值,以及如何在它们二者间抉择。 ## 有什么区别? 值类型最基本的特征就是复制在赋值、初始化和传递参数过程中的数据,并为这个数据创建一个独立的实例: ~~~ // 值类型例子 struct S { var data: Int = -1 } var a = S() var b = a // 把a复制给b a.data = 42 // a被改变了, b却没有 println("\(a.data), \(b.data)") // prints "42, -1" ~~~ 复制一个引用类型的时候,其实是隐式地创建了一个共享的实例。在复制后,两个实例指向了同一块数据,所以当修改其中一个实例数据的时候,另一个实例的数据也被修改了,比如: ~~~ // 引用类型的例子 class C { var data: Int = -1 } var x = C() var y = x // x被复制给了y x.data = 42 // x指向的数据被修改了 (同时y也被修改了) println("\(x.data), \(y.data)") // prints "42, 42" ~~~ ## 可变性在安全中的作用 选择值类型而不是引用类型的一个主要原因是能让你的代码变得更加简单。你在任何情况下用一个值类型,都能够假设你的其他代码不会使它改变,这通常在多线程环境中很有用,如果一个线程中使用的数据被另一个线程给意外的修改了,这通常会产生非常严重的Bug,且相当难以调试。 由于只有当你需要修改数据时两者的区别才会得到体现,所以当你的实例不会对数据进行修改的时候,值类型和引用类型看起来是完全相同的。 你也许会想,写一个完全不可变的类,这或许是有价值的,使用Cocoa的NSObject能简化这个过程,并且能很好地保持原有的语义。现在,你能通过使用不可变的存储属性,以及避免暴露修改数据的接口,从而在Swift里实现一个不可变的类。事实上,大多数的Cocoa类,比如NSURL等,都被设计为不可变的类,然而,Swift当前并没有提供任何语言机制去强制申明一个类不可改变(比如子类化就能修改一个类的实现),只有结构体和枚举才是强制不可变的。 ## 如何选择? 所以如果你想要创建一个新的类型,你怎么选择?当你写Cocoa程序的时候,大多数APIs都需要从NSObject继承,你就已经是一个类了(引用类型),针对其他情况,这里有些指导规则: 使用值类型,当...: * 通过使用==去比较实例的数据 * 你想得到一个实例的独立副本 * 数据在多线程环境下被修改 使用引用类型(比如使用一个类),当...: * 通过使用===去判断两个实例是否恒等 * 你想要创建一个共享的,可变的对象 在Swift里,Array、String和Dictionary都是值类型,他们的行为和C语言中的int类似,每个实例都有自己的数据,你不需要额外做任何事情,比如做一个显式的copy,防止其他代码在你不知情的情况下修改等,更重要的是,你能安全地在线程间传递它,而不需要使用同步技术。在提高安全性的精神下,这个模型将帮助你在Swift中写出更多可预知的代码。
';

Swift Switch介绍

最后更新于:2022-04-01 07:19:46

# Swift Switch介绍 ## Switch简介 Switch作为选择结构中必不可少的语句也被加入到了Swift中,只要有过编程经验的人对Switch语句都不会感到陌生,但苹果对Switch进行了大大的增强,使其拥有其他语言中没有的特性。使用Switch语句很简单: ~~~ var i = 0   switch i {       case 0:           "0" //被输出       case 1:           "1"       default:           "2"   }   ~~~ 在这个最简单的Switch语句中,与其他语言很不同的一点是:不用显式的加上break。Swift的Switch语句会自动在case结尾处加上break,执行完满足条件的case部分后,就自动退出了。但是在某些情况下,我们可能会希望Switch能同时执行两条case,那么可以这样: ~~~ var i = 3   switch i {       case 0,3:           "0" //被输出       case 1:           "1"       default:           "2"   }   ~~~ 在case后的多个条件中,用逗号隔开即可。 如果就是想执行完一条case之后,想继续执行下一条case,就要用到一个新的关键字: ~~~ var i = 0   switch i {       case 0:           "0" //被输出           fallthrough       case 1:           "1" //被输出       case 2:           "2"       default:           "default"   }   ~~~ 使用新的关键字**fallthrough**能使Switch在执行完一个case之后,紧接着执行下一条case。 Swift的Switch语句一定要**涵盖**所有的情况,这并不是说一定要有default,只要上面的case能满足所有的情况,就可以不用写default。 需要注意的地方有两点: * Switch当然也支持显式的break,通常只有一种情况下你会这么做,那就是当你也不想在default里做任何事情的时候,这个时候你就可以在default里显式地加上一句break。 * fallthrough并不是在任何情况下都有效,当你在Switch里使用Value Binding技术的时候,fallthrough就被禁止了。Value Binding在下面会讲到。 ## 支持多种数据类型 在Objective-C里,Switch语句只能支持整形的数据(或者一个字符),但是在Swift里,Switch能支持多种数据类型,包括浮点、布尔、字符串等: 支持浮点: ~~~ let float = 1.5   switch float {       case 1.5:           "1.5"   //被输出       default:           "default"   }   ~~~ 支持布尔: ~~~ let isSuccess = true   switch isSuccess {       case true:           "true"   //被输出       default:           "default"   }   ~~~ 支持字符串: ~~~ let name = "Bannings"   switch name {       case "Bannings":           "Bannings"   //被输出       default:           "default"   }   ~~~ 可以说是史上支持数据类型最多的Switch了。 ## 支持区间运算符 不仅仅是循环结构里可以用区间运算符,在Switch里同样可以用区间运算符: ~~~ var i = 15   switch i {       case 0 ... 10:           "0~10"       case 11 ... 20:           "11~20" //被输出       default:           "default"   }   ~~~ 对某个数值区间进行批量匹配,这样是不是很酷?浮点数也同样支持区间运算符。 ## 支持元组 作为被大大增强的Switch,元组也是被支持的: ~~~ let request = (true,"success")   switch request {       case (true, "success"):           "success"   //被输出       case (false, "failed"):           "failed"       default:           "default"   }   ~~~ 使用元组和使用其他数据类型一致,不过元组还有一项特点,对于不想关心的值,可以用下划线_跳过,如: ` let (name,  _, age) = ("Bannings" ,true, 22)  ` 那么在使用Switch时,同样支持这项特性: ~~~ let request = (true,"success")   switch request {       case (_, "success"):           "success"   //被输出       case (false, _):           "failed"       default:           "default"   }   ~~~ 对于不关心的值跳过,只要满足另一个值就行了。需要注意一点的是,如果元组中的值也是数字类型,那么也是可以用区间运算符的: ~~~ let request = (10,"failed")   switch request {       case (_, "success"):           "success"       case (1 ... 10, _):           "1~10"    //被输出       default:           "default"   }   ~~~ ### Value Binding 针对元组,Switch还支持类似于Optional Binding的Value Binding,就是能把元组中的各个值提取出来,然后直接在下面使用: ~~~ let request = (0,"success")   switch request {       case (0, let state):           state    //被输出:success       case (let errorCode, _):           "error code is \(errorCode)"   }  // 涵盖了所有可能的case,不用写default了   ~~~ 这样也是可以的: ~~~ let request = (0,"success")   switch request {       case let (errorCode, state):           state    //被输出:success       case (let errorCode, _):           "error code is \(errorCode)"   }   ~~~ 把let放在外面和放在里面为每一个元素单独写上let是等价的。 当你在一个case里使用Value Binding的时候,如果你同时也在它的上一个case里使用了fallthrough,这是编译器所不允许的,你可能会收到这样一个编译错误:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca449d5bcd.jpg) 只要把下面的errorCode去掉就行了,当然,考虑好自己的逻辑。 ## 支持额外的逻辑判断 在使用Switch语句时,Switch会适当地导向相应的case部分,这其实就相当于做了一次逻辑判断,但Swift的Switch语句还支持额外的逻辑判断: ~~~ let request = (0,"success")   switch request {       case (0, let state) where state != "success":           state       case (let errorCode, _):           "error code is \(errorCode)"//被输出:"error code is 0"   }   ~~~ 这同样很酷,相信在某种情况下会派上大用场。 ## 总结 Swift对Switch进行了大大增强,使Switch的灵活性得到了很大提升,这是激动人心的改变,但是如果掌控不好其中的变化,可能会使你在进行错误排查时增加难度,也会使代码复杂度变高。在适当地时候灵活运用,保持头脑清晰总是有益的。
';

Swift 可选值(Optional Values)介绍

最后更新于:2022-04-01 07:19:44

# Swift 可选值(Optional Values)介绍 ## Optional的定义 Optional也是Objective-C没有的数据类型,是苹果引入到Swift语言中的全新类型,它的特点就和它的名字一样:可以有值,也可以没有值,当它没有值时,就是nil。此外,Swift的nil也和Objective-C有些不一样,在Objective-C中,只有对象才能为nil,而在Swift里,当基础类型(整形、浮点、布尔等)没有值时,也是nil,而不是一个初始值,没有初始值的值,是不能使用的,这就产生了Optional类型。定义一个Optional的值很容易,只需要在类型后面加上问号(?)就行了,如: `var str: String?` 一个Optional值和非Optional值的区别就在于:Optional值未经初始化虽然为nil,但普通变量连nil都没有: ~~~ //未被初始化,但是是一个Optional类型,为nil var str: String? str //输出nil //未被初始化,也不是Optional类型 var str2: String str2    //使用时出错 ~~~ ## Optional的拆包 ### 显式拆包 Optional类型的值不能被直接使用,当需要用时要显式拆包,以表明我知道这个Optional是一定有值的: ~~~ var str: String? = "Hello World!" str! //Hello World! ~~~ 对比拆包前后,对str的输出: ~~~ var str: String? = "Hello World!" str     //{Some "Hello World!"} str!    //Hello World! ~~~ 之所以要拆包使用,是因为Optional类型其实是一个枚举:  ~~~ enum Optional : Reflectable, NilLiteralConvertible {     case None     case Some(T)     init()     init(_ some: T)     /// Haskell's fmap, which was mis-named     func map(f: (T) -> U) -> U?     func getMirror() -> MirrorType     static func convertFromNilLiteral() -> T? } ~~~ 当Optional没有值时,返回的nil其实就是Optional.None,即没有值。除了None以外,还有一个Some,当有值时就是被Some包装的真正的值,所以我们拆包的动作其实就是将Some里面的值取出来。 有没有似曾相识的感觉?Java里面也有泛型。 ### 隐式拆包 除了显式拆包,Optional还提供了隐式拆包,通过在声明时的数据类型后面加一个感叹号(!)来实现: ~~~ var str: String! = "Hello World!" str //Hello World! ~~~ 可以看到没有使用(?)进行显式的折包也得到了Some中的值,这个语法相当于告诉编译器:在我们使用Optional值前,这个Optional值就会被初始化,并且总是会有值,所以当我们使用时,编译器就帮我做了一次拆包。如果你确信你的变量能保证被正确初始化,那就可以这么做,否则还是不要尝试为好。 **另外:**在上面可以看到,Optional其实就是一个枚举,然后给它指定一个类型就行了,所以下面这两种方法都能声明一个Optional值: ~~~ var str: String! = "Hello World!" var str2: OptionalString> ~~~ ## Optional Binding 在说Optional Binding之前,我想先说下Xcode6 Beta5在这一版中的一个小变化:在Xcode6 Beta5之前,如果是一个Optional值,可以直接放到条件判断语句中,如: ~~~ var str: String? = "Hello World!" if str {     "not nil" } else {     "nil" } ~~~ 如果不是nil,则右边的Playground会显示“not nil”;反之则显示“nil”,但是至Xcode6 Beta5开始,这样就不能通过编译器了,你需要用下面这种方式来代替: ~~~ var str: String? = "Hello World!" if str != nil {     "not nil" } else {     "nil" } ~~~ 看似合理,但是在某种情况下会非常不爽![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca449b6adc.gif),比如你在str != nil条件成真后接着在上下文中使用str,会被要求进行拆包,我们以一个Int类型的Optional来做示例: ~~~ var count: Int? count = 100 if count != nil {     "count is " + String(count!)    //count is 100 } else {     "nil" } ~~~ 我在把count强转成String的时候被要求拆包了,这是因为count本身是一个Optional的类型,为了避免在条件判断语句后执行一次或更多次的拆包,Swift引进了Optional Binding,我们就可以这样做: ~~~ var count: Int? count = 100 if let validCount = count {     "count is " + String(validCount)    //count is 100 } else {     "nil" } ~~~ 通过在条件判断语句中(如if、while等)把Optional值直接给一个临时常量,Swift会自动检测Optional是否包含值,如果包含值,会隐式的拆包并给那个临时常量,在接下来的上下文中就能直接使用这个临时常量了,这样是不是就觉得很爽呢![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca449c5105.gif) 注:在Optional Binding中,除了以常量的方式去接收拆包的值之外,也能以一个变量的形式 去接收,但相信在大多数情况下我们只是使用那个值就行了,并不会去改变它。 **Swift 1.2 新语法:** 在if let 中可以使用条件判断了:  ~~~ var a: NSString? a = "test" if let b = a {     b } if true, let b = a where b == "test" {     "true" }  ~~~ 如果a 不是**"test"**,则不会打印出**"true"**。 ## Optional Chaining Optional Chaining对Swift来说是很基本但又必不可少的东西,相对于简单类型(Int、String等)来说,Optional更主要的应用场景是在复杂对象上,当一个对象包含另一个对象,同时这两个对象都有可能为nil的情况下才是Optional派上用场的地方,在Objective-C里,向nil发消息得到的就是一个nil,但是Swift不能在nil上直接调用方法或属性,同时为了方便我们使用,从而引入了Optional类型,可是这还不够,我们做一个简单的例子: ~~~ class Person {     var pet: Pet? } class Pet {     var name: String     var favoriteToy: Toy?     init (name: String) {         self.name = name     } } class Toy {     var name: String     init (name: String) {         self.name = name     } } ~~~ 一个Person对象代表一个人,这个人可能有一个宠物,宠物会有它自己的名字,而且宠物可能会有自己喜爱的玩具,按照前面提到的知识,我们要首先判断这个人有没有宠物,然后再判断他的宠物有没有喜爱的玩具,然后才能得到这个玩具的名称,利用Optional Binding,我们写出来的可能就像这样: ~~~ let jackon = Person() jackon.pet = Pet(name: "Max") jackon.pet?.favoriteToy = Toy(name: "Ball") if let pet = jackon.pet {     if let toy = pet.favoriteToy {         toy.name     } } ~~~ 这里用到了两个if,因为pet和toy对象都可能为nil,我们需要预防每一个可能为nil的对象,如果这个对象再复杂一点,那if也就更多了,而使用Optional Chaining的话,写出来的就像这样: ~~~ let jackon = Person() jackon.pet = Pet(name: "Max") jackon.pet?.favoriteToy = Toy(name: "Ball") if let toy = jackon.pet?.favoriteToy {     toy.name } ~~~ 当一个Optional值调用它的另一个Optional值的时候,Optional Chaining就形成了,基本上,Optional Chaining就是**总是返回一个Optional**的值,只要这个Chaining中有一个值为nil,整条Chaining就为nil,和Objective-C的向nil发消息类似。 有一点很有趣,就是Optional Chaining除了能将属性返回的类型变为Optional外,连方法的返回值都能强制变为Optional,哪怕这个方法没有返回值,但是别忘了,Void也算是一个类型: `typealias Void = ()` 如果我们的Pet类有一个玩玩具的play方法的话,就可以这样来判断是否会调用成功: ~~~ if let p: Void = jackon.pet?.play() {     "play is called" } ~~~ 使用Optional Chaining,能使我们的代码变得更加可读,同时更加简洁。
';

Swift 元组(Tuples)介绍

最后更新于:2022-04-01 07:19:42

# Swift 元组(Tuples)介绍 ## 元组的定义 元组是Objective-C中没有的数据类型,与数组类似,都是表示一组数据的集合,但与数组不同,它的特点是: * 元组的长度任意 * 元组中的数据可以是不同的数据类型 元组的定义很简单,用小括号括起来,然后以逗号隔开就可以了,如: ` var userInfo = ("Bannings" ,true, 22)  ` ## 读取元组中的数据 这样就创建了一个元组,而想要获取其中的值,则有多种方法,可以直接通过索引来取: ~~~ userInfo.0  //Bannings   userInfo.1  //true   userInfo.2  //22   ~~~ 但是这样并不方便,也不直观,那么可以用下面这种方式来访问: ~~~ let (name, isMale, age) = userInfo   name    //Bannings   isMale  //true   age     //22   ~~~ 把已知的userInfo赋给一个全是变量名组成的新的元组,那么就能以变量名去访问元组中的数据了。 还有另外一种方法也能以变量名的方式去访问元组中的数据,那就是在元组初始化的时候就给它一个变量名: ~~~ let userInfo = (name:"Bannings" ,isMale:true, age:22)   userInfo.name       //Bannings   userInfo.isMale     //true   userInfo.age        //22   ~~~ ## 跳过不关心的值 除此之外,元组还支持“跳过”某些你并不关心的值,只需要用下划线(_)去忽略它们就行了: ~~~ let (name,  _, age) = userInfo   name    //Bannings   //isMale  这个就不能访问了   age     //22   ~~~ 或者是在该元组初始化的时候不给它指定变量名: ~~~ let userInfo = (name:"Bannings" ,true, age:22)   userInfo.name       //Bannings   //userInfo.isMale     这个就不能访问了   userInfo.age        //22   ~~~ ## 可变元组和不可变元组 用var定义的元组就是可变元组,let定义的就是不可变元组。不管是可变还是不可变元组,元组在创建后就不能对其长度进行增加和删除之类的修改,只有可变元组能在创建之后修改元组中的数据: ~~~ var userInfo = (name:"Bannings" ,true, age:22)"white-space:pre">    //定义可变元组   userInfo.name = "newName"   userInfo.name   //newName      let userInfo1 = (name:"Bannings" ,true, age:22)"white-space:pre">   //定义不可变元组   userInfo1.name = "newName"  //报错,不可修改   ~~~ 需要注意的是,可变元组虽然可以修改数据,但却不能改变其数据的数据类型: ~~~ var userInfo = (name:"Bannings" ,true, age:22)   userInfo.name = 1   //报错   ~~~
';

iOS 自定义页面的切换动画与交互动画 By Swift

最后更新于:2022-04-01 07:19:39

# iOS 自定义页面的切换动画与交互动画 By Swift 在iOS7之前,开发者为了寻求自定义Navigation Controller的Push/Pop动画,只能受限于子类化一个UINavigationController,或是用自定义的动画去覆盖它。但是随着iOS7的到来,Apple针对开发者推出了新的工具,以更灵活地方式管理UIViewController切换。 我把最终的Demo稍做修改,算是找了一个合适的应用场景,另外配上几张美图,拉拉人气![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca4488de4a.gif)。 虽然是Swift的Demo,但是转成Objective-C相当容易。 ## 最终效果预览: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca448ad4f2.jpg) ## 自定义导航栏的Push/Pop动画 为了在基于UINavigationController下做自定义的动画切换,先建立一个简单的工程,这个工程的rootViewController是一个UINavigationController,UINavigationController的rootViewController是一个简单的UIViewController(称之为主页面),通过这个UIViewController上的一个Button能进入到下一个UIViewController中(称之为详情页面),我们先在主页面的ViewController上实现两个协议:UINavigationControllerDelegate和**UIViewControllerAnimatedTransitioning**,然后在ViewDidLoad里面把navigationController的delegate设为self,这样在导航栏Push和Pop的时候我们就知道了,然后用一个属性记下是Push还是Pop,就像这样: ~~~ func navigationController(navigationController: UINavigationController!, animationControllerForOperation operation: UINavigationControllerOperation, fromViewController fromVC: UIViewController!, toViewController toVC: UIViewController!) -> UIViewControllerAnimatedTransitioning! {     navigationOperation = operation     return self } ~~~ 这是iOS7的新方法,这个方法需要你提供一个UIViewControllerAnimatedTransitioning,那**UIViewControllerAnimatedTransitioning**到底是什么呢? UIViewControllerAnimatedTransitioning是苹果新增加的一个协议,其目的是在需要使用自定义动画的同时,又不影响视图的其他属性,让你把焦点集中在动画实现的本身上,然后通过在这个协议的回调里编写自定义的动画代码,即“切换中应该会发生什么”,负责切换的具体内容,任何实现了这一协议的对象被称之为**动画控制器。**你可以借助协议能被任何对象实现的这一特性,从而把各种动画效果封装到不同的类中,只要方便使用和管理,你可以发挥一切手段。我在这里让主页面实现动画控制器也是可以的,因为它是导航栏的rootViewController,会一直存在,我只要在里面编写自定义的Push和Pop动画代码就可以了: ~~~ //UIViewControllerTransitioningDelegate func transitionDuration(transitionContext: UIViewControllerContextTransitioning!) -> NSTimeInterval {     return 0.4 } func animateTransition(transitionContext: UIViewControllerContextTransitioning!) {     let containerView = transitionContext.containerView()     let toViewController = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)     let fromViewController = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)     var destView: UIView!     var destTransform: CGAffineTransform!     if navigationOperation == UINavigationControllerOperation.Push {         containerView.insertSubview(toViewController.view, aboveSubview: fromViewController.view)         destView = toViewController.view         destView.transform = CGAffineTransformMakeScale(0.1, 0.1)         destTransform = CGAffineTransformMakeScale(1, 1)     } else if navigationOperation == UINavigationControllerOperation.Pop {         containerView.insertSubview(toViewController.view, belowSubview: fromViewController.view)         destView = fromViewController.view         // 如果IDE是Xcode6 Beta4+iOS8SDK,那么在此处设置为0,动画将不会被执行(不确定是哪里的Bug)         destTransform = CGAffineTransformMakeScale(0.1, 0.1)     }     UIView.animateWithDuration(transitionDuration(transitionContext), animations: {         destView.transform = destTransform         }, completion: ({completed in             transitionContext.completeTransition(true)         })) } ~~~ 上面第一个方法返回动画持续的时间,而下面这个方法才是具体需要实现动画的地方。UIViewControllerAnimatedTransitioning的协议都包含一个对象:transitionContext,通过这个对象能获取到切换时的上下文信息,比如从哪个VC切换到哪个VC等。我们从transitionContext获取containerView,这是一个特殊的容器,切换时的动画将在这个容器中进行;UITransitionContextFromViewControllerKey和UITransitionContextToViewControllerKey就是从哪个VC切换到哪个VC,容易理解;除此之外,还有直接获取view的UITransitionContextFromViewKey和UITransitionContextToViewKey等。 我按Push和Pop把动画简单的区分了一下,Push时scale由小变大,Pop时scale由大变小,不同的操作,toViewController的视图层次也不一样。最后,在动画完成的时候调用**completeTransition**,告诉transitionContext你的动画已经结束,这是非常重要的方法,**必须调用**。在动画结束时没有对containerView的子视图进行清理(比如把fromViewController的view移除掉)是因为transitionContext会自动清理,所以我们无须在额外处理。 注意一点,这样一来会发现原来导航栏的交互式返回效果没有了,如果你想用原来的交互式返回效果的话,在返回动画控制器的delegate方法里返回nil,如: ~~~ if operation == UINavigationControllerOperation.Push {     navigationOperation = operation     return self } return nil ~~~ 然后在viewDidLoad里,Objective-C直接self.navigationController.interactivePopGestureRecognizer.delegat = self就行了,Swift除了要navigationController.interactivePopGestureRecognizer.delegate = self之外,还要在self上声明实现了UIGestureRecognizerDelegate这个协议,虽然实际上你并没有实现。 一个简单的自定义导航栏Push/Pop动画就完成了。 ## 自定义Modal的Present/Dismiss动画 自定义Modal的Present与Dismiss动画与之前类似,都需要提供一个动画管理器,我们用详情页面来展示一个Modal页面,详情页面就作为动画管理器: ~~~ func transitionDuration(transitionContext: UIViewControllerContextTransitioning!) -> NSTimeInterval {     return 0.6 } func animateTransition(transitionContext: UIViewControllerContextTransitioning!) {     let containerView = transitionContext.containerView()     let toViewController = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)     let fromViewController = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)     var destView: UIView!     var destTransfrom = CGAffineTransformIdentity     let screenHeight = UIScreen.mainScreen().bounds.size.height     if modalPresentingType == ModalPresentingType.Present {         destView = toViewController.view         destView.transform = CGAffineTransformMakeTranslation(0, screenHeight)         containerView.addSubview(toViewController.view)     } else if modalPresentingType == ModalPresentingType.Dismiss {         destView = fromViewController.view         destTransfrom = CGAffineTransformMakeTranslation(0, screenHeight)         containerView.insertSubview(toViewController.view, belowSubview: fromViewController.view)     }     UIView.animateWithDuration(transitionDuration(transitionContext), delay: 0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0,         options: UIViewAnimationOptions.CurveLinear, animations: {             destView.transform = destTransfrom         }, completion: {completed in             transitionContext.completeTransition(true)     }) } ~~~ 动画部分用了一个iOS7的弹簧动画,usingSpringWithDamping的值设置得越小,弹的就越明显,动画的其他地方与之前类似,不一样的是之前主页面除了做动画管理器之外,还实现了UINavigationControllerDelegate协议,因为我们是自定义导航栏的动画,而在这里需要自定义Modal动画就要实现另一个协议:**UIViewControllerTransitioningDelegate**,这个协议与之前的UINavigationControllerDelegate协议具有相似性,都是返回一个动画管理器,iOS7的方法总共有四个,有两个交互式的先不管,我们只需要实现另两个即可: ~~~ func animationControllerForPresentedController(presented: UIViewController!, presentingController presenting: UIViewController!, sourceController source: UIViewController!) -> UIViewControllerAnimatedTransitioning! {     modalPresentingType = ModalPresentingType.Present     return self } func animationControllerForDismissedController(dismissed: UIViewController!) -> UIViewControllerAnimatedTransitioning! {     modalPresentingType = ModalPresentingType.Dismiss     return self } ~~~ 我同样的用一个属性记下是Present还是Dismiss,然后返回self。因为我是用的Storyboard,所以需要在prepareForSegue方法里设置一下transitionDelegate: ~~~ override func prepareForSegue(segue: UIStoryboardSegue!, sender: AnyObject!) {     let modal = segue.destinationViewController as UIViewController     modal.transitioningDelegate = self } ~~~ 对需要执行自定义动画的VC设置transitionDelegate属性即可。 如此一来,一个针对模态VC的自定义动画也完成了。 ## 自定义导航栏的交互式动画 与动画控制器类似,我们把实现了**UIViewControllerInteractiveTransitioning**协议的对象称之为**交互控制器**,最常用的就是把交互控制器应用到导航栏的Back手势返回上,而如果要实现一个自定义的交互式动画,我们有两种方式来完成:实现一个交互控制器,或者使用iOS提供的UIPercentDrivenInteractiveTransition类作交互控制器。 ### 使用UIPercentDrivenInteractiveTransition 我们这里就用UIPercentDrivenInteractiveTransition来完成导航栏的交互式动画。先看下UIPercentDrivenInteractiveTransition的定义: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44933dc4.jpg) 实际上这个类就是实现了UIViewControllerInteractiveTransitioning协议的交互控制器,我们使用它就能够轻松地为动画控制器添加一个交互动画。调用updateInteractiveTransition:更新进度;调用cancelInteractiveTransition取消交互,返回到切换前的状态;调用finishInteractiveTransition通知上下文交互已完成,同completeTransition一样。我们把交互动画应用到详情页面Back回主页面的地方,由于之前的动画管理器的角色是主页面担任的,Navigation Controller的delegate同一时间只能有一个,那在这里交互控制器的角色也由主页面来担任。首先添加一个手势识别器: ~~~ let popRecognizer = UIScreenEdgePanGestureRecognizer(target: self, action: Selector("handlePopRecognizer:")) popRecognizer.edges = UIRectEdge.Left self.navigationController.view.addGestureRecognizer(popRecognizer) UIScreenEdgePanGestureRecognizer继承于UIPanGestureRecognizer,能检测从屏幕边缘滑动的手势,设置edges为left检测左边即可。然后实现handlePopRecognizer: func handlePopRecognizer(popRecognizer: UIScreenEdgePanGestureRecognizer) {     var progress = popRecognizer.translationInView(navigationController.view).x / navigationController.view.bounds.size.width     progress = min(1.0, max(0.0, progress))     println("\(progress)")     if popRecognizer.state == UIGestureRecognizerState.Began {         println("Began")         self.interactivePopTransition = UIPercentDrivenInteractiveTransition()         self.navigationController.popViewControllerAnimated(true)     } else if popRecognizer.state == UIGestureRecognizerState.Changed {         self.interactivePopTransition?.updateInteractiveTransition(progress)         println("Changed")     } else if popRecognizer.state == UIGestureRecognizerState.Ended || popRecognizer.state == UIGestureRecognizerState.Cancelled {         if progress > 0.5 {             self.interactivePopTransition?.finishInteractiveTransition()         } else {             self.interactivePopTransition?.cancelInteractiveTransition()         }         println("Ended || Cancelled")         self.interactivePopTransition = nil     } } ~~~ 我用了一个实例变量引用UIPercentDrivenInteractiveTransition,这个类只在需要用时才创建,否则在正常Push/Pop的时候,即使只是点击操作并没有识别手势的情况下,也会进入交互(你也可以在要求你返回交互控制器时,进行一些判断,通过返回nil来屏蔽,但这显然就太麻烦了)。当手势识别的时候我们调用pop,用户手势发生变化时,调用update去更新,不管是end还是cancel,都判断下是进入下一个页面还是返回之前的页面,完成这一切后把交互控制器清理掉。 现在我们已经有了交互控制器对象,只需要把它给告知给Navigation Controller就行了,我们实现UINavigationControllerDelegate的另一个方法: ~~~ func navigationController(navigationController: UINavigationController!, interactionControllerForAnimationController animationController: UIViewControllerAnimatedTransitioning!) -> UIViewControllerInteractiveTransitioning! {     return self.interactivePopTransition } ~~~ 我们从详情页面通过自定义的交互动画返回到上一个页面的工作就完成了。 Demo效果预览: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca449623d3.jpg) [使用UIPercentDrivenInteractiveTransition的Demo](http://download.csdn.net/download/zhangao0086/7740937) ### 自定义交互控制器 我在之前提过,UIPercentDrivenInteractiveTransition实际上就是实现了**UIViewControllerInteractiveTransitioning**协议,只要是实现了这个协议的对象就可以称之为交互控制器,我们如果想更加精确的管理动画以及深入理解处理上的细节,就需要自己实现**UIViewControllerInteractiveTransitioning**协议。 UIViewControllerInteractiveTransitioning协议总共有三个方法,其中startInteractiveTransition:是必须实现的方法,我们在里面初始化动画的状态: ~~~ func startInteractiveTransition(transitionContext: UIViewControllerContextTransitioning!) {     self.transitionContext = transitionContext     let containerView = transitionContext.containerView()     let toViewController = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)     let fromViewController = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)     containerView.insertSubview(toViewController.view, belowSubview: fromViewController.view)     self.transitingView = fromViewController.view } ~~~ 这里不涉及动画,只是把需要切换的view添加到上下文环境中即可。动画部分我们还是和之前使用UIPercentDrivenInteractiveTransition的接口保持一致,添加几个方法: ~~~ func updateWithPercent(percent: CGFloat) {     let scale = CGFloat(fabsf(Float(percent - CGFloat(1.0))))     transitingView?.transform = CGAffineTransformMakeScale(scale, scale)     transitionContext?.updateInteractiveTransition(percent) } func finishBy(cancelled: Bool) {     if cancelled {         UIView.animateWithDuration(0.4, animations: {             self.transitingView!.transform = CGAffineTransformIdentity             }, completion: {completed in                 self.transitionContext!.cancelInteractiveTransition()                 self.transitionContext!.completeTransition(false)         })     } else {         UIView.animateWithDuration(0.4, animations: {             print(self.transitingView)             self.transitingView!.transform = CGAffineTransformMakeScale(0, 0)             print(self.transitingView)             }, completion: {completed in                 self.transitionContext!.finishInteractiveTransition()                 self.transitionContext!.completeTransition(true)         })     } } ~~~ updateWithPercent:方法用来更新view的transform属性,finishBy:方法主要用来判断是进入下一个页面还是返回到之前的页面,并告知transitionContext目前的状态,以及对当前正在scale的view做最后的动画。这里的transitionContext和transitingView可以在前面的处理手势识别代码中取得,我将里面的代码更新了一下,变成下面这样: ~~~ func handlePopRecognizer(popRecognizer: UIScreenEdgePanGestureRecognizer) {     var progress = popRecognizer.translationInView(navigationController.view).x / navigationController.view.bounds.size.width     progress = min(1.0, max(0.0, progress))     println("\(progress)")     if popRecognizer.state == UIGestureRecognizerState.Began {         println("Began")         isTransiting = true         //self.interactivePopTransition = UIPercentDrivenInteractiveTransition()         self.navigationController.popViewControllerAnimated(true)     } else if popRecognizer.state == UIGestureRecognizerState.Changed {         //self.interactivePopTransition?.updateInteractiveTransition(progress)         updateWithPercent(progress)         println("Changed")     } else if popRecognizer.state == UIGestureRecognizerState.Ended || popRecognizer.state == UIGestureRecognizerState.Cancelled {         //if progress > 0.5 {         //    self.interactivePopTransition?.finishInteractiveTransition()         //} else {         //    self.interactivePopTransition?.cancelInteractiveTransition()         //}         finishBy(progress 0.5)         println("Ended || Cancelled")         isTransiting = false         //self.interactivePopTransition = nil     } } ~~~ 另外还用一个额外布尔值变量isTransiting来标识当前是否在手势识别中,这是为了在返回交互控制器的时候,不会在不当的时候返回self: ~~~ func navigationController(navigationController: UINavigationController!, interactionControllerForAnimationController animationController: UIViewControllerAnimatedTransitioning!) -> UIViewControllerInteractiveTransitioning! {     if !self.isTransiting {         return nil     }     return self } ~~~ 这样一来就完成了自定义交互控制器。可以发现,基本流程与使用UIPercentDrivenInteractiveTransition是一致的,UIPercentDrivenInteractiveTransition主要是帮我们封装了transitionContext的初始化以及对它的调用等,只是动画部分需要我们在额外处理一下了。 [使用自定义交互控制器的Demo](http://download.csdn.net/download/zhangao0086/7741237) ## 最终效果: 我在主页面上多放了几个带Image的Button,在点击Button时会将Button的Image传递到详情页面,详情页面相应的也有一个UIImageView用来显示。在主页面初始化动画状态的时候,会生成一个Image的快照来进行动画,要是在以前,我们只能通过UIGraphics的APIs进行一系列的操作,涉及视图的scale、旋转、透明及渲染到context等,但现在,我们只需要用iOS7的API就行了: ~~~ @availability(iOS, introduced=7.0) func snapshotViewAfterScreenUpdates(afterUpdates: Bool) -> UIView ~~~ 这个API能帮助我们快速获取一个视图的的快照,afterUpdates参数表示是否等所有效果应用到该视图之后再获取,如果设置为false,则立即获取;为true则会受到后面对该视图的影响。 在动画之前,把主页面和详情页面对应的Button和ImageView隐藏,然后对快照生成的View进行动画,动画用简单的frame隐式动画就可以了。 [最终效果的Demo](https://github.com/zhangao0086/iOS_AnimatedTransition)(上传到我的资源页面时总是失败,所以只能上传到GitHub上了) [](https://github.com/zhangao0086/iOS_AnimatedTransition) 最后附上一张图,这个图比较容易区分那几个名称相近的协议: ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44993ea1.jpg) ## UPDATED: GitHub上已更新至Xcode 6,主要是语法上的一些小调整
';

前言

最后更新于:2022-04-01 07:19:37

> 原文出处:[Swift开发集锦](http://blog.csdn.net/column/details/banningsswiftrelated.html) 作者:[张奥](http://blog.csdn.net/zhangao0086) **本系列文章经作者授权在看云整理发布,未经作者允许,请勿转载!** # Swift开发集锦 > 此专栏将会收录与Swift技术相关的资源与内容,不仅限于代码
';