一种描述图表的函数式方式,并利用 Core Graphics 来绘制它们。通过对 Core Graphic 进行一层函数式的封装,可以得到一个更简单且易于组合的API。
let bound = CGRect(x: 0.0, y: 0.0, width: 80.0, height: 40.0)
let renderner = UIGraphicsImageRenderer(bounds: bound)
let image = renderner.image { (context) in
UIColor.red.setFill()
context.fill(CGRect(x: 0.0, y: 10.0, width: 20.0, height: 20.0))
UIColor.green.setFill()
context.fill(CGRect(x: 20.0, y: 0.0, width: 40.0, height: 40.0))
UIColor.blue.setFill()
context.cgContext.fillEllipse(in: CGRect(x: 60.0, y: 10.0, width: 20.0, height: 20.0))
}
上面的代码虽然短小精悍,但却难以维护。比如如何添加一个额外的圆进去呢?可能得先添加一段绘制圆的代码,然后再更新位于该圆形右边其它图形的代码来移动它们。
于是打算构建一个库,来表达想画的是什么。进而通过使用运算符将图形排列,组合为一个图表。修改这个图表将非常的简单,不用再去考虑计算边框和移动其他部分的问题。
在这个库中,将绘制三种类型的元素:椭圆、矩形与文字。使用枚举,可以为这三种情况定义一个数据类型:
enum Primitive {
case ellipse
case rectangle
case text(String)
}
于是可以在 CGContext 的扩展中定义一个方法 draw 来绘制图形元素:
extension CGContext {
func draw(_ primitive: Primitive, in frame: CGRect) {
switch primitive {
case .ellipse:
fillEllipse(in: frame)
case .rectangle:
fill(frame)
case .text(let text):
let attributeText = NSAttributedString(string: text, attributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 12)])
attributeText.draw(in: frame)
}
}
}
定义一个 image 方法方便将图标绘制成一张图片:
func image(in size: CGSize, draw: (CGContext, CGRect) -> Void) -> UIImage {
let bound = CGRect(origin: CGPoint.zero, size: size)
let renderner = UIGraphicsImageRenderer(bounds: bound)
return renderner.image { draw($0.cgContext, bound) }
}
接着通过如下代码就能绘制相应的图形图片:
image(in: CGSize(width: 100.0, height: 50.0), draw: { (context, bound) in
context.draw(.ellipse, in: bound)
})
image(in: CGSize(width: 100.0, height: 50.0), draw: { (context, bound) in
context.draw(.rectangle, in: bound)
})
image(in: CGSize(width: 100.0, height: 50.0), draw: { (context, bound) in
context.draw(.text("我是文字"), in: bound)
})
构建Attribute 枚举来描述图表各类样式属性的数据结构。它现在只支持fillColor,不过将其拓展以支持描边、渐变、文字排版属性等等的样式属性并不会很麻烦:
enum Attribute {
case fillColor(UIColor)
}
使用关键字 indirect 将 Diagram 定义为一个递归枚举表示图表:
indirect enum Diagram {
case primitive(CGSize, Primitive)
case beside(Diagram, Diagram)
case below(Diagram, Diagram)
case attributed(Attribute, Diagram)
case align(CGPoint, Diagram)
}
上述枚举分别表示:
需要注意的是对齐方式是使用一个CGPoint属性的x、y来分别表示垂直、水平两个方向的。 CGPoint 的 x 为 0 表示 左对⻬,为 1 则表示右对⻬。类似地,y 为 0 时表示上对⻬,为 1 时则表示下对⻬。
计算数据类型 Diagram 的尺寸,在值为 .beside 时,宽度等于两个 (被关联的) 图表宽度之和,而高度则等于左右图表中较高者 的高度。.below 也是以类似的方式进行计算。其它情况只需要递归地调用 size:
extension Diagram {
var size: CGSize {
switch self {
case let .primitive(size, _):
return size
case let .beside(left, right):
return CGSize(width: left.size.width + right.size.width, height: max(left.size.height, right.size.height))
case let .below(top, bottom):
return CGSize(width: max(top.size.width, bottom.size.width), height: top.size.height + bottom.size.height)
case let .attributed(_, diagram):
return diagram.size
case let .align(_, diagram):
return diagram.size
}
}
}
为 CGSize 与 CGPoint 定义下列运算符:
func *(l: CGFloat, r: CGSize) -> CGSize {
return CGSize(width: l*r.width, height: l*r.height)
}
func *(l: CGSize, r: CGSize) -> CGSize {
return CGSize(width: l.width*r.width, height: l.height*r.height)
}
func -(l: CGSize, r: CGSize) -> CGSize {
return CGSize(width: l.width - r.width, height: l.height - r.height)
}
func +(l: CGPoint, r: CGPoint) -> CGPoint {
return CGPoint(x: l.x + r.x, y: l.y + r.y)
}
extension CGSize {
var point: CGPoint {
return CGPoint(x: width, y: height)
}
}
extension CGPoint {
var size: CGSize {
return CGSize(width: x, height: y)
}
}
还需要再定义一个 fit 方法。这个方法会确保在某尺寸值 (比如某个图表 的尺寸) ⻓宽比不变的情况下,将它依据传入的矩形进行缩放。被等比修正的尺寸值在目标矩形中的坐标值则由一个类型为 CGPoint 的参数 alignment 传入,该 CGPoint 的 x 为 0 表示 左对⻬,为 1 则表示右对⻬,y 为 0 时表示上对⻬,为 1 时则表示下对⻬:
extension CGSize {
func fit(into rect: CGRect, alignment: CGPoint) -> CGRect {
let scale = min(rect.width/width, rect.height/height)
let targetSize = scale*self
let spacerSize = alignment.size*(rect.size - targetSize)
return CGRect(origin: rect.origin + spacerSize.point, size: targetSize)
}
}
方法分析:
例如希望在一个 200x100 的矩形中适配并居中一个 1x1 的正方形:
let ceter = CGPoint(x: 0.5, y: 0.5)
let target = CGRect(x: 0, y: 0, width: 200, height: 100)
let size = CGSize(width: 1, height: 1)
print("\(size.fit(into: target, alignment: ceter))")
// 输出:(50.0, 0.0, 100.0, 100.0)
左对⻬:
let leftTop = CGPoint(x: 0, y: 0)
print("\(size.fit(into: target, alignment: leftTop))")
// 输出:(0.0, 0.0, 100.0, 100.0)
如果两个图表相邻,也就是枚举值 .beside 或 .below 时,需要计算出一个图表 与合并后整体图表的比值,然后根据该比值将绘制边界拆分后,分别绘制图形。使用了一个 CGRect 的辅助方法,它按照指定的比例与拆分方向,将某个矩形平行地拆分:
extension CGRectEdge {
var isHorizontal: Bool {
return self == .maxXEdge || self == .minXEdge
}
}
extension CGRect {
func split(ratio: CGFloat, edge: CGRectEdge) -> (CGRect, CGRect) {
let length = edge.isHorizontal ? width : height
return divided(atDistance: length*ratio, from: edge)
}
}
为了能更方便地使用CGPoint进行对齐,可以为 CGPoint 定义下面这个扩展:
extension CGPoint {
static let left = CGPoint(x: 0.0, y: 0.5)
static let top = CGPoint(x: 0.5, y: 0.0)
static let right = CGPoint(x: 1.0, y: 0.5)
static let bottom = CGPoint(x: 0.5, y: 1.0)
static let center = CGPoint(x: 0.5, y: 0.5)
}
接着可以对 draw(:in:) 进行重载。这个个版本的 draw(:in:) 会接收两个参数: 一个图表,以及用于绘制该图表的矩形边界:
extension CGContext {
func draw(_ diagram: Diagram, in bound: CGRect) {
switch diagram {
case let .primitive(size, primiteve):
let frame = size.fit(into: bound, alignment: .center)
draw(primiteve, in: frame)
case let .align(alignment, diagram):
let frame = diagram.size.fit(into: bound, alignment: alignment)
draw(diagram, in: frame)
case let .beside(left, right):
let radio = left.size.width/diagram.size.width
let (leftBound, rightBound) = bound.split(ratio: radio, edge: .minXEdge)
draw(right, in: rightBound)
draw(left, in: leftBound)
case let .below(top, down):
let radio = top.size.height/diagram.size.height
let (topBound, downBound) = bound.split(ratio: radio, edge: .minYEdge)
draw(top, in: topBound)
draw(down, in: downBound)
case let .attributed(.fillColor(color), diagram):
saveGState()
color.setFill()
draw(diagram, in: bound)
restoreGState()
}
}
}
于是就可以通过前边的 image 方法绘制这5种图表的图片:
image(in: CGSize(width: 50.0, height: 50.0), draw: { (context, bound) in
context.draw(.primitive(bound.size, .rectangle), in: bound)
})
image(in: CGSize(width: 100.0, height: 50.0), draw: { (context, bound) in
let left = Diagram.primitive(CGSize(width: 50, height: 20), .rectangle)
let right = Diagram.primitive(CGSize(width: 50, height: 30), .rectangle)
context.draw(.beside(left, right), in: bound)
})
image(in: CGSize(width: 50.0, height: 100.0), draw: { (context, bound) in
let top = Diagram.primitive(CGSize(width: 50, height: 30), .rectangle)
let bottom = Diagram.primitive(CGSize(width: 50, height: 20), .text("北京"))
context.draw(.below(top, bottom), in: bound)
})
image(in: CGSize(width: 100.0, height: 50.0), draw: { (context, bound) in
let red = Diagram.attributed(.fillColor(UIColor.red), .primitive(bound.size, .ellipse))
context.draw(red, in: bound)
})
image(in: CGSize(width: 100.0, height: 50.0), draw: { (context, bound) in
context.draw(.align(CGPoint(x: 1, y: 0.5), .primitive(CGSize(width: 50, height: 50), .rectangle)), in: bound)
})
为了更容易地构建图表,添加一些额外的函数 (也称作组合算子 (Combinator)) 。这在函数式库中是一种很普遍的模式:选定一小部分核心的数据类型和函数,然后在它们 之上构建一些便利函数。
对于矩形,圆形,文字,正方形图表,定义如下的便利函数:
func rect(width: CGFloat, height: CGFloat) -> Diagram {
return Diagram.primitive(CGSize(width: width, height: height), .rectangle)
}
func circle(diameter: CGFloat) -> Diagram {
return Diagram.primitive(CGSize(width: diameter, height: diameter), .ellipse)
}
func text(content: String, width: CGFloat, height: CGFloat) -> Diagram {
return Diagram.primitive(CGSize(width: width, height: height), .text(content))
}
func square(side: CGFloat) -> Diagram {
return rect(width: side, height: side)
}
为水平或垂直的组合图表添加一个运算符是非常方便的,这将使代码更易读。运算符只是将 .beside 与 .below 封装了起来,还定义了优先级组,在合并图表时可以少写很多括号:
precedencegroup VerticalCombination {
associativity: left
}
infix operator --- : VerticalCombination
func ---(top: Diagram, bottom: Diagram) -> Diagram {
return Diagram.below(top, bottom)
}
precedencegroup HorizontalCombination {
higherThan: VerticalCombination
associativity: left
}
infix operator ||| : AdditionPrecedence
func |||(left: Diagram, right: Diagram) -> Diagram {
return Diagram.beside(left, right)
}
还可以扩展 Diagram 类型,添加填充和对⻬的方法。这些方法也可以被定义为框架的顶层 函数。这只是一个⻛格问题,两者在功能上并没有太大区别:
extension Diagram {
func filled(color: UIColor) -> Diagram {
return Diagram.attributed(.fillColor(color), self)
}
func aligned(position: CGPoint) -> Diagram {
return Diagram.align(position, self)
}
}
最后定义一个空图表和水平连接一组图表的方式。只需要使用 reduce 方法就可以实现:
extension Diagram {
init() {
self = rect(width: 0.0, height: 0.0)
}
}
extension Sequence where Element == Diagram {
var hcat: Diagram {
return reduce(Diagram(), |||)
}
}
通过添加上面这些小巧的辅助函数,就得到了一个强大的图表绘制库。
假如,有一组如下城市人口数据:
let contens: [(String, Float)] = [("衡阳", 1153.0), ("北京", 2345.0), ("上海", 4532.0), ("广州", 3232.0), ("深圳", 3474.0)]
如何将上述数据绘制成一张柱状图?
首先为Sequence扩展一个normalized属性用于等比规范所有的值,并确保最大值等于一:
extension Sequence where Element == CGFloat {
var normalized: [CGFloat] {
let maxValue = reduce(0.0, Swift.max)
return map { $0/maxValue }
}
}
然后为UIColor扩展一个random属性用于获取随机颜色,区分不同的图表元素:
extension UIColor {
static var random: UIColor {
return UIColor(red: ((CGFloat)(arc4random()%256))/255.0, green: ((CGFloat)(arc4random()%256))/255.0, blue: ((CGFloat)(arc4random()%256))/255.0, alpha: 1.0)
}
}
然后编写一个 barGraph 函数来处理一组由名称与值 (柱形的相对高度) 组成的多元组。对应每个多元组中值的部分,会绘制一个合适大小的矩形,再使用 hcat 方法以水平方向连接这些矩形。最后,使用 — 运算符,将文字依次放置在柱形下方:
func barGraph(input: [(String, Float)]) -> Diagram {
let values = input.map { CGFloat($0.1) }
let bars = values.normalized.map { (v) in
return rect(width: 1.0, height: 3*v).filled(color: .random).aligned(position: CGPoint.bottom)
}.hcat
let labels = input.map { (label, _) in
return text(content: label, width: 1.0, height: 0.3).aligned(position: .top)
}.hcat
return bars --- labels
}
最后通过image函数将这个柱形图的图表绘制到一张图片中:
image(in: CGSize(width: 50.0*((CGFloat)(contens.count)), height: 165.0), draw: { (context, bound) in
context.draw(barGraph(input: contens), in: bound)
})