如何用 Swift 语言构建一个自定控件

本文译自:How To Make a Custom Control in Swift

用户界面控件是所有应用程序重要的组成部分之一。它们以图形组件的方式呈现给用户,用户可以通过它们与应用程序进行交互。苹果提供了一套控件,例如 UITextFieldUIButtonUISwitch。通过工具箱中的这些已有控件,我们可以创建各式各样的用户界面。

然而,有时候你希望界面做得稍微的与众不同,那么此时苹果提供的这些控件就无法满足你的需求。

自定义控件,除了是自己构建二外,与苹果提供的,没什么差别。也就是说,自定义控件不存在于 UIKit 框架。自定义控件跟苹果提供的标准控件一样,应该是通用,并且多功能的。你也会发现,互联网上有一些积极的开发者乐意分享他们自定义的控件。

本文中,你将实现一个自己的 RangeSlider 自定义控件。这个控件是一个两端都可以滑动的,也就是说,你可以通过该控件获得最小值和最大值。你将会接触到这样一些概念:对现有控件的扩展,设计和实现自定义控件的 API,甚至还能学到如何分享你的自定义控件到开发社区中。

注意:本文截稿时,我们还不会贴出关于 iOS 8 beta 版本的截图。所有文中涉及到的截图都是在iOS 8之前的版本中得到的,不过结果非常类似。

目录:


开始

假设你在开发一个应用程序,该程序提供搜索商品价格列表。通过这个假象的应用程序允许用户对搜索结果进行过滤,以获得一定价格范围的商品。你可能会提供这样一个用户界面:两个 UISlider 控件,一个用于设置最低价格,另外一个设置最高价格。然而,这样的设计,不能够让用户很好的感知价格的范围。要是能够提供一个 slider,两端可以分别设置用于搜索的最高和最低的价格范围,就更好了。

你可以通过创建一个 UIView 的子类,然后为可视的价格范围定做一个 view。这对于应用程序内部来说,是 ok的,但是要想移植到别的程序中,就需要花更多的精力了。

最好的办法是将构建一个新的尽可能通用的 UI 控件,这样就能在任意的合适场合中重用。这也是自定义控件的本质。

启动 Xcode,File/New/Project,选中 iOS/Application/Single View Application 模板,然后点击 Next。在接下来的界面中,输入 CustomSliderExample 当做工程名,然后是 Organization NameOrganization Identifier,然后,一定要确保选中 Swift 语言,iPhone 选中,Use Core Data 不要选。

最后,选择一个保存工程的地方并单击 Create

首先,我们需要做出决定的就是创建自定义控件需要继承自哪个类,或者对哪个类进行扩展。

位了使自定义控件能够在应用程序中使用,你的类必须是 UIView 的一个子类。

如果你注意观察苹果的 UIKit 参考,会发现框架中的许多控件,例如 UILabelUIWebView 都是直接继承自 UIView 的。然而,也有极少数,例如 UIButtonUISwitch 是继承自 UIControl 的,如下继承图所示:

注意:iOS 中 UI 组件的完整类继承图,请看 UIKit Framework 参考

UIControl 实现了 target-action 模式,这是一种将变化通知订阅者的机制。UIControl 同样还有一些与控件状态相关的属性。在本文中的自定义空间中,将使用到 target-action 模式,所以从 UIControl 开始继承使用将是一个非常好的切入点。

在 Project Navigator 中右键单击 CustomSliderExample,选择 New File…,然后选择 iOS/Source/Cocoa Touch Class 模板,并单击 Next。将类命名位 RangeSlider,在 Subclass of 字段中输入 UIControl,并确保语言是 Swift。然后单击 Next,并在默认存储位置中 Create 出新的类。

虽然编码非常让人愉悦,不过你可能也希望尽快看到自定义控件在屏幕中熏染出来的模样!在写自定义控件相关的任何代码之前,你应该先把这个控件添加到 view controller中,这样就可以实时观察控件的演进程度。

打开 ViewController.swift,用下面的内容替换之:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import UIKit
class ViewController: UIViewController {
let rangeSlider = RangeSlider(frame: CGRectZero)
override func viewDidLoad() {
super.viewDidLoad()
rangeSlider.backgroundColor = UIColor.redColor()
view.addSubview(rangeSlider)
}
override func viewDidLayoutSubviews() {
let margin: CGFloat = 20.0
let width = view.bounds.width - 2.0 * margin
rangeSlider.frame = CGRect(x: margin, y: margin + topLayoutGuide.length,
width: width, height: 31.0)
}
}

上面的代码根据指定的 frame 实例化了一个全新的控件,然后将其添加到 view 中。为了在应用程序背景中凸显出控件,我们将控件的背景色被设置位了红色。如果不把控件的背景色设置为红色,那么控件中什么都没有,可能会想,控件去哪里了!:]

编译并运行程序,将看到如下类似界面:

在开始给控件添加可视元素之前,应该先定义几个属性,用以在控件中记录下各种信息。这也是开始应用程序编程接口 (API) 的开始。

注意:控件中定义的方法和属性是你决定用来暴露给别的开发者使用的。稍后你将看到 API 设计相关的内容,现在只需要紧跟就行!

添加默认的控件属性

打开 RangeSlider.swift,用下面的代码替换之:

1
2
3
4
5
6
7
8
import UIKit
class RangeSlider: UIControl {
var minimumValue = 0.0
var maximumValue = 1.0
var lowerValue = 0.2
var upperValue = 0.8
}

上面定义的四个属性用来描述控件的状态,提供最大值和最小值,以及有用户设置的 upper 和 lower 两个值。

好的控件设计,应该提供一些默认的属性值,否则将你的控件绘制到屏幕中时,看起来会有点奇怪。

现在是时候开始做控件的交互元素了,我们分别用两个 thumbs 表示高和低两个值,并且让这两个 thumbs 能够滑动。

Images vs. CoreGraphics

在屏幕中渲染控件有两种方法:

1、Images - 为控件构建不同的图片,这些图片代表控件的各种元素。
2、Core Graphics - 利用 layers 和 Core Graphics 组合起来熏染控件。

这两种方法都有利有弊,下面来看看:

Images - 利用图片来构建控件是最简单的一种方法 - 只要你知道如何绘制图片!:] 如果你想要让开发者能够修改控件的外观,那么你应该将这些图片以 UIImage 属性的方式暴露出去。

通过图片的方式来构建的控件,给使用控件的人提供了非常大的灵活度。开发者可以改变每一个像素,以及控件的详细外观,不过这需要非常熟练的图形设计技能 - 并且通过代码非常难以对控件做出修改。

Core Graphics - 利用 Core Graphics 构建控件意味着你必须自己编写渲染控件的代码,这就需要付出更多的代价。不过,这种方法可以创建更加灵活的 API。

使用 Core Graphics,可以把控件的所有特征都参数化,例如颜色、边框厚度和弧度 - 几乎每一个可视元素都通过绘制完成!这种方法运行开发者对控件做出任意调整,以适配相应的需求。

本文中,你将学到第二种技术 - 利用 Core Graphics 来熏染控件。

主要:有趣的时,苹果建议在他们提供的控件中使用图片。这可能是苹果知道每个控件的大小,他们不希望程序中出现太多的定制。也就是说,他们希望所有的应用程序,都具有相似的外观和体验。

打开 RangeSlider.swift 将下面的 import 添加到文件的顶部,也就是 import UIKit 下面:

1
import QuartzCore

将下面的属性添加到 RangeSlider 中,也就是我们刚刚定义的那行代码下面:

1
2
3
4
5
6
7
let trackLayer = CALayer()
let lowerThumbLayer = CALayer()
let upperThumbLayer = CALayer()
var thumbWidth: CGFloat {
return CGFloat(bounds.height)
}

这里有 3 个 layer - trackLayer, lowerThumbLayer, 和 upperThumbLayer - 用来熏染滑块控件的不同组件。thumbWidth 用来布局使用。

接下来就是控件默认的一些图形属性。

RangeSlider 类中,添加一个 初始化方法,以及一个 helper 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
override init(frame: CGRect) {
super.init(frame: frame)
trackLayer.backgroundColor = UIColor.blueColor().CGColor
layer.addSublayer(trackLayer)
lowerThumbLayer.backgroundColor = UIColor.greenColor().CGColor
layer.addSublayer(lowerThumbLayer)
upperThumbLayer.backgroundColor = UIColor.greenColor().CGColor
layer.addSublayer(upperThumbLayer)
updateLayerFrames()
}
required init(coder: NSCoder) {
super.init(coder: coder)
}
func updateLayerFrames() {
trackLayer.frame = bounds.rectByInsetting(dx: 0.0, dy: bounds.height / 3)
trackLayer.setNeedsDisplay()
let lowerThumbCenter = CGFloat(positionForValue(lowerValue))
lowerThumbLayer.frame = CGRect(x: lowerThumbCenter - thumbWidth / 2.0, y: 0.0,
width: thumbWidth, height: thumbWidth)
lowerThumbLayer.setNeedsDisplay()
let upperThumbCenter = CGFloat(positionForValue(upperValue))
upperThumbLayer.frame = CGRect(x: upperThumbCenter - thumbWidth / 2.0, y: 0.0,
width: thumbWidth, height: thumbWidth)
upperThumbLayer.setNeedsDisplay()
}
func positionForValue(value: Double) -> Double {
let widthDouble = Double(thumbWidth)
return Double(bounds.width - thumbWidth) * (value - minimumValue) /
(maximumValue - minimumValue) + Double(thumbWidth / 2.0)
}

初始化方法简单的创建了 3 个 layer,并将它们以 children 的身份添加到控件的 root layer 中,然后通过 updateLayerFrames 对这些 layer 的位置进行更新定位! :]

最后,positionForValue 方法利用一个简单的比例,对控件的最小和最大值的范围做了一个缩放,将值映射到屏幕中确定的一个位置。

接下来,override一下 frame,通过将下面的代码添加到 RangeSlider.swift 中,实现对属性的观察:

1
2
3
4
5
override var frame: CGRect {
didSet {
updateLayerFrames()
}
}

当 frame 发生变化时,属性观察者会更新 layer frame。这一步是必须的,因为当控件初始化时,传入的 frame 并不是最终的 frame,就像 ViewController.swift 中的。

编译并运行程序,可以看到滑块初具形状!看起来,如下图所示:

还记得吗,红色是整个控件的背景色。蓝色是滑块的轨迹,绿色 thumb 是两个代表两端的值。

现在控件看起来有形状了,不过几乎所有的控件都提供了相关方法,让用户与之交互。

针对本文中的控件,用户必须能够通过拖拽 2 个 thumb 来设置控件的范围。你将处理这些交互,并通过控件更新 UI 和暴露的属性。

添加交互逻辑

本文的交互逻辑需要存储那个 thumb 被拖拽了,并将效果反应到 UI 中。控件的 layer 是放置该逻辑的最佳位置。

跟之前一样,在 Xcode 中创建一个新的 Cocoa Touch Class,命名为 RangeSliderThumbLayer,继承自 CALayer

用下面的代码替换掉 RangeSliderThumbLayer.swift 文件中的内容:

1
2
3
4
5
6
7
import UIKit
import QuartzCore
class RangeSliderThumbLayer: CALayer {
var highlighted = false
weak var rangeSlider: RangeSlider?
}

上面的代码中简单的添加了两个属性:一个表示这个 thumb 是否 高亮 (highlighted),另外一个引用回父 range slider。由于 RangeSlider 有两个 thumb layer,所以将这里的引用设置位 weak,避免循环引用。

打开 RangeSlider.swift,修改一下 lowerThumbLayerupperThumbLayer 两个属性的类型,用下面的代码替换掉它们的定义:

1
2
let lowerThumbLayer = RangeSliderThumbLayer()
let upperThumbLayer = RangeSliderThumbLayer()

还是在 RangeSlider.swift 中,找到 init,将下面的代码添加进去:

1
2
lowerThumbLayer.rangeSlider = self
upperThumbLayer.rangeSlider = self

上面的代码简单的将 layer 的 rangeSlider 属性设置为 self

编译并运行程序,界面看起来没有什么变化。

现在你已经有了 slider 的thumb layer - RangeSliderThumbLayer,然后需要给控件添加拖拽 thumb 的功能。

添加触摸处理

打开 RangeSlider.swift,将下面这个属性添加进去:

1
var previousLocation = CGPoint()

这个属性用来跟踪记录用户的触摸位置。

那么你该如何来跟踪控件的各种触摸和 release 时间呢?

UIControl 提供了一些方法来跟踪触摸。UIControl 的子类可以 override 这些方法,以实现自己的交互逻辑。

在自定义控件中,我们将 override 3 个 UIControl 关键的方法:beginTrackingWithTouch, continueTrackingWithTouchendTrackingWithTouch

将下面的方法添加到 RangeSlider.swift 中:

1
2
3
4
5
6
7
8
9
10
11
12
override func beginTrackingWithTouch(touch: UITouch!, withEvent event: UIEvent!) -> Bool {
previousLocation = touch.locationInView(self)
// Hit test the thumb layers
if lowerThumbLayer.frame.contains(previousLocation) {
lowerThumbLayer.highlighted = true
} else if upperThumbLayer.frame.contains(previousLocation) {
upperThumbLayer.highlighted = true
}
return lowerThumbLayer.highlighted || upperThumbLayer.highlighted
}

当首次触摸控件时,会调用上面的方法。

代码中,首先将触摸事件的坐标转换到控件的坐标空间。然后检查每个 thumb,是否触摸位置在其上面。方法中返回的值将决定 UIControl 是否继续跟踪触摸事件。

如果任意一个 thumb 被 highlighted 了,就继续跟踪触摸事件。

现在,有了初始的触摸事件,我们需要处理用户在屏幕上移动的事件了。

将下面的方法添加到 RangeSlider.swift 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func boundValue(value: Double, toLowerValue lowerValue: Double, upperValue: Double) -> Double {
return min(max(value, lowerValue), upperValue)
}
override func continueTrackingWithTouch(touch: UITouch!, withEvent event: UIEvent!) -> Bool {
let location = touch.locationInView(self)
// 1. Determine by how much the user has dragged
let deltaLocation = Double(location.x - previousLocation.x)
let deltaValue = (maximumValue - minimumValue) * deltaLocation / Double(bounds.width - bounds.height)
previousLocation = location
// 2. Update the values
if lowerThumbLayer.highlighted {
lowerValue += deltaValue
lowerValue = boundValue(lowerValue, toLowerValue: minimumValue, upperValue: upperValue)
} else if upperThumbLayer.highlighted {
upperValue += deltaValue
upperValue = boundValue(upperValue, toLowerValue: lowerValue, upperValue: maximumValue)
}
// 3. Update the UI
CATransaction.begin()
CATransaction.setDisableActions(true)
updateLayerFrames()
CATransaction.commit()
return true
}

boundValue 会将传入的值控制在某个确定的范围。通过这个方法比嵌套调用 min/max 更容易理解。

下面我们根据注释,来分析一下 continueTrackingWithTouch 方法都做了些什么:

  1. 首先计算出位置增量,这个值决定着用户手指移动的数值。然后根据控件的最大值和最小值,对这个增量做转换。
  2. 根据用户滑动滑块的距离,修正一下 upper 或 lower 值。
  3. 设置 CATransaction 中的 disabledActions。这样可以确保每个 layer 的frame 立即得到更新,并且不会有动画效果。最后,调用 updateLayerFrames 方法将 thumb 移动到正确的位置。

至此,已经编写了移动滑块的代码 - 不过我们还要处理触摸和拖拽事件的结束。

将下面方法添加到 RangeSlider.swift 中:

1
2
3
4
override func endTrackingWithTouch(touch: UITouch!, withEvent event: UIEvent!) {
lowerThumbLayer.highlighted = false
upperThumbLayer.highlighted = false
}

上面的代码简单的将两个 thumb 还原位 non-highlighted 状态。

编译并运行程序,尝试移动滑块!现在你应该可以移动 thumb 了。

你可能注意到当在移动滑块时,可以在控件之外的范围对其拖拽,然后手指回到控件内,也不会丢失跟踪。其实这在小屏幕的设备上,是非常重要的一个功能。

值改变的通知

现在你已经有一个可以交互的控件了 - 用户可以对其进行操作,以设置范围的大小值。但是如何才能把这些值的改变通知调用者:控件有新的值了呢?

这里有多种模式可以实现值改变的通知: NSNotificationKey-Value-Observing (KVO), delegate 模式,target-action 模式等。有许多选择!

面对这么多的通知方式,那么我们该怎么选择呢?

如果你研究过 UIKit 控件,会发现它们并没有使用 NSNotification,也不鼓励使用 KVO。所以为了保持与 UIKit 的一致性,我们可以先排除这两种方法。另外的两种模式:delegate 和 target-action 被广泛用于 UIKit 中。

Delegate 模式 - delegate 模式需要提供一个 protocol,里面有一些用于通知的方法。控件中有一个属性,一般命名位 delegate,它可以是任意实现该协议的类。经典的一个示例就是 UITableView 提供了 UITableViewDelegate protocol。注意,控件只接受单个 delegate 实例。一个 delegate 方法可以使用任意的参数,所以可以给这样的方法传递尽可能多的信息。

Target-action 模式 - UIControl 基类已经提供了 target-action 模式。当控件状态发生了改变,target 会获得相应 action 的通知,该 action 是在 UIControlEvents 枚举值做定义的。我们可以给控件的 action 提供多个 target,另外还可以创建自定义事件 (查阅 UIControlEventApplicationReserved),自定义事件的数量不得超过 4 个。控件 action 针对某个事件,无法传送任意的信息,所以当事件触发时,不能用它来传递额外的信息。

这两种模式关键不同点如下:

  • 多播 (Multicast) - target-action 模式可以对改变事件进行多播通知,而 delegate 模式只能绑定到单个 delegate 实例上。
  • 灵活 (Flexibility) - 在 delegate 模式中,你可以定义自己的 protocol,这就意味着你可以控制信息的传递量。而 target-action 是无法传递额外信息的,客户端只能在收到事件后,自行查询信息。

我们的 slider 控件不会有大量的状态变化,也不需要提供大量的通知。唯一真正改变的就是控件的 upper 和 lower 值。

基于这样的情况,使用 target-action 模式是最好的。这也是为什么在本文开头的时候告诉你为什么这个控件要继承自 UIControl

slider 的值是在 continueTrackingWithTouch:withEvent: 方法中进行更新的,所以这个方法也是添加通知代码的地方。

打开 RangeSlider.swift,定位到 continueTrackingWithTouch 方法,然后将下面的代码添加到 return true 语句前面:

1
sendActionsForControlEvents(.ValueChanged)

上面的这行代码就能将值改变事件通知给任意的订阅者 target。

现在我们应该对这个事件进行订阅,并当事件来了以后,作出相应的处理。

打开 ViewController.swift,将下面这行代码添加到 viewDidLoad 尾部:

1
rangeSlider.addTarget(self, action: "rangeSliderValueChanged:", forControlEvents: .ValueChanged)

通过上面的代码,每次 slider 发送 UIControlEventValueChanged action 时,都会调用 rangeSliderValueChanged 方法。

将下面的代码添加到 ViewController.swift 中:

1
2
3
func rangeSliderValueChanged(rangeSlider: RangeSlider) {
println("Range slider value changed: (\(rangeSlider.lowerValue) \(rangeSlider.upperValue))")
}

当 slider 值发生变化是,上面这个方法简单的将 slider 的值打印出来。

编译并运行程序,并移动一下 slider,可以在控制台中看到控件的值,如下所示:

1
2
3
4
5
6
Range slider value changed: (0.117670682730924 0.390361445783134)
Range slider value changed: (0.117670682730924 0.38835341365462)
Range slider value changed: (0.117670682730924 0.382329317269078)
Range slider value changed: (0.117670682730924 0.380321285140564)
Range slider value changed: (0.119678714859438 0.380321285140564)
Range slider value changed: (0.121686746987952 0.380321285140564)

看到 控件五颜六色的,你可能不高心,它开起来就像水果沙拉一样!

现在是时候给控件换换面目了!

结合 Core Graphics 对控件进行修改

首先,首选更新一下slider thumb 移动的轨迹图形。

跟之前一样,给工程添加另外一个继承自 CALayer 的子类,命名为 RangeSliderTrackLayer

打开刚刚添加的文件 RangeSliderTrackLayer.swift,然后用下面的内容替换之:

1
2
3
4
5
6
import UIKit
import QuartzCore
class RangeSliderTrackLayer: CALayer {
weak var rangeSlider: RangeSlider?
}

上面的代码添加了一个到 slider 控件的引用,跟之前 thumb layer 做的一样。

打开 RangeSlider.swift 文件,找到 trackLayer 属性,用刚刚创建的这个类对其实例化,如下所示:

1
let trackLayer = RangeSliderTrackLayer()

接下来,找到 init 并用下面的代码替换之:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
init(frame: CGRect) {
super.init(frame: frame)
trackLayer.rangeSlider = self
trackLayer.contentsScale = UIScreen.mainScreen().scale
layer.addSublayer(trackLayer)
lowerThumbLayer.rangeSlider = self
lowerThumbLayer.contentsScale = UIScreen.mainScreen().scale
layer.addSublayer(lowerThumbLayer)
upperThumbLayer.rangeSlider = self
upperThumbLayer.contentsScale = UIScreen.mainScreen().scale
layer.addSublayer(upperThumbLayer)
}

上面的代码确保新的 track layer 引用到 range slider - 并没有再用那可怕的颜色了!然后将 contentsScale 因子设置位与设备的屏幕一样,这样可以确保所有的内容在 retina 显示屏中没有问题。

下面还有一个事情需要做,就是将 viewDidLoad 中的如下代码移除掉:

1
rangeSlider.backgroundColor = UIColor.redColor()

编译并运行程序,看到什么了呢?

什么东西都没有?这是正确的!

不要烦恼 - 我们只不过移除掉了在 layer 中花哨的测试颜色。控件依旧存在 - 只不过现在是白色的!

由于许多开发者希望能够通过编码对控件做各种配置,以使其外观能够效仿一些流行的程序,所以我们给 slider 添加一些属性,运行开发者对其外观做出一些定制。

打开 RangeSlider.swift,将下面的属性添加到已有属性下面:

1
2
3
4
5
var trackTintColor = UIColor(white: 0.9, alpha: 1.0)
var trackHighlightTintColor = UIColor(red: 0.0, green: 0.45, blue: 0.94, alpha: 1.0)
var thumbTintColor = UIColor.whiteColor()
var curvaceousness : CGFloat = 1.0

这些颜色属性的目的非常容易理解,但是 curvaceousness?这个属性在这里有点趣味 - 稍后你将发现其用途!

接下来,打来 RangeSliderTrackLayer.swift

这个 layer 用来渲染两个 thumb 滑动的轨迹。目前它继承自 CALayer,仅仅是绘制一个单一颜色。

为了绘制轨迹,需要实现方法 drawInContext:,并利用 Core Pgraphics APIs 来进行渲染。

注意:要想深入学习 Core Graphics,建议阅读 Core Graphics 101 教程

将下面这个方法添加到 RangeSliderTrackLayer 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
override func drawInContext(ctx: CGContext!) {
if let slider = rangeSlider {
// Clip
let cornerRadius = bounds.height * slider.curvaceousness / 2.0
let path = UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius)
CGContextAddPath(ctx, path.CGPath)
// Fill the track
CGContextSetFillColorWithColor(ctx, slider.trackTintColor.CGColor)
CGContextAddPath(ctx, path.CGPath)
CGContextFillPath(ctx)
// Fill the highlighted range
CGContextSetFillColorWithColor(ctx, slider.trackHighlightTintColor.CGColor)
let lowerValuePosition = CGFloat(slider.positionForValue(slider.lowerValue))
let upperValuePosition = CGFloat(slider.positionForValue(slider.upperValue))
let rect = CGRect(x: lowerValuePosition, y: 0.0, width: upperValuePosition - lowerValuePosition, height: bounds.height)
CGContextFillRect(ctx, rect)
}
}

一旦 track 形状确定,控件的背景色就会被填充,另外高亮范围也会被填充。

编译并运行程序,会看到新的 track layer 被完美的渲染出来!如下图所示:

给暴露出来的属性设置不同的值,观察一下它们是如何反应到控件渲染中的。

如果你对 curvaceousness 做什么的还存在疑惑,那么试着修改一下它看看!

接下来我们使用相同的方法来绘制 thumb layer。

打开 RangeSliderThumbLayer.swift,然后将下面的方法添加到属性声明的下方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
override func drawInContext(ctx: CGContext!) {
if let slider = rangeSlider {
let thumbFrame = bounds.rectByInsetting(dx: 2.0, dy: 2.0)
let cornerRadius = thumbFrame.height * slider.curvaceousness / 2.0
let thumbPath = UIBezierPath(roundedRect: thumbFrame, cornerRadius: cornerRadius)
// Fill - with a subtle shadow
let shadowColor = UIColor.grayColor()
CGContextSetShadowWithColor(ctx, CGSize(width: 0.0, height: 1.0), 1.0, shadowColor.CGColor)
CGContextSetFillColorWithColor(ctx, slider.thumbTintColor.CGColor)
CGContextAddPath(ctx, thumbPath.CGPath)
CGContextFillPath(ctx)
// Outline
CGContextSetStrokeColorWithColor(ctx, shadowColor.CGColor)
CGContextSetLineWidth(ctx, 0.5)
CGContextAddPath(ctx, thumbPath.CGPath)
CGContextStrokePath(ctx)
if highlighted {
CGContextSetFillColorWithColor(ctx, UIColor(white: 0.0, alpha: 0.1).CGColor)
CGContextAddPath(ctx, thumbPath.CGPath)
CGContextFillPath(ctx)
}
}
}

一旦定义好了 thumb 的形状路径,就会将其形状填充好。注意绘制微弱的阴影看起来的效果就是 thumb 上方的轨迹。接下来是绘制边框。最后,如果 thumb 是高亮的 - 也就是被移动状态 - 那么就绘制微弱的灰色阴影效果。

在运行之前,还有最后一件事情要做。按照下面的代码对 highlighted 属性的定义做出修改:

1
2
3
4
5
var highlighted: Bool = false {
didSet {
setNeedsDisplay()
}
}

这里,定义了一个属性观察者,这样当每次 highlighted 属性修改时,相应的 layer 都会得到重绘。这会使得触摸事件发生时,填充色发生轻微的变动。

再次编译并运行程序,这下看起来会非常的有形状,如下图所示:

不难发现,用 Core Graphics 来绘制控件是非常值得做的。使用 Core Graphics 可以做出比通过图片渲染方法更通用的控件。

处理控件属性的改变

那么到现在,还有什么事情要做呢?控件现在看起来已经非常的华丽了,它的外观是通用的,并且也支持 target-action 通知。

貌似已经做完了?

思考一下,如果当控件熏染之后,如果通过代码对 slider 的属性做了修改,会发生什么?例如,你希望修改一下 slider 的默认值,或者修改一下 track highlight,表示出一个有效范围。

目前,还没有任何代码来观察属性的设置情况。我们需要将其添加到控件中。我们需要实现属性观察者,来更新控件的 frame 或者重绘控件。打开 RangeSlider.swift,按照下面的代码对属性的声明作出修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
var minimumValue: Double = 0.0 {
didSet {
updateLayerFrames()
}
}
var maximumValue: Double = 1.0 {
didSet {
updateLayerFrames()
}
}
var lowerValue: Double = 0.2 {
didSet {
updateLayerFrames()
}
}
var upperValue: Double = 0.8 {
didSet {
updateLayerFrames()
}
}
var trackTintColor: UIColor = UIColor(white: 0.9, alpha: 1.0) {
didSet {
trackLayer.setNeedsDisplay()
}
}
var trackHighlightTintColor: UIColor = UIColor(red: 0.0, green: 0.45, blue: 0.94, alpha: 1.0) {
didSet {
trackLayer.setNeedsDisplay()
}
}
var thumbTintColor: UIColor = UIColor.whiteColor() {
didSet {
lowerThumbLayer.setNeedsDisplay()
upperThumbLayer.setNeedsDisplay()
}
}
var curvaceousness: CGFloat = 1.0 {
didSet {
trackLayer.setNeedsDisplay()
lowerThumbLayer.setNeedsDisplay()
upperThumbLayer.setNeedsDisplay()
}
}

一般情况,我们需要根据依赖的属性,调用 setNeedsDisplay 方法将对于的 layer 进行重新处理。setLayerFrames 方法会对控件的布局作出调整。

现在,找到 updateLayerFrames,然后将下面的代码添加到该方法的顶部:

1
2
CATransaction.begin()
CATransaction.setDisableActions(true)

并将下面的代码添加到方法的尾部:

1
CATransaction.commit()

上面的代码将整个 frame 的更新封装到一个事物处理中,这样可以让界面重绘变得流畅。同样还明确的把 layer 中的动画禁用掉,跟之前一样,这样 layer frame 的更新会变得即时。

由于现在每当 upper 和 lower 值发生变动时, frame 会自动更新了,所以,找到 continueTrackingWithTouch 方法,并将下面的代码删除掉:

1
2
3
4
5
6
7
// 3. Update the UI
CATransaction.begin()
CATransaction.setDisableActions(true)
updateLayerFrames()
CATransaction.commit()

上面的这些代码就能够确保属性变化时,能够反应到 slider 控件中。

为了确保代码无误,我们需要写点测试 case 进行测试。

打开 ViewController.swift,并将下面代码添加到 viewDidLoad: 尾部:

1
2
3
4
5
let time = dispatch_time(DISPATCH_TIME_NOW, Int64(NSEC_PER_SEC))
dispatch_after(time, dispatch_get_main_queue()) {
self.rangeSlider.trackHighlightTintColor = UIColor.redColor()
self.rangeSlider.curvaceousness = 0.0
}

上面的代码会在暂停 1 秒钟之后,对控件的一些属性做出更新。其中将 track highlight 的颜色修改为红色,并修改了 slider 和 thumb 的形状。

编译并运行程序,一秒钟之后,你看到 slider 由:

变为:

很容易不是吗?

上面刚刚添加到 view controller 中的代码,演示了一个非常有趣,而又经常被忽略的内容 - 对开发的自定义控件做充分的测试。当你在开发一个自定义控件时,你需要负责对所有的属性和外观做出验证。这里有一个好的方法就是创建不同的按钮和滑块 (它们连接到控件的不同属性) 对控件做出测试。这样,你就可以实时修改控件的属性,并实时观察到它们的结果。

何去何从?

现在我们的 range slider 控件已经完成开发,并可以在程序中使用了!你可以在这里下载到完整的工程。

不过,创建通用性自定义控件的一个关键好处就是你可以将其用于不同的工程 - 并且分享给别的开发者使用。

准备好了吗?

实际上还没有。在分享自定义控件之前,还有一些事情需要考虑:

文档 - 你可能认为代码写得非常的完美,具有自我陈述的能力,不在需要额外的文档了,不过别的开发者是不同意的。最佳实践就是提供 public API 的相关文档,至少要提供所有分享的 public 代码,也就是说对所有的public 类和属性进行文档化。

例如,文档中需要说明 RangeSlider 是什么的 - slider 是这样的一个东西:定义了 4 个属性,包括minimumValue, maximumValue, lowerValue, 和 upperValue - 它是做什么的 - 允许用户通过在界面中定义数值的范围。

鲁棒性 - 如果将 upperValue 设置为比 maximumValue 还要大,会发生什么?当然,你是肯定不会这样做的 - 这是愚蠢的一件事情,不是吗?但是你无法保证所有的人都不这么做!你需要确保控件的状态总是有效 - 尽管一些愚蠢的码农会尝试这样做。

API 设计 - 前面说的鲁棒性涉及到一个更广泛的主题 - API 设计。创建一个具有灵活性、直观性和鲁棒性的 API 有利于控件被广泛的使用和流行。

API 设计是很深的一个主题,超出了本文的介绍范围,如果你感兴趣,建议阅读 Matt Gemmel 关于 API 设计的 25 条规则

网络中有许多地方可以分享你的控件。下面是建议的一些地方:

  • GitHub - GitHub 已经是分享开源项目首选的一个地方。在 GitHub 上有大量关于 iOS 的自定义控件。GitHub 的伟大之处在于它允许人们很容易的就能访问到你共享的代码,也能够很容易的通过 forking 你的共享的代码,与别的控件进行协作开发,另外还能很方便的对控件 faise issues。
  • CocoaPods - CocoaPods 是一个 iOS 和 OSX 工程的第三方库依赖管理工具, 允许开发者很容易的将你的控件添加到他们的工程中,所以你可以通过 CocoaPods 分享你的控件。
  • Cocoa Controls - 这个网站位商业和开源的控件提供一个目录。许多开源控件都会提供到 Cocoa Control 上面,然后 host 在 GitHub 中,这是促进你进行创作的伟大方式。

希望通过本文的学习,你已经能愉悦的创建 slider 控件了,可能你还希望构建自己的自定义控件。如果你做了,可以在本文的评论中分享一下 - 我们非常想看到你的创作!