Swift开源项目: TTGEmojiRate的实现

前言

前段时间在Dribbble上发现了一个Rating控件的演示动画,控件以Emoji表情为基础,结合了上下滑动手势,正好最近正在深入学习iOS动画、绘图相关的知识,就尝试着用UIBezierPath实现了出来。本文就是TTGEmojiRate的实现过程。

Github: https://github.com/zekunyan/TTGEmojiRate

TTGEmojiRate Example

分析

先看看原本的效果:Rating Version A - Hoang Nguyen

Rating Version A - Hoang Nguyen

可以看出来,主要的特点如下:

  • 可以上下拖动,改变Emoji表情嘴的弧度。
  • 拖动的时候Rate的值也会随之变化,从0到5,并且跟表情的“喜怒”相对应。
  • 颜色也会变化,从绿色到蓝色再到红色,也对应表情的“喜怒”。

实际实现的时候,增加了眼睛元素,并且增强了自定义,如颜色的变化范围、线条的粗细等都可以设定,基本的思路还是不变的。

实现

思路

开始写代码之前,先理理思路。

拖动的时候,直接影响的应该是Rate值,然后在Rate值改变的时候刷新整个控件,刷新的时候重绘。重绘的时候,嘴、眼睛的弧度,颜色的值都要根据Rate值重新计算,如下图:

Image

拖动改变Rate值

这个还是很容易实现的,直接重写UIView的touch相关的三个方法,在里面记录拖动在Y轴上的变化值,然后映射到Rate值上就可以了。

先声明一个CGPoint属性,用来保存手指按下时的点位置:

1
private var touchPoint: CGPoint? = nil

在手指移动的时候,在touchesMoved方法里面计算当前点跟上一次触摸点的Y轴上的差值,然后映射到Rate值上。

1
2
3
4
5
6
7
8
public override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent?) {
// 获取当前触摸点
let currentPoint = touches.first?.locationInView(self)
// 改变Rate值
rateValue = rateValue + Float((currentPoint!.y - touchPoint!.y) / CGRectGetHeight(self.bounds) * rateDragSensitivity)
// 保存当前触摸点
touchPoint = currentPoint
}

注意:

  • 计算Y轴差值的时候,除以了当前控件的高度,这是为了保证Rate值按比例增减。
  • 增加了一个rateDragSensitivity属性,用来调节改变Rate值的“灵敏度”

UIBezierPath - 贝塞尔曲线

控件的主要内容都是用UIBezierPath绘制出来的。网上关于UIBezierPath的讲解很多,在这里就不详细说了。

简单来说,UIBezierPath用来绘制矢量路径,是一种参数曲线,在使用的时候,只需要先设定好锚点、控制点,系统就可以根据贝塞尔曲线的算法,绘制出对应的线,并且保证锚点和对应的控制点的连线与曲线相切。

这里有一个演示绘制贝塞尔曲线过程的网站:Bézier curve

脸是最简单的,就是一个圆,直接用一个方法就可以:

1
2
3
4
5
6
private func drawFaceWithRect(rect: CGRect) {
let facePath = UIBezierPath(ovalInRect: rect)
rateColor.setStroke()
facePath.lineWidth = rateLineWidth // 线粗细
facePath.stroke()
}

实际实现的时候可以加上Margin,防止线画到View的边界之外。

嘴、眼睛

先看看UIBezierPath提供的可以用来绘制曲线的方法:
addCurveToPoint(_:controlPoint1:controlPoint2:)addQuadCurveToPoint(_:controlPoint:),如下图:

UIBezierPath

直观上来讲,嘴、眼睛的绘制跟addQuadCurveToPoint方法绘制的效果基本一致,但是这样的效果没法调整,因为只能控制唯一的一个控制点,所以还是要用addCurveToPoint方法,对称的绘制两条曲线,拼接起来,如下图:

Curve拼接

这样的话,就可以通过调整两个控制点,来控制嘴、眼睛的弯曲宽度、形状。

以绘制嘴为例:

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
private func drawMouthWithRect(rect: CGRect) {
let width = CGRectGetWidth(rect)
let height = CGRectGetWidth(rect)
// 左端点
let leftPoint = CGPointMake(
width * (1 - rateMouthWidth) / 2,
height * (1 - rateMouthVerticalPosition))
// 右端点
let rightPoint = CGPointMake(
width - leftPoint.x,
leftPoint.y)
// 中间点 - Y值根据当前的Rate值计算,0.3为系数
let centerPoint = CGPointMake(
width / 2,
leftPoint.y + height * 0.3 * (CGFloat(rateValue) - 2.5) / 5)
// 控制点跟中间点在X轴上的距离
let halfLipWidth = width * rateMouthWidth * rateLipWidth / 2
// 创建贝塞尔曲线
let mouthPath = UIBezierPath()
// 移动到起始点
mouthPath.moveToPoint(leftPoint)
// 添加左半边曲线路径
mouthPath.addCurveToPoint(
centerPoint,
controlPoint1: leftPoint,
controlPoint2: CGPointMake(centerPoint.x - halfLipWidth, centerPoint.y))
// 添加右半边曲线路径
mouthPath.addCurveToPoint(
rightPoint,
controlPoint1: CGPointMake(centerPoint.x + halfLipWidth, centerPoint.y),
controlPoint2: rightPoint)
// 设定样式
mouthPath.lineCapStyle = CGLineCap.Round;
rateColor.setStroke()
mouthPath.lineWidth = rateLineWidth
mouthPath.stroke()
}

说明:

  • 所有的距离、坐标都是根据当前控件的大小计算出来的。
  • rateMouthWidth为嘴的宽度与整个控件宽度的比值,即相对值。
  • rateMouthVerticalPosition为嘴的左右两个端点的Y轴坐标值,也为相对值。
  • rateLipWidth为中心点的两个控制点的距离与嘴宽度的比值,也是相对值。

眼睛的绘制跟嘴原理一致,就不再说明。

颜色的渐变

Dribbble的演示中,控件的线条颜色也是会变化的,从红色到蓝色再到绿色,是连续变化的。这个时候用常见的RGB色彩模式是不好控制的,效果也不好。

所以这个时候要用HSB色彩模式

HSB 色彩模式是基于人眼的一种颜色模式。是普及型设计软件中常见的色彩模式,其中H代表色相;S代表饱和度;B代表亮度。- 百度百科

HSB色彩模式

对应到UIColor类,就是下面两个方法:

1
2
3
4
// 创建UIColor
init(hue hue: CGFloat, saturation saturation: CGFloat, brightness brightness: CGFloat, alpha alpha: CGFloat)
// 获取HSB值,注意参数
func getHue(_ hue: UnsafeMutablePointer<CGFloat>, saturation saturation: UnsafeMutablePointer<CGFloat>, brightness brightness: UnsafeMutablePointer<CGFloat>, alpha alpha: UnsafeMutablePointer<CGFloat>) -> Bool

实现的时候,为了增加可定制性,控件颜色的变化范围是可以设置的,用以下属性保存:

1
public var rateColorRange: (from: UIColor, to: UIColor)

刷新时,就可以根据当前的Rate值,重新计算颜色的HSB和alpha值:

1
2
3
4
5
6
7
8
let rate: CGFloat = CGFloat(rateValue / 5) // Rate值归一化
self.rateColor = UIColor.init(
hue: hueFrom + hueDelta * rate, // 色相
saturation: saturationFrom + saturationDelta * rate, // 饱和度
brightness: brightnessFrom + brightnessDelta * rate, // 亮度
alpha: alphaFrom + alphaDelta * rate // 透明度
)

说明:

  • 所有的颜色参数都是根据Rate值做线性增减。
  • xxxFromxxxDelta分别指HSB和alpha的起始值与变化范围,在设置rateColorRange时计算保存下来。

这样,颜色就能做到跟Rate值做连续的线性变化。

善于使用didSet

实现控件的时候,对外暴露了很多属性,如线的宽度rateLineWidth、嘴的宽度rateMouthWidth等。为了对这些属性做校验,并且在设置后刷新控件,就要用到didSet

didSet在Swift里面,跟类的属性是一一绑定的,在对属性赋值后会被调用。

控件的大部分属性都做了校验、刷新,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/// Mouth width. From 0.2 to 0.7.
@IBInspectable public var rateMouthWidth: CGFloat = 0.6 {
didSet {
// 判断上限
if rateMouthWidth > 0.7 {
rateMouthWidth = 0.7
}
// 判断下限
if rateMouthWidth < 0.2 {
rateMouthWidth = 0.2
}
// 刷新、重绘
self.setNeedsDisplay()
}
}

@IBDesignable、@IBInspectable

为了能在XIB、StoryBoard里面使用、编辑控件,就要用到@IBDesignable@IBInspectable这两个关键字。

在类的前面加上@IBDesignable关键字,使IB可以预览控件:

1
2
3
4
@IBDesignable
public class EmojiRateView: UIView {
// ...
}

在属性前面加上@IBInspectable,就可以在IB里面编辑属性,实时预览:

1
2
3
@IBInspectable public var rateLineWidth: CGFloat = 14 {
// ...
}

详细的使用可以参考NSHipster上的文章:IBInspectable / IBDesignable

最后,在IB里面就是下面这样:
IB example

By the way =。=
属性名字太长,在IB里面显示不完整,咋办。。。

回调

拖动改变Rate值的时候,肯定要有回调,如下定义:

1
public var rateValueChangeCallback: ((newRateValue: Float) -> Void)? = nil

rateValuedidSet里面回调:

1
2
3
4
5
6
7
@IBInspectable public var rateValue: Float = 2.5 {
didSet {
// ...
// 回调
self.rateValueChangeCallback?(newRateValue: rateValue)
}
}

总结

看似简单的一个Rating控件,从构思到实现,再到完善,一点一点朝着完美去做,收获不少~

最后,Dribbble是个好地方,贝塞尔曲线好强大,XCode 7.1写Swift还是有点卡=。=

以上。

参考


当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器