Swift函数式编程九(图表)

Posted 酒茶白开水

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Swift函数式编程九(图表)相关的知识,希望对你有一定的参考价值。

代码地址

一种描述图表的函数式方式,并利用 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)
}

上述枚举分别表示:

  • 一个有确定尺寸的 Primitive,即椭圆、 矩形或者文字其中之一。
  • 表示一对左右相邻 (水平方向)的图表。
  • 表示一对上下相邻 (垂直方向) 的图表。
  • 带有样式属性的图表。
  • 带有对齐方式的图表。

需要注意的是对齐方式是使用一个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)
    }
}

方法分析:

  • 首先使用min函数计算出适应的缩放比例
  • 然后按比例计算出最终的尺寸
  • 最后按照对齐方式计算出最终的位置

例如希望在一个 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)
        })

以上是关于Swift函数式编程九(图表)的主要内容,如果未能解决你的问题,请参考以下文章

Swift函数式编程九(图表)

九函数与函数式编程

python3的学习之路九函数式编程

Swift 响应式编程

Swift函数式编程八(纯函数式数据结构)

Swift函数式编程八(纯函数式数据结构)