本文翻译自:HOW TO BUILD A CUSTOM (AND “DESIGNABLE”) CONTROL IN SWIFT
大约两年前我写了一篇关于如何在iOS里创建自定义控件的教程。那篇教程在开发者社区中非常受欢迎,所以我决定用Swift语言来更新它,同时添加 designale/inspectable 属性的支持,以便直接通过Interface Builder来设计这个控件。
在我们开始教程之前,让我们看一下最终的效果:
开始吧!
不管是你自己设计用户界面还是有设计师帮你,UIKit提供的标准控件都无法完全符合你的需求。
例如,如果你想创建一个控件让用户选择0到360度的角度呢?
一个解决方案是创建一个圆形滑块,让用户拖动旋钮选择角度值。这可能是你在很多其它的界面上看到的一种情况,但却不同于UIKit提供的。
这就是为什么这是一个完美的例子,让我们使用UIKit预留的功能,创建一些特别的东西。
子类化UIControl
UIControl 是UIView 的子类,同时也是所有UIKit 控件的父类(例如UIButton,UISlider,UISwitch等等)。 UIControl实例的主要规则就是创建一个逻辑来分发动作到它们的目标上。主要是根据它的状态来绘制特定的用户界面(90%的时间都是在做这个事,例如高亮,选择,禁用)。
通过UIControl我们管理三个重要的任务:
* 绘制用户界面
* 跟踪用户交互
* 管理Target-Action模式
在圆形滑块中,我们将创建一个用户界面(滑块本身),用户可以与之交互(移动旋钮)。用户的决定将会被转换成动作传给控件的目标(控件转换这个旋钮的位置到一个0到360度之间的值,同时应用target/action模式)。
好了,我们准备打开Xcode。我提议你在文章的最后下载完整的项目工程,然后跟着这个教程来阅读我的代码。
我们将按照上面说的三步来实现。
这些步骤完全是模块化的,意味着如果你对我绘制这个组件的方法不感兴趣,你可以直接跳到步骤2和步骤3。
打开 BWCircluarSlider.swift文件,然后跟着下一章。
1) 绘制用户界面
我喜欢Core Graphics,我想创建一些你可以继续自定义的东西。我唯一想用UIKit绘制的部分就是那个用来展示滑动值的文本框。
注意:Core Graphics的一些知识还是必须的,但是你应该还是可以阅读这个代码,我将尽我可能地从头解释。
让我们分析一下这个控件不同的部分,以便更好地查看它是如何绘制的。
首先,一个黑色圆环定义了滑块的背景。
活动的区域会用蓝色到紫色的渐变来填充。
那个旋钮是让用户拖动来选择值的
最后,一个文本框来表明当前选择的角度。下一节中它将会接受用户的从键盘输入的值。
为了绘制这个界面,我门主要使用 drawRect 函数,函数里面的第一步就是获取当前的图形上下文。
let ctx = UIGraphicsGetCurrentContext()
绘制背景
背景是由一个360度的圆弧定义的。这可以通过CGContextAddArc 添加右路径到上下文中,然后添加笔画来实现。
下面的代码是用来实现这个简单的任务的:
//Build the circle
CGContextAddArc(ctx,
CGFloat(self.frame.size.width / 2.0),
CGFloat(self.frame.size.height / 2.0),
radius,
0,
CGFloat(M_PI * 2),
0)
// Set fill/stroke color
UIColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0).set()
// Set line info
CGContextSetLineWidth(ctx, 72)
CGContextSetLineCap(ctx, kCGLineCapButt)
// Draw it!
CGContextDrawPath(ctx, kCGPathStroke)
函数CGContextArc 以圆弧中心坐标和半径开始,还需要用弧度表示的开始和结束的角度(你可以在文件BWCircularSlider.swift开头的地方找到一些数学帮助方法)以及最后一个参数来表明绘制方向,0表示逆时针。
其它的代码行仅仅是设置,像颜色,行宽。最后我们使用CGContextDrawPath函数绘制这个路径。
绘制活动区域
这部分有些技巧。我们绘制一个线性渐变遮罩的图片。让我们看看是如何做的。
这个遮罩图片像有一个洞穿过去了,所以我们只能看到原始渐变矩形的一部分。
一个有趣的方面是,这次这个圆弧绘制了一个阴影,创造了一种模糊效果的遮罩。
创建这个遮罩图片:
UIGraphicsBeginImageContext(CGSizeMake(self.bounds.size.width,self.bounds.size.height));
let imageCtx = UIGraphicsGetCurrentContext()
CGContextAddArc(imageCtx,
CGFloat(self.frame.size.width/2) ,
CGFloat(self.frame.size.height/2),
radius,
0,
CGFloat(DegreesToRadians(Double(angle))) ,
0);
UIColor.redColor().set()
//Use shadow to create the Blur effect
CGContextSetShadowWithColor(imageCtx, CGSizeMake(0, 0), CGFloat(self.angle/15), UIColor.blackColor().CGColor);
//define the path
CGContextSetLineWidth(imageCtx, Config.TB_LINE_WIDTH)
CGContextDrawPath(imageCtx, kCGPathStroke)
//save the context content into the image mask
var mask:CGImageRef = CGBitmapContextCreateImage(UIGraphicsGetCurrentContext());
UIGraphicsEndImageContext();
首先我们创建一个图片上下文,然后激活阴影。函数CGContextSetShadowWithColor帮我们选择了:
* 图形上下文
* 偏移值(我们不需要)
* 模糊值(我们会参数化这个值,在用户进行交互过程中使用当前角度除以15获得一个在模糊区域上的简单动画效果)
* 颜色
我们还是绘制一个圆弧,这次是根据当前的角度。
例如,如果实例的角度变量等于360度,则绘制一整个圆,如果是90度,我们只是绘制一部分。最后我们使用CGBitmapContextCreateImage函数来从当前的绘制获取一个图片资源。这个图片将是我们的遮罩。
剪切上下文:
现在我们有了这个遮罩来定义这个可以看到渐变的“洞”。
我们使用CGContextClipToMask 函数来剪切上下文,然后传递我们刚刚创建的遮罩。
CGContextClipToMask(ctx, self.bounds, mask);
最后,我们绘制这个渐变:
// Split colors in components (rgba)
let startColorComps:UnsafePointer<CGFloat> = CGColorGetComponents(startColor.CGColor);
let endColorComps:UnsafePointer<CGFloat> = CGColorGetComponents(endColor.CGColor);
let components : [CGFloat] = [
startColorComps[0], startColorComps[1], startColorComps[2], 1.0, // Start color
endColorComps[0], endColorComps[1], endColorComps[2], 1.0 // End color
]
// Setup the gradient
let baseSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradientCreateWithColorComponents(baseSpace, components, nil, 2)
// Gradient direction
let startPoint = CGPointMake(CGRectGetMidX(rect), CGRectGetMinY(rect))
let endPoint = CGPointMake(CGRectGetMidX(rect), CGRectGetMaxY(rect))
// Draw the gradient
CGContextDrawLinearGradient(ctx, gradient, startPoint, endPoint, 0);
CGContextRestoreGState(ctx);
绘制这个渐变需要很多的操作,但基本上分为4个部分(用注释分开了):
* 定义颜色
* 定义渐变方向
* 选择一种颜色空间
* 创建和绘制渐变
感谢这个遮罩,仅仅这个矩形的一部分会可见。
绘制旋钮
现在我们想在当前的角度下的右边绘制这个旋钮。这一步非常简单(我们只是绘制一个白色圆圈),但是需要一些计算来获得这个旋钮的位置。
我们必须使用三角函数来把一个标量值转换成一个CGPoint。不用害怕,这只是使用了正弦和余弦函数。
func pointFromAngle(angleInt:Int)->CGPoint{
//Circle center
let centerPoint = CGPointMake(self.frame.size.width/2.0 - Config.TB_LINE_WIDTH/2.0, self.frame.size.height/2.0 - Config.TB_LINE_WIDTH/2.0);
//The point position on the circumference
var result:CGPoint = CGPointZero
let y = round(Double(radius) * sin(DegreesToRadians(Double(-angleInt)))) + Double(centerPoint.y)
let x = round(Double(radius) * cos(DegreesToRadians(Double(-angleInt)))) + Double(centerPoint.x)
result.y = CGFloat(y)
result.x = CGFloat(x)
return result;
}
给定一个角度,找到圆周上的点,我们同时需要圆心和它的半斤。
使用正弦函数sin可以获取Y轴上的坐标,使用余弦函数可以获取X轴上的坐标。
记住,正弦函数和余弦函数返回的值都是假定半径为1的。我们只需要乘以我们的半径值然后移到相对圆心的位置即可。
我希望下面的函数会帮助你更好地理解:
point.y = center.y + (radius * sin(angle));
point.x = center.x + (radius * cos(angle));
现在我们知道如何获取旋钮的位置了,可以用下面的函数绘制:
func drawTheHandle(ctx:CGContextRef){
CGContextSaveGState(ctx);
//I Love shadows
CGContextSetShadowWithColor(ctx, CGSizeMake(0, 0), 3, UIColor.blackColor().CGColor);
//Get the handle position
var handleCenter = pointFromAngle(angle)
//Draw It!
UIColor(white:1.0, alpha:0.7).set();
CGContextFillEllipseInRect(ctx, CGRectMake(handleCenter.x, handleCenter.y, Config.TB_LINE_WIDTH, Config.TB_LINE_WIDTH));
CGContextRestoreGState(ctx);
}
上面的步骤是:
* 保存当前的上下文(当你在一个分开的函数中进行绘制时,保存上下文是一种很好的实践)。
* 为这个旋钮设置一些阴影。
* 定义旋钮的颜色,使用函数CGContextFillEllipseInRect绘制。
我们可以在函数 drawRect函数的最后调用这个函数:
drawTheHandle(ctx)
我们已经完成了绘制的部分了。
2) 跟踪用户的交互
子类化UIControl,我们可以重写3个特别的方法来提供自定义的跟踪行为。
开始跟踪
当在一个控件的范围内发生了一个触摸事件时,消息 beginTrackingWithTouch 会首先发送到这个控件。
让我们看看如何重写它吧:
override func beginTrackingWithTouch(touch: UITouch, withEvent event: UIEvent) -> Bool {
super.beginTrackingWithTouch(touch, withEvent: event)
return true
}
它返回一个Bool 值来决定这个控件是否需要在这个触摸移动时响应。对于我们的情况,需要跟踪触摸的移动,所以我们返回true。这个方法有两个参数,一个是触摸对象,一个是事件。
继续跟踪
在上一个方法中我们已经指定了我们想跟踪一个持续的事件,所以一个特定的方法: continueTrackingWithTouch 将会在用户执行移动时调用:
func continueTrackingWithTouch(touch: UITouch, withEvent event: UIEvent) -> Bool
这个方法返回一个Bool值来指定一个触摸跟踪是否应该持续。
我们可以使用这个函数根据触摸的位置来过滤用户的动作。例如,我们可以选择让触摸的位置在圆圈内时才激活这个控件。但在这里我们的情况是希望处理任何的触摸位置。
在这个教程里,这个方法负责改变旋钮的位置(我们将会在下面看到在这个方法里发送动作到目标)。
我们以下面的代码重写:
override func continueTrackingWithTouch(touch: UITouch, withEvent event: UIEvent) -> Bool {
super.continueTrackingWithTouch(touch, withEvent: event)
let lastPoint = touch.locationInView(self)
self.moveHandle(lastPoint)
self.sendActionsForControlEvents(UIControlEvents.ValueChanged)
return true
}
首先我们使用locationInView 来获取触摸的位置。然后我们把它传递给moveHandle函数,这个函数会把这个位置转换成一个有效的旋钮的位置。
什么事有效的位置呢?
因为这个旋钮应该仅仅在圆圈的区域内移动。但是我们不希望强制用户必须在圆圈内移动手指,因为位置太小了,不好操作,体验会很不好。所以我们将接受任何一个触摸位置,然后转换它到滑块圆圈内。
moveHandle函数做了这个工作,另外,在这个函数里,我们还把位置转换为角度值。
func moveHandle(lastPoint:CGPoint){
//Get the center
let centerPoint:CGPoint = CGPointMake(self.frame.size.width/2, self.frame.size.height/2);
//Calculate the direction from a center point and a arbitrary position.
let currentAngle:Double = AngleFromNorth(centerPoint, p2: lastPoint, flipped: false);
let angleInt = Int(floor(currentAngle))
//Store the new angle
angle = Int(360 - angleInt)
//Update the textfield
textField!.text = "(angle)"
//Redraw
setNeedsDisplay()
}
大部分的工作都是由AngleFromNorth来完成的。给定两个点,它会返回角度值:
func AngleFromNorth(p1:CGPoint , p2:CGPoint , flipped:Bool) -> Double {
var v:CGPoint = CGPointMake(p2.x - p1.x, p2.y - p1.y)
let vmag:CGFloat = Square(Square(v.x) + Square(v.y))
var result:Double = 0.0
v.x /= vmag;
v.y /= vmag;
let radians = Double(atan2(v.y,v.x))
result = RadiansToDegrees(radians)
return (result >= 0 ? result : result + 360.0);
}
注意,不是我写的这个angleFromNorth方法,我是直接从Apple提供的一个clockControl 例子中拿来的。
现在我们有了一个表示角度的值,我们保存到angle 属性中,然后更新文本框的值。
setNeedDisplay 函数确保 drawRect 方法尽快被调用。
结束跟踪
下面的方法是当跟踪结束时会触发:
override func endTrackingWithTouch(touch: UITouch, withEvent event: UIEvent) {
super.endTrackingWithTouch(touch, withEvent: event)
}
在这个例子中,我们不需要重写这个方法,但是如果我们想在用户结束与这个控件交互时做些操作,那么这个方法将会很有用。
3) Target-Action 模式
到这里,这个圆形滑块就可以工作了。你可以拖动这个旋钮,会看到文本框中的值变化。现在
给控件事件发送动作
如果我们想与UIControl的行为一致,我们必须在控件的值发生变化时收到通知。为了实现这个,我们可以使用sendActionsForControlEvents函数来指定事件的类型,这里是UIControlEventValueChanged。
关于事件的类型还有很多可能的值(在Xcode中,cmd + 鼠标点击UIControlEventValueChanged会看到这个列表值)。例如,如果你的控件是一个UITextField的子类,你可能对UIControlEventEdigitingDidBegin事件感兴趣,或者,如果你想在触摸抬起时收到通知,可以使用UIControlTouchUpInside。
如果你看回第二小节,你将看到我们在函数continueTrackingWithTouch返回前调用了sendActionsForControlEvents。
self.sendActionsForControlEvents(UIControlEvents.ValueChanged)
感谢这个方法,当用户移动旋钮时,每一个注册的对象都会收到关于这个变化的通知。
如何使用这个控件
现在我们可以在我们的应用中使用这个自定义的控件了。
因为这个控件是UIControl的子类,它不能直接在 Interface Builder 使用。但是别担心,我们可以使用一个UIView 作为桥梁,然后以这个视图的子视图来访问它。
打开 BWCircularSliderView.swift 文件来跟着我一起做。查看如下的awakeFromNib 方法:
override func awakeFromNib() {
super.awakeFromNib()
// Build the slider
let slider:BWCircularSlider = BWCircularSlider(startColor:self.startColor, endColor:self.endColor, frame: self.bounds)
// Attach an Action and a Target to the slider
slider.addTarget(self, action: "valueChanged:", forControlEvents: UIControlEvents.ValueChanged)
// Add the slider as subview of this view
self.addSubview(slider)
}
我们使用 init:startColor:endColor:frame 方法来实例化一个圆形滑块,顺便指定了颜色和大小。
这是一个自定义的初始化方法,用来保存渐变颜色和一个与桥梁视图大小一样的frame。意味着这个控件将继承这个视图的大小(你同样可以用自动布局来实现)。
现在我们使用 addTarget:action:forControlEvent: 来定义我们想如何与这个控件进行交互。
slider.addTarget(self, action: "valueChanged:", forControlEvents: UIControlEvents.ValueChanged)
然后我们可以实现valueChanged方法来在控件值发生变化时做点什么:
func valueChanged(slider:BWCircularSlider){
// Do something with the new value...
println("Value changed (slider.angle)")
}
现在检查 重写的方法 willMoveToSuperview
#if TARGET_INTERFACE_BUILDER
override func willMoveToSuperview(newSuperview: UIView?) {
let slider:BWCircularSlider = BWCircularSlider(startColor:self.startColor, endColor:self.endColor, frame: self.bounds)
self.addSubview(slider)
}
#else
这段代码,加上@IBInspectable 和 @IBDesignable 关键字可以实现在 Interface Builder中直接预览视图效果(可以查看这个文章来了解更多关于 IBDesignable的知识)。
(这个 TARGET_INTERFACE_BUILDER 表示我们想要在InterfaceBuilder 中才执行这段代码,而当应用运行时不会执行)。
现在我们只需要在Storyboard中添加一个BWCircularSliderView实例,直接在Interface Builder中改变 startColor 和 endColor属性值,然后这个控件的预览效果会立刻显示在屏幕上。
总结
通过我在这篇教程一开始说的步骤,你可以创建任何你想要的控件。
当然可能还有很多其它的方式来创建类似的效果,但我还是按照苹果的建议,100%展示给你官方文档中建议的方法。