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
进行样式的控制不可以吗?
好,先别急,这里卖个关子,我们继续往下看。
第三部分,selectedBackgroundView
和titleMaskView
,从名字看,也不能一下子了解含义,我们先全局搜索下相关连的代码,与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
,我们实时改变titleMaskView
的frame
。而通过实际运行项目,我们可以很容易理解selectedBackgroundView
就是用户可拖拽的选项高亮条。
到这,我渐渐有点理解作者为什么要构建两个完全一样的contentView,并都包含左右两个UILabel了。
作者应该是对于titleLabelsContentView
设定为普通状态的Label,左右两个Label都是未选中的颜色状态,同时将selectedTitleLabelsContentView
设定为选定状态,左右Label都使用了选中时候的颜色状态,然后通过titleMaskView
进行遮罩,这样,selectedTitleLabelsContentView
其余部分就被隐藏,会显示出下部titleLabelsContentView
普通状态的Label颜色。
嘿嘿,读一下剩下的源代码,和我的猜测一致,不得不说,我真是太聪明了,这个思路真是太赞了。
如何真正实现一个好的UI库
看到这个小标题,可能有人会产生疑惑,实现好一个UI库不就是功能正确,效果正常吗?错!
我认为这只是基本的两点,还有如下几点需要包含:
- 使用正确的类型
- 在正确的函数中做正确的事
- 暴露不过多也不过少的属性
- 抛出、监听相对应的事件
- 根据不同屏幕大小、屏幕方向进行适配
- 横竖屏情况都能展示良好
第一,从DGRunkeeperSwitch来看,首先由于其模仿的是UISegmentControl,所以自然而然的应该继承与UIControl而不是UIView。有人要问有啥区别,简单来说就是UIControl将UIView中能接受的Touch事件,转换成了更高级的UIEvent,比如UITouchUpInside。
第二,作者通过init函数进行初始化,通过layoutSubview进行页面布局,而不是像很多人自己写代码时将很多东西一窝蜂的堆到了init中。
提供了颜色、字体、边距以及动画弹性等属性给外部调用,同时将不应该暴露的内部UIKit变量进行私有化,并将
selectedIndex
通过private(set)
对外设置为只读。在切换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
}
好啦,今天就差不多到这啦~下周再见。