前言
前段时间在Dribbble上发现了一个 Rating控件 的演示动画,控件以Emoji表情为基础,结合了上下滑动手势,正好最近正在深入学习iOS动画、绘图相关的知识,就尝试着用 UIBezierPath
实现了出来。本文就是TTGEmojiRate的实现过程。
Github: https://github.com/zekunyan/TTGEmojiRate
分析
先看看原本的效果: Rating Version A - Hoang Nguyen
可以看出来,主要的特点如下:
- 可以上下拖动,改变Emoji表情嘴的弧度。
- 拖动的时候Rate的值也会随之变化,从0到5,并且跟表情的“喜怒”相对应。
- 颜色也会变化,从绿色到蓝色再到红色,也对应表情的“喜怒”。
实际实现的时候,增加了眼睛元素,并且增强了自定义,如颜色的变化范围、线条的粗细等都可以设定,基本的思路还是不变的。
实现
思路
开始写代码之前,先理理思路。拖动的时候,直接影响的应该是Rate值,然后在Rate值改变的时候刷新整个控件,刷新的时候重绘。重绘的时候,嘴、眼睛的弧度,颜色的值都要根据Rate值重新计算,如下图:
拖动改变Rate值
这个还是很容易实现的,直接重写 UIView
的touch相关的三个方法,在里面记录拖动在Y轴上的变化值,然后映射到Rate值上就可以了。
先声明一个CGPoint属性,用来保存手指按下时的点位置:
private var touchPoint: CGPoint? = nil
在手指移动的时候,在 touchesMoved
方法里面计算当前点跟上一次触摸点的Y轴上的 差值 ,然后映射到Rate值上。
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
脸
脸是最简单的,就是一个圆,直接用一个方法就可以:
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:)
,如下图:
直观上来讲,嘴、眼睛的绘制跟 addQuadCurveToPoint
方法绘制的效果基本一致,但是这样的效果没法调整,因为只能控制唯一的一个控制点,所以还是要用 addCurveToPoint
方法,对称的绘制两条曲线,拼接起来,如下图:
这样的话,就可以通过调整两个控制点,来控制嘴、眼睛的弯曲宽度、形状。
以绘制嘴为例:
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代表亮度。- 百度百科
对应到 UIColor
类,就是下面两个方法:
// 创建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
实现的时候,为了增加可定制性,控件颜色的变化范围是可以设置的,用以下属性保存:
public var rateColorRange: (from: UIColor, to: UIColor)
刷新时,就可以根据当前的Rate值,重新计算颜色的HSB和alpha值:
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值做线性增减。
-
xxxFrom
、xxxDelta
分别指HSB和alpha的起始值与变化范围,在设置rateColorRange
时计算保存下来。
这样,颜色就能做到跟Rate值做连续的线性变化。
善于使用didSet
实现控件的时候,对外暴露了很多属性,如线的宽度 rateLineWidth
、嘴的宽度 rateMouthWidth
等。为了对这些属性做校验,并且在设置后刷新控件,就要用到 didSet
。
didSet
在Swift里面,跟类的属性是一一绑定的,在对属性赋值后会被调用。
控件的大部分属性都做了校验、刷新,如下:
/// 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可以预览控件:
@IBDesignable
public class EmojiRateView: UIView {
// ...
}
在属性前面加上 @IBInspectable
,就可以在IB里面编辑属性,实时预览:
@IBInspectable public var rateLineWidth: CGFloat = 14 {
// ...
}
详细的使用可以参考NSHipster上的文章: IBInspectable / IBDesignable
最后,在IB里面就是下面这样:
By the way =。=
属性名字太长,在IB里面显示不完整,咋办。。。
回调
拖动改变Rate值的时候,肯定要有回调,如下定义:
public var rateValueChangeCallback: ((newRateValue: Float) -> Void)? = nil
在 rateValue
的 didSet
里面回调:
@IBInspectable public var rateValue: Float = 2.5 {
didSet {
// ...
// 回调
self.rateValueChangeCallback?(newRateValue: rateValue)
}
}
总结
看似简单的一个Rating控件,从构思到实现,再到完善,一点一点朝着完美去做,收获不少~
最后,Dribbble是个好地方,贝塞尔曲线好强大,XCode 7.1写Swift还是有点卡=。=
以上。