DGRunKeeperSwitch 源码解析

DGRunKeeperSwitch是非常有趣的自定义的Segment Control的实现,从其Github上的展现效果来看,可以发现在 同一个 UILabel中的文本竟然可以展现出两种不同的颜色,是不是很奇妙?今天就让我们来看看它是如何实现的。

源码分析

打开项目,发现这个项目真的很简单,就一个文件,DGRunkeeperSwitch.swift,并且实现也只有接近260行左右。

既然这个项目是个UI的开源库,我们主要还是先从界面层级入手。和Glow的开源库(GLCalendar)不同,这个是纯手写的控件,因此无法从.xib文件来快速了解,所以我们把目标首先投向相关的UIKit子属性,包括如下:

// 1. 
private var titleLabelsContentView = UIView()
private var leftTitleLabel = UILabel()
private var rightTitleLabel = UILabel()

// 2.
private var selectedTitleLabelsContentView = UIView()
private var selectedLeftTitleLabel = UILabel()
private var selectedRightTitleLabel = UILabel()

// 3.
private(set) var selectedBackgroundView = UIView() 
private var titleMaskView: UIView = UIView()

其中第一部分我们一看命名就很容易理解了,有一个ContentView作为container,包含了segment control对应的左右两个Label。

然后来看第二部分,第二部分从命名上也很直观,感觉上和第一部分是一致的,但是却可能代表的是选中的状态。不过我们很奇怪,作者为什么要构建一个一模一样的来表征不同的状态呢,直接用一个变量比如 var selected = false 进行样式的控制不可以吗?

好,先别急,这里卖个关子,我们继续往下看。

第三部分,selectedBackgroundViewtitleMaskView,从名字看,也不能一下子了解含义,我们先全局搜索下相关连的代码,与titleMaskView相关的内容如下:

titleMaskView.backgroundColor = .blackColor()
selectedTitleLabelsContentView.layer.mask = titleMaskView.layer

看起来是用titleMaskView给之前可能的选中状态的selectedTitleLabelsContentView加了一层遮罩。

由于遮罩是白色的地方不显示,黑色的地方(准确来说是非白色的区域)显示,因此我们可以理解上述代码是通过titleMaskView来显示selectedTitleLabelsContentView中的内容(也就是两个UILabel),非titleMaskView区域自动隐藏了。

addObserver(self, forKeyPath: "selectedBackgroundView.frame", options: .New, context: nil)

override public func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer<Void>) {
    if keyPath == "selectedBackgroundView.frame" {
        titleMaskView.frame = selectedBackgroundView.frame
    }
}

哦,看完上述这段代码,我开始有点恍然大悟了,通过监听selectedBackgroundView.frame,我们实时改变titleMaskViewframe。而通过实际运行项目,我们可以很容易理解selectedBackgroundView就是用户可拖拽的选项高亮条。

到这,我渐渐有点理解作者为什么要构建两个完全一样的contentView,并都包含左右两个UILabel了。

作者应该是对于titleLabelsContentView设定为普通状态的Label,左右两个Label都是未选中的颜色状态,同时将selectedTitleLabelsContentView设定为选定状态,左右Label都使用了选中时候的颜色状态,然后通过titleMaskView进行遮罩,这样,selectedTitleLabelsContentView其余部分就被隐藏,会显示出下部titleLabelsContentView普通状态的Label颜色。

嘿嘿,读一下剩下的源代码,和我的猜测一致,不得不说,我真是太聪明了,这个思路真是太赞了。

如何真正实现一个好的UI库

看到这个小标题,可能有人会产生疑惑,实现好一个UI库不就是功能正确,效果正常吗?错!

我认为这只是基本的两点,还有如下几点需要包含:

  • 使用正确的类型
  • 在正确的函数中做正确的事
  • 暴露不过多也不过少的属性
  • 抛出、监听相对应的事件
  • 根据不同屏幕大小、屏幕方向进行适配
  • 横竖屏情况都能展示良好
  1. 第一,从DGRunkeeperSwitch来看,首先由于其模仿的是UISegmentControl,所以自然而然的应该继承与UIControl而不是UIView。有人要问有啥区别,简单来说就是UIControl将UIView中能接受的Touch事件,转换成了更高级的UIEvent,比如UITouchUpInside。

  2. 第二,作者通过init函数进行初始化,通过layoutSubview进行页面布局,而不是像很多人自己写代码时将很多东西一窝蜂的堆到了init中。

  3. 提供了颜色、字体、边距以及动画弹性等属性给外部调用,同时将不应该暴露的内部UIKit变量进行私有化,并将selectedIndex通过private(set)对外设置为只读。

  4. 在切换Segment选择后,抛出了相应的sendActionsForControlEvents(.ValueChanged) 用于给外部监听。

效果之外的重点

作者在实现这个项目之中,有几点是比较值得注意的:

利用元组同时赋值多个属性

public var leftTitle: String {
    set { (leftTitleLabel.text, selectedLeftTitleLabel.text) = (newValue, newValue) }
    get { return leftTitleLabel.text! }
}

在Swift中引入了一个元组的新类型,我们可以利用这个数据结构同时给多个属性赋值。

private(set)

private(set) public var selectedIndex: Int = 0

作者在实现过程中保留了一个selectedIndex 变量,但是这个类对外只读,对内可以读写,因此用了private(set)

这相当于在Objective-C时代,我们在.h文件中声明 @property(nonatomic, strong, readonly) Class *A
然后又在.m文件中,声明 @property(nonatomic, strong, readwrite) Class *A

UIView和CALayer

很多人写iOS的时候,分不清UIView和CALayer之间的区别,很多人都理解成了继承的关系。大错特错!

  • 实际上UIView里面有个成员变量是CALayer,而CALayer的delegate是UIView(这会涉及到很多的隐式动画之类的,不展开了)
  • UIView可以接受Touch事件,而Layer不行
  • UIView有个layerClass的类型方法,可以被复写,用于改变这个UIView对应的基础Layer类型,比如你可以将赋值CAGradientLayer给这个View

在本项目中,作者复写了layerClass,如下:

override public class func layerClass() -> AnyClass {
    return DGRunkeeperSwitchRoundedLayer.self
}

好啦,今天就差不多到这啦~下周再见。