好看的图表怎么画,看完这几个 API 你就会了

5,839 阅读4分钟

「这是我参与11月更文挑战的第22天,活动详情查看:2021最后一次更文挑战

前言

作为一名前端码农,半年前看到下面的这张图,内心不禁呐喊怎么能这么酷炫啊!

截屏2021-11-22 下午12.32.58.png

要是我也能开发出这样的数据可视化图表就好了,于是我就立下了 flag。

终于今天我要来兑现我的 flag 啦!

先来一波概念

在正式的开始编码之前,我们先来熟悉一下 SwiftUI 提供的一些绘制图形和图形特效的 API 吧!

  1. 绘制一个带圆角的矩形
RoundedRectangle(cornerRadius: 4)
  1. 用颜色或渐变填充此形状。
public func fill<S>(_ content: S, style: FillStyle = FillStyle()) -> some View where S : ShapeStyle
  1. 按给定的尺寸和锚点,对视图进行缩放
public func scaleEffect(_ scale: CGSize, anchor: UnitPoint = .center) -> some View
  1. 动画
public func animation(_ animation: Animation?) -> some View
  1. 阴影
public func shadow(color: Color = Color(.sRGBLinear, white: 0, opacity: 0.33), radius: CGFloat, x: CGFloat = 0, y: CGFloat = 0) -> some View
  1. 创建一个路径
var path = Path()
  1. 在指定点开始一个新的子路径
public mutating func move(to p: CGPoint)
  1. 将二次贝塞尔曲线添加到路径中,并具有指定的端点和控制点
public mutating func addQuadCurve(to p: CGPoint, control cp: CGPoint)
  1. 将圆弧添加到路径中,指定半径和角度
public mutating func addArc(center: CGPoint, radius: CGFloat, startAngle: Angle, endAngle: Angle, clockwise: Bool, transform: CGAffineTransform = .identity)
  1. 从当前点到指定点追加一条直线段
public mutating func addLine(to p: CGPoint)
  1. 关闭并完成当前子路径
public mutating func closeSubpath()
  1. 使用颜色或渐变描绘此形状的轮廓
public func stroke<S>(_ content: S, style: StrokeStyle) -> some View where S : ShapeStyle
  1. 围绕指定点旋转此视图的渲染输出
public func rotationEffect(_ angle: Angle, anchor: UnitPoint = .center) -> some View
  1. 围绕给定的旋转轴在三个维度上旋转此视图
public func rotation3DEffect(_ angle: Angle, axis: (x: CGFloat, y: CGFloat, z: CGFloat), anchor: UnitPoint = .center, anchorZ: CGFloat = 0, perspective: CGFloat = 1) -> some View
  1. 在视图上添加手势
public func gesture<T>(_ gesture: T, including mask: GestureMask = .all) -> some View where T : Gesture

代码实践

接下来,咱们就开始去利用这些 API 来实现精美的 Chart 吧!

柱状图

首先,我们先从简单的柱状图开始。实现的效果如下:

image

绘制一个圆角矩形,这里需要用到 RoundedRectangle 这个结构体,在 SwiftUI 中的定义如下:

@frozen public struct RoundedRectangle : Shape {

    public var cornerSize: CGSize

    public var style: RoundedCornerStyle

    @inlinable public init(cornerSize: CGSize, style: RoundedCornerStyle = .circular)

    @inlinable public init(cornerRadius: CGFloat, style: RoundedCornerStyle = .circular)

    /// Describes this shape as a path within a rectangular frame of reference.
    ///
    /// - Parameter rect: The frame of reference for describing this shape.
    ///
    /// - Returns: A path that describes this shape.
    public func path(in rect: CGRect) -> Path

    /// The data to animate.
    public var animatableData: CGSize.AnimatableData

    /// The type defining the data to animate.
    public typealias AnimatableData = CGSize.AnimatableData

    /// The type of view representing the body of this view.
    ///
    /// When you create a custom view, Swift infers this type from your
    /// implementation of the required ``View/body-swift.property`` property.
    public typealias Body
}

通过代码可知,我们只需要在初始化的时候传入一个设置圆角大小的值即可生成一个圆角矩形,又由于 RoundedRectangle 继承自 Shape,所以我们可以使用 Shape 的特性来对生成的圆角矩形添加效果。

RoundedRectangle(cornerRadius: 4)

既然已经知道如何绘制一个矩形,那对我们来说绘制10个,20个也不再话下了,我们只要根据传入数据的 size 用一个 for 循环,就可以绘制出一定数量的圆角矩形。

GeometryReader { geometry in
            HStack(alignment: .bottom, spacing: getSpaceWidth(width: geometry.frame(in: .local).width - 20)) {
                ForEach(0..<self.data.count, id: \.self){ i in
                    .....
                }
            }.padding([.top, .leading, .trailing], 20)
        }

光画出矩形还不够,如何才能让这些矩形根据传入的数据,形成高低不一的效果呢!这时候,我们就需要用到 scaleEffect 缩放函数了,它的定义如下:

@inlinable public func scaleEffect(_ scale: CGSize, anchor: UnitPoint = .center) -> some View

根据参数的定义,只需要我们传入缩放的比例以及锚点,就能将我们的矩形进行缩放,此 API 在绘制 2D 图形的时候,使用的频率非常高,非常好用。

RoundedRectangle(cornerRadius: 4)
    .frame(width: CGFloat(self.cellWidth))
    .scaleEffect(CGSize(width: 1, height: self.scaleValue), anchor: .bottom)
    .onAppear {
        self.scaleValue = self.value
    }
    .animation(.spring().delay(self.touchLocation < 0 ? Double(self.index) * 0.04 : 0))

接着,就要为我们绘制的矩形填充颜色啦!这里我们用到的是 fill 函数,它可以填充 Color 或者 Gradient,为了好看我们当然选择渐变色,LinearGradient 对象恰巧可以为我们绘制渐变的颜色。

RoundedRectangle(cornerRadius: 4)
                    .fill(LinearGradient(gradient: gradient?.getGradient() ?? GradientColor(start: accentColor, end: accentColor).getGradient(), startPoint: .bottom, endPoint: .top))
                    .frame(width: CGFloat(self.cellWidth))
                    .scaleEffect(CGSize(width: 1, height: self.scaleValue), anchor: .bottom)
                    .onAppear {
                        self.scaleValue = self.value
                    }
                    .animation(.spring().delay(self.touchLocation < 0 ? Double(self.index) * 0.04 : 0))

最后,为了让我们的柱状图用户体验更佳,我们需要为它添加手势响应。根据手势滑动得到在屏幕上的坐标,然后根据当前的坐标去计算数据数组中的索引,从而得到数组的值,部分代码如下:

.gesture(DragGesture().onChanged({ value in
                self.touchLocation = value.location.x / self.formSize.width
                self.showValue = true
                self.currentValue = self.getCurrentValue()?.1 ?? 0
                if self.data.valuesGiven && self.formSize == ChartForm.medium {
                    self.showLabelValue = true
                }
            }).onEnded({ value in
                self.showValue = false
                self.showLabelValue = false
                self.touchLocation = -1
            })
            )
            .gesture(TapGesture())

由于篇幅原因,我就不放上全部的代码了,想看源码的,请移步:github.com/ShenJieSuzh…

饼状图

接下来,我们继续来实现饼状图 PieChart。再画饼状图之前,我们先来画一个圆吧!SwiftUI 提供了 Path 这个结构体,让我们可以绘制 2D 的图形,所以绘制一个圆的代码如下:

var path: Path {
    var path = Path()
    path.addArc(center: CGPoint(x: 200, y: 200), radius: 100, startAngle: Angle(degrees: 0), endAngle: Angle(degrees: 360), clockwise: false)
    return path
}

这时候你的页面上就会出现一个圆,如图:

image

看到这,你是不是就突然意识到了点啥?饼状图其实就是一个圆,只不过是它是由几大块同一个圆心,同样半径的扇形所组成,所以通过这一个特征,我们就可以依次的去绘制不同面积的扇形,最后将这些扇形拼成一个饼状图了。

这里我们需要用到 SwiftUI 提供给我们的几个 API,分别是:Path,addArc,addLine 以及 closeSubpath。

下面一次来介绍一下吧!

  1. Path 是 SwiftUI 提供的一个用于绘制 2D 图形的结构体,我称之为路径。

  2. addArc 函数的定义为:

public mutating func addArc(center: CGPoint, radius: CGFloat, startAngle: Angle, endAngle: Angle, clockwise: Bool, transform: CGAffineTransform = .identity)

这个函数的作用是根据给定的圆心,半径和角度绘制一个圆弧。

  1. addLine 函数的定义为:
public mutating func addLine(to p: CGPoint)

这个函数的作用是从当前点到给定的点绘制一条直线。

  1. closeSubpath 函数的定义为:
public mutating func closeSubpath()

这个函数的作用是关闭我们之前定义的 Path,就好比我们写完字后要给笔盖上笔套一样。

熟悉了以上这些函数的概念之后,我们就开始绘制饼状图吧!由于饼状图显示给用户的是几大块数据的比较,所以传入它的数据一定是数组,那我们就可以用 for 循环的方式来依次去绘制,代码如下:

var body: some View {
        GeometryReader { geometry in
            ZStack {
                ForEach(0..<self.slices.count) { i in
                    PieChartCell(rect: geometry.frame(in: .local), startDeg: self.slices[i].startDeg, endDeg: self.slices[i].endDeg, index: i, backgroundColor: self.backgroundColor, accentColor: self.accentColor)
                }
            }
        }
    }

另外,我们还需要计算一下每个扇形的开始角度以及结束角度,因为是饼状图,所以我们要以 360 度的角度来做基准,依次去计算每块扇形的数据,代码如下:

var slices: [PieSlice] {
        var tempSlices:[PieSlice] = []
        var lastEndDeg: Double = 0
        let maxValue = data.reduce(0, +)
        for slice in data {
            let normalized: Double = Double(slice) / Double(maxValue)
            let startDeg = lastEndDeg
            let endDeg = lastEndDeg + (normalized * 360)
            lastEndDeg = endDeg
            tempSlices.append(PieSlice(startDeg: startDeg, endDeg: endDeg, value: slice, normalizedValue: normalized))
        }
        return tempSlices
    }

角度计算完成后,就可以绘制扇形了,这里我就直接贴上代码了,比较简单,另外它的一些特效函数在上面绘制柱状图的时候也说过了,这里也不做多的阐述,看代码吧:

struct PieChartCell: View {
    @State private var show: Bool = false
    var rect: CGRect
    var radius: CGFloat {
        return min(rect.width, rect.height) / 2
    }
    var startDeg: Double
    var endDeg: Double
    var path: Path {
        var path = Path()
        path.addArc(center: rect.mid, radius: self.radius, startAngle: Angle(degrees: self.startDeg), endAngle: Angle(degrees: self.endDeg), clockwise: false)
        path.addLine(to: rect.mid)
        path.closeSubpath()
        return path
    }
    
    var index: Int
    var backgroundColor: Color
    var accentColor: Color
    
    var body: some View {
        path.fill()
            .foregroundColor(self.accentColor)
            .overlay(path.stroke(self.backgroundColor, lineWidth: 2))
            .scaleEffect(self.show ? 1 : 0)
            .animation(Animation.spring().delay(Double(self.index) * 0.04))
            .onAppear {
                self.show = true
            }
    }
}

运行的效果图如下:

image

折线图

说完柱状图和饼状图后,我们接下来就得看下折线图啦!

先上图看个效果吧!

image

是不是有点酷炫,那接下来我们就一步步的去实现它吧!

首先,肯定是少不了 SwiftUI 提供的 Path 这个结构体,用它来绘制折线图是最好不过了;由于折线图的分布是一个一个的点,然后依次要将它们串联起来,所以我们得先根据给定的数据数组来计算折线图的点。

由于我们已经知道了数据数组,但是它仅仅只是一个 Double 类型的数组,所以我们需要将它的每个值来对应一个 CGPoint,那具体怎么做呢!接着往下看。

我们先来计算每个点之间的 x 和 y 的比例关系,由于是折线图,所以我们在 x 轴上点与点之间的比例应该是均等的,需要体现数据差别的是点在 y 轴上不同,因此计算在 x 轴上的比例的代码如下:

var stepWidth: CGFloat {
        if data.points.count < 2 {
            return 0
        }
        return frame.size.width / CGFloat(data.points.count - 1)
    }

计算在 y 轴上的比例的代码如下:

var stepHeight: CGFloat {
        var min: Double?
        var max: Double?
        let points = self.data.onlyPoints()
        if minDataValue != nil && maxDataValue != nil {
            min = minDataValue
            max = maxDataValue
        }else if let minPoint = points.min(), let maxPoint = points.max(), minPoint != maxPoint {
            min = minPoint
            max = maxPoint
        }else {
            return 0
        }
        
        if let min = min, let max = max, min != max{
            if min <= 0 {
                return (frame.size.height - padding) / CGFloat(max - min)
            }else {
                return (frame.size.height - padding) / CGFloat(max - min)
            }
        }
        
        return 0
    }

有了比例关系之后,我们就可以计算出具体的点的坐标了,起始点的坐标为;

var p1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y)

第二个点的坐标为:

let p2 = CGPoint(x: step.x * CGFloat(pointIndex), y: step.y*CGFloat(points[pointIndex]-offset))

这样,我们就得到了每个点的坐标,就可以用 SwiftUI 提供的 Path 结构体里的方法来将这些点串起来,绘制一条折线啦!

但是为了美观,我们的效果图上绘制的折线用到了贝塞尔曲线,我们原本是在俩个点之间绘制一条直线,但加入了贝塞尔曲线后,它会在我们俩点之间加入一个锚点,然后通过这个锚点可以弯曲我们的直线,达到让原本尖锐的波浪线呈现一种缓和的效果,有点类似 PS 里的钢笔工具,用到的相应 API 为:

ublic mutating func addQuadCurve(to p: CGPoint, control cp: CGPoint)

使用方式如代码所示:

static func quadCurvedPathWithPoints(points:[Double], step:CGPoint, globalOffset: Double? = nil) -> Path {
        var path = Path()
        if (points.count < 2){
            return path
        }
        
        let offset = globalOffset ?? points.min()!
        var p1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y)
        path.move(to: p1)
        for pointIndex in 1..<points.count {
            let p2 = CGPoint(x: step.x * CGFloat(pointIndex), y: step.y*CGFloat(points[pointIndex]-offset))
            let midPoint = CGPoint.midPointForPoints(p1: p1, p2: p2)
            path.addQuadCurve(to: midPoint, control: CGPoint.controlPointForPoints(p1: midPoint, p2: p1))
            path.addQuadCurve(to: p2, control: CGPoint.controlPointForPoints(p1: midPoint, p2: p2))
            p1 = p2
        }
        return path
    }

最后,就是要给折线加上一些视觉效果了,个人感觉渐变的效果能提升特别高的视觉效果,于是代码如下:

self.closedPath
.fill(LinearGradient(gradient: gradient.getGradient(), startPoint: .bottom, endPoint: .top))
.rotationEffect(.degrees(90), anchor: .center)
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
.transition(.opacity)
.animation(.easeIn(duration: 1.6))
    
self.path
.trim(from: 0, to: self.showFull ? 1:0)
.stroke(LinearGradient(gradient: gradient.getGradient(), startPoint: .leading, endPoint: .trailing), style: StrokeStyle(lineWidth: 3, lineJoin: .round))
.rotationEffect(.degrees(180), anchor: .center)
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
.animation(.easeOut(duration: 1.2).delay(Double(self.index) * 0.4))
.onAppear {
    self.showFull = true
}
.onDisappear {
    self.showFull = false
}                    
                    

最后

本来还想再写点,看了一下马上到凌晨一点了,先暂时码这么多吧!身体要紧,明天还得上班。睡了 ~

下载.jpeg

源码地址:github.com/ShenJieSuzh… 点个 🌟 吧!

**往期文章:

请你喝杯 ☕️ 点赞 + 关注哦~

  1. 阅读完记得给我点个赞哦,有👍 有动力
  2. 关注公众号--- HelloWorld杰少,第一时间推送新姿势

最后,创作不易,如果对大家有所帮助,希望大家点赞支持,有什么问题也可以在评论区里讨论😄~**