用Swift创建一个自定义,可调整的控件

本文翻译自:HOW TO BUILD A CUSTOM (AND “DESIGNABLE”) CONTROL IN SWIFT

大约两年前我写了一篇关于如何在iOS里创建自定义控件的教程。那篇教程在开发者社区中非常受欢迎,所以我决定用Swift语言来更新它,同时添加 designale/inspectable 属性的支持,以便直接通过Interface Builder来设计这个控件。

在我们开始教程之前,让我们看一下最终的效果:

用Swift创建一个自定义,可调整的控件

开始吧!


不管是你自己设计用户界面还是有设计师帮你,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的一些知识还是必须的,但是你应该还是可以阅读这个代码,我将尽我可能地从头解释。

让我们分析一下这个控件不同的部分,以便更好地查看它是如何绘制的。

首先,一个黑色圆环定义了滑块的背景。

用Swift创建一个自定义,可调整的控件

活动的区域会用蓝色到紫色的渐变来填充。

用Swift创建一个自定义,可调整的控件

那个旋钮是让用户拖动来选择值的

用Swift创建一个自定义,可调整的控件

最后,一个文本框来表明当前选择的角度。下一节中它将会接受用户的从键盘输入的值。

用Swift创建一个自定义,可调整的控件

为了绘制这个界面,我门主要使用 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函数绘制这个路径。

绘制活动区域

这部分有些技巧。我们绘制一个线性渐变遮罩的图片。让我们看看是如何做的。

用Swift创建一个自定义,可调整的控件

这个遮罩图片像有一个洞穿过去了,所以我们只能看到原始渐变矩形的一部分。

一个有趣的方面是,这次这个圆弧绘制了一个阴影,创造了一种模糊效果的遮罩。

创建这个遮罩图片:

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%展示给你官方文档中建议的方法。

下载代码