美文网首页Swift - basis
Swift UI简单统计图绘制

Swift UI简单统计图绘制

作者: 张小西的BUG | 来源:发表于2019-11-27 14:36 被阅读0次
    常用的表格图绘制主要用到折线图和饼图。也有不错的第三方框架,比如:Charts。如果不是专门做统计的,没有必要引入进来,就当学习使用。因为框架太重,提供的功能很多好多用不到,有时候使用一个很简单的折线图或者饼图,要设置十几个参数。

    那么我们可以参考Charts来自己实现折线图,或者饼图的绘制。如下,就是自己使用CoreGraphics实现的效果。

    context.gif

    先说动画效果。因为在没有了解之前我一直很好奇怎样实现CGContext的作图动效。其实用的是CADisplayLink定时器的一种,比较特殊可以让你与屏幕刷新频率相同的速率来刷新你的视图,没有时间参数传入。还有二中比较常用的定时器TimerDispatchSourceTimer,不在此具体介绍了。我们可以封装一个装门的动画类(Charts也是这么干的,作图的基类都初始化了这个动画类)。主要代码

    /// 定时器
    var displayLin: CADisplayLink?
    
    /// 开始时间
    var startTime: TimeInterval = 0.0
    
    /// 结束时间
    var endTime: TimeInterval = 0.0
    
    /// 动画时间
    var duration: TimeInterval = 0.0
    
    /// 更新界面回调
    var updateBlock: (() -> Void)?
    
    /// 动画结束回调
    var stopBlock: (() -> Void)?
    
    /// 更新进度
    var phaseX: Double = 1
    
    func animate(duration: TimeInterval) {
        self.startTime = CACurrentMediaTime()
        self.duration = duration
        self.endTime = startTime + duration
        self.updateAnimationPhsees(currentTime: startTime)
        if self.displayLin == nil {
            self.displayLin = CADisplayLink(target: self, selector: #selector(animationLoop))
            self.displayLin?.add(to: .main, forMode: .common)
        }
        
    }
    

    其中核心的属性就是phaseX((currentTime - startTime)/durationTime),因为我们需要它去刷新作图的进度,这个类的核心就是计算它。然后我们就可以去实现作图的动画了。

    func stop() {
        self.displayLin?.remove(from: .main, forMode: .common)
        self.displayLin = nil
        stopBlock?()
    }
    
    @objc private func animationLoop(){
        let currentTime = CACurrentMediaTime()
        self.updateAnimationPhsees(currentTime: currentTime)
        updateBlock?()
        if currentTime >= self.endTime {
            self.stop()
        }
    }
    
    /// 更新进度
    private func updateAnimationPhsees(currentTime: TimeInterval) {
        let elapsedTime = currentTime - self.startTime
        if elapsedTime >= duration {
            phaseX = 1.0
            return
        }
        phaseX = elapsedTime/duration
    }
    

    实现示例中的折线图是比较简单的,事实上我们项目中比这个麻烦得多是类似支付宝中账单的折线图效果,这个地方只是抛砖引玉。实现折线图大概主要会使用二种方法,第一种就是使用CAShapeLayer(功能很强大,很多复杂的动效都是CAShapeLayer+UIBezierPath+CoreAnimation实现 ),第二种就是使用CGContext也就是画布,所有的作图都是在override func draw(_ rect: CGRect)中实现,记得调用父类,同时设着背景色,不然背景会是黑色的。

    总共分三步,第一步画线,第二步画点,第三步加上动画。先获取画布的上下文let context = UIGraphicsGetCurrentContext()。画线的API很简单,和贝塞尔曲线一样,如果需要话虚线的话,需要设置setLineDash

        context.setStrokeColor(UIColor.red.cgColor)
        context.setLineWidth(1)
        context.move(to: xPoint)
        context.addLine(to: zeroPoint)
        context.addLine(to: yPoint)
        context.strokePath()
    

    具体业务逻辑就不在此介绍,就是算出每个点是多少,最大值是多少,总之算出点,然后context.addLine和 context.strokePath连起来就完成第一步了。再来画圆,空心圆或者实心圆(填充的背景色context.setFillColor(UIColor.red.cgColor)),带边框的圆(画笔的颜色 context.setStrokeColor(UIColor.white.cgColor)),折线的颜色,和圆的边框色不一样记需要设置2次。以第一步计算的点来作为圆心画圆,画圆的API

        public func fillEllipse(in rect: CGRect)
        public func strokeEllipse(in rect: CGRect)
    

    参数rect的意思就是在这个区域内做一个最大的内切圆,如果圆心为P(x,y)半径为r,那么这个区域表示为((x - r) , (y - r) , 2 * r , 2 * r),所以最后画圆就是这样的,和贝塞尔曲线不太一样,但是API更简单

     context.fillEllipse(in: CGRect(x: x1 - radius, y: y1 - radius , width: radius * 2, height: radius * 2))
    context.setStrokeColor(UIColor.white.cgColor)
    context.strokeEllipse(in: CGRect(x: x1 - radius, y: y1 - radius , width: radius * 2, height: radius * 2))    
    

    这样画完了,并没有,你会发现画的折线会穿过圆,又不能调整试图层级,但是可以设置作图的先后顺序,不过这样并没有解决问题的根本原因,空心圆还是会出现,所以就需要我们计算作图时点的实际位置了,其实也很简单,就是用简单的三角函数就可以了。

    /// 计算折线不穿过点
    private func changePoint(x1: CGPoint , x2: CGPoint , radius: CGFloat) -> (x1: CGPoint , x2: CGPoint) {
        let k = (x2.y - x1.y)/(x2.x - x1.x)
        let arc = atan(k) // 反三角函数,获取角度
        let sinArc = sin(arc)
        let coseArc = cos(arc)
        let _x1 = CGPoint(x: x1.x + radius * coseArc, y: x1.y + radius * sinArc)
        let _x2 = CGPoint(x: x2.x - radius * coseArc, y: x2.y  - radius * sinArc)
        return (_x1 , _x2)
    }
    

    x1为起始点,x2位结束点 ,radius为半径,返回实际的起始和结束点点。第二步就完成了。最后就是加动画了,个人比较喜欢做动效。

    private let animation = AnimationModel()
    /// 是否正在动画
    private var isAnimation: Bool = false
    func startAnmaiton(duration: TimeInterval) {
        guard isAnimation == false else {
            return
        }
        isAnimation = true
        self.animation.animate(duration: duration)
        self.animation.updateBlock = {
            self.setNeedsDisplay()
        }
        self.animation.stopBlock = { [weak self] in
            self?.isAnimation = false
        }
    }
    

    self.setNeedsDisplay()可以触发override func draw(_ rect: CGRect)函数,就是在里面来作图。作图动画怎么理解呢?动画模型里面开始动画就会就算进度,我们作图就是 根据进度去画,比如你需要画10个点,那么你实际上你画的点就是 10 * animation.phaseX直到animation.phaseX为1时,正好全部画完,你就可以看到动画了,补充下:Charts里面动效本质都是这么实现的只是加了其他的特效,而且里面是分x轴和y轴动效的,主要是框架里面功能比较齐全。

    假设你的点都计算好了,存放在dataArr中,那么你每次画图的实际的点就是let drawLenge: Int = Int((Double(dataArr.count) * animation.phaseX)),整个作图就是这样的

    /// 画折线图
    private func drawLinchart(rect: CGRect , context: CGContext) {
        guard self.maxData > 0 else {
            return
        }
        context.saveGState()
        context.setLineWidth(1)
        context.setFillColor(UIColor.red.cgColor)
        let drawLenge: Int = Int((Double(dataArr.count) * animation.phaseX))
        if drawLenge <= dataArr.count , drawLenge > 1 {
            let w: CGFloat = (rect.width - hMargin * 2)/(CGFloat(dataArr.count - 1))
            for i in 1 ... drawLenge {
                let x1 = hMargin + CGFloat(i - 1) * w
                let y1 = rect.height - CGFloat(dataArr[i - 1]/maxData) * (rect.height - 2 * VMagrin) - VMagrin
               
                if i != drawLenge {
                    let x2 = hMargin + CGFloat(i) * w
                    let y2 = rect.height - CGFloat(dataArr[i]/maxData) * (rect.height - 2 * VMagrin) - VMagrin
                    let pointGroup = changePoint(x1: CGPoint(x: x1, y: y1), x2: CGPoint(x: x2, y: y2), radius: radius)
                    context.move(to: pointGroup.x1) ; context.addLine(to: pointGroup.x2)
                }
                context.setStrokeColor(UIColor.red.cgColor)
                context.setLineJoin(.round)
                context.strokePath()
                context.fillEllipse(in: CGRect(x: x1 - radius, y: y1 - radius , width: radius * 2, height: radius * 2))
                context.setStrokeColor(UIColor.white.cgColor)
                context.strokeEllipse(in: CGRect(x: x1 - radius, y: y1 - radius , width: radius * 2, height: radius * 2))
            }
        }
      
        context.restoreGState()
    }
    

    当然里面也有很多需要去优化的点,比如你的起始点,我使用的第一次直接就是二个点,这个需要商榷,还有X轴的数据源,我是按照数据源去平分的,这个需要看具体需求了,还有就是之前说到的类似支付宝里面的折线图,折线图需要去填充颜色,这个是比较麻烦的,目前我也没有太好的解决办法,但是个人觉得这个填充色,需要用CAShapeLayer去解决,这样的话就不会和CGContext的填充色冲突,但是这样会有一个很难解决的问题,CAShapeLayer会覆盖掉画的圆,项目中我是通过图层顺序解决的,使用二个图层,一个是画填充色放到底层,一个是折线图放在上层,动效的话就同步进行看不出差异。

    我觉得饼图是比较难处理的,画饼图很容易,但是处理点击事件就比较麻烦了,如果高中数学忘的差不多算起来就更麻烦了,我也看了Charts里面怎么计算的,老实说看的很懵逼,最后按照自己的理解去计算。里面最基本的概念弧度制,Swift提供三角函数的角度都是弧度制,360度就是2*pi,转换关系就很简单了。还有就是坐标系,水平东边(右手边)为起始位置,顺时针计算角度(数学里面是逆时针计算角度)

    extension CGFloat {
    
    /// 获取角度
    func DEG2RAD() -> CGFloat {
        return CGFloat.pi * self/180
    }
    

    }

    先看准备工作,实现示例中的饼图:

    private let animation = AnimationModel()
    
    private let dataArr: [Double] = [0.25 , 0.3 ,0.15 , 0.1 , 0.2]
    
    private let colorArr: [UIColor] = [.white , .red , .yellow , .blue , .brown]
    
    private let radius: CGFloat = 100
    
    /// 是否正在动画
    private var isAnimation: Bool = false
    
    /// 选中时的半径比例
    private var selectRadius = 1.2
    
    /// 初始角度
    private var startAngle: CGFloat = 270
    
    /// 开始拖动时的角度
    private var startMoveAngle: CGFloat = 270
    
    /// 开始触摸的点
    private var startTouchPoint: CGPoint = CGPoint.zero
    

    饼图的实现是用CGContext + CGMutablePath主要的API就一个(CoreGraphics框架提供的,里面有很多作图的API,有时间可以看看)

    public func addRelativeArc(center: CGPoint, radius: CGFloat, startAngle: CGFloat, delta: CGFloat, transform: CGAffineTransform = .identity)
    

    先说主要参数startAngeOuter,开始角度(坐标系和数学里不一样,此处顺时针),delta:扫过的角度(就是该组站的比例然后乘以2pi)。对于画饼图很容易。最初结束Charts时,看到里面饼图动画感觉很炫酷,看了源码后,感觉远简单于最初的感受,但是示例中没有实现回弹动效。开始画饼图,第一步计算起始点,实际上是圆上的点,扇形的起点,第二步计算扫过的角度,通过数据源计算转成弧度制,第三部添加线,回到圆心封闭扇形。整个代码如下:

     let center: CGPoint = CGPoint(x: rect.width/2, y: rect.height/2)
        context.saveGState()
        var angle: CGFloat = 0
        var _radius = self.radius
        for i in 0 ..< dataArr.count {
            if i == self.selectIndex {
                _radius = self.radius * CGFloat(self.selectRadius)
            }else {
                _radius = self.radius
            }
            context.setFillColor(colorArr[i].cgColor)
            let sliceAngle: CGFloat = CGFloat(dataArr[i]) * 360
            let startAngeOuter = startAngle + angle
            
            let arcStartPointX = center.x + _radius * cos(startAngeOuter.DEG2RAD())
            let arcStartPointY = center.y + _radius * sin(startAngeOuter.DEG2RAD())
            let path = CGMutablePath()
            path.move(to: CGPoint(x: arcStartPointX, y: arcStartPointY))
            path.addRelativeArc(center: center, radius: _radius, startAngle: startAngeOuter.DEG2RAD(), delta: sliceAngle.DEG2RAD())
            path.addLine(to: center)
       
            path.closeSubpath()
            context.beginPath()
            context.addPath(path)
            context.fillPath(using: .evenOdd)
    
            angle = angle + sliceAngle * CGFloat(self.animation.phaseX)
        }
        context.restoreGState()
    

    再来聊聊动效,很简单就一行代码angle = angle + sliceAngle * CGFloat(self.animation.phaseX)如果不使用动效那就不要乘以CGFloat(self.animation.phaseX)就可以了, angle表示每个扇形在圆中的实际起始位置,当数据源确定和第一个扇形的起始位置确定,那么每一个扇形的起始位置就确定了。通过改变扇形的起始位置来实现动效。然后再去网上搜索,很有很多动效,比较炫酷的动效扇形基本都是用Layer来画的,然后用layer.mask来实现动画,mask遮罩的意思,想了解的可以自行网上搜索。至此饼图就画完了,动效也有了。接下来就是处理细节了。比如画同心圆,这个只需要注意同心圆要画到后面就行了,很熟悉的API还是如此的简单

     /// 画同心圆
    private func drawInnerArc(context: CGContext , percent: CGFloat) {
        guard percent >= 0 , percent < 1 else {
            return
        }
        context.saveGState()
        context.setFillColor(UIColor.black.cgColor)
        defer { context.restoreGState() }
        // 圆心
        let center = CGPoint(x: self.frame.size.width/2, y: self.frame.size.height/2)
        // 半径
        let innerRadius = self.radius * percent
        context.fillEllipse(in: CGRect(x: center.x - innerRadius, y: center.y - innerRadius, width: innerRadius * 2, height: innerRadius * 2))
    }
    

    接下来处理比较麻烦的事了,旋转和点击,数学要感冒不要晕。先判断点击的点是否在园内,这个比较简单。然后判断点击的角度,顺时针计算,避开特殊点。代码写的比较繁琐,还可以精简,当时写的时候算的有点麻烦。

     /// 计算点击的角度(三角函数基本知识)
    private func angleForPoint(point: CGPoint) -> CGFloat {
        // 圆心
        let c = CGPoint(x: self.frame.size.width/2, y: self.frame.size.height/2)
        let tx = Double(point.x - c.x)
        let ty = Double(point.y - c.y)
        let length = sqrt(tx * tx + ty * ty)
        let r = acos(tx/length)
        var angle = (CGFloat(r)/CGFloat.pi) * 180
        if point.y <= c.y {
            angle = 360 - angle
            if point.y == c.y , point.x < c.x{
                angle = 180
            }else if point.y == c.y ,point.x > c.x {
                angle = 360
            } else if point.x == c.x {
                angle = 270
            }
        }else if point.y > c.y , point.x == c.x {
            angle = 90
        }
        return angle
    }
    

    获取点击的扇形对应的下标,数据源中的位置

    /// 获取下标
    private func indexForAngle(_ angle: CGFloat) -> Int {
        let angreArr = self.dataArr.map {  progress in
            return progress * 360
        }
        var start: Double = Double(self.startAngle) > 360 ? Double(startAngle) - 360 : Double(startAngle)
        var maxAngle: Double = start
        for i in 0 ... angreArr.count - 1 {
           maxAngle = start + angreArr[i]
            if maxAngle < 360 {
                if Double(angle) > start , Double(angle) < maxAngle {
                    return i
                }
            }else {
                if Double(angle) > start {
                    return i
                }
                maxAngle =  maxAngle - 360
                if Double(angle) < maxAngle {
                    return i
                }
            }
            start = maxAngle
            start = start > 360 ? (start - 360) : start
           
        }
        return -1
    }
    

    这个计算比较麻烦。考虑到起始角度加上扫过的角度大于360,要分别去判断。如果小于360,只需要判断点击的角度大于扇形的起始位置start,小于扇形的结束位置maxAngle就满足条件。所以点击手势的处理事件就完成了

      /// 点击手势
    @objc private func tapGestureRcogniezd(recognizer: UITapGestureRecognizer) {
        let point = recognizer.location(in: self)
        if self.isContain(point: point) {
            let angle = self.angleForPoint(point: point)
            selectIndex = self.indexForAngle(angle)
        }else {
            debugPrint("不处理")
            selectIndex = -1
        }
    }
     
    private var selectIndex: Int = -1 {
        didSet{
            if oldValue != selectIndex {
               self.setNeedsDisplay()
            }
        }
    }
    

    在看旋转手势,有了之前的判断和铺垫,旋转动效就很简单了,只需要计算旋转的角度修改作图的初始角度startAngle就OK了,怎没计算旋转角度呢?记住点击屏幕的点,可以得到一个初始的角度这个角度。

     override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        if let point = touches.first?.location(in: self) {
            self.startTouchPoint = point
            let angle = self.angleForPoint(point: point)
            self.startMoveAngle = angle
            self.startMoveAngle -= self.startAngle
        }
    }
    

    然后再就是计算拖动过程中角度的变化

    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        if let point = touches.first?.location(in: self)  {
            if caluateDistance(p1: point, p2: startTouchPoint) >= 8 {
                self.selectIndex = -1
                let angle = self.angleForPoint(point: point)
                self.startAngle = angle - startMoveAngle
                if self.startAngle < 0 {
                    self.startAngle = startAngle + 360
                }else if self.startAngle >= 360 {
                    self.startAngle = startAngle - 360
                }
                self.setNeedsDisplay()
            }
        }
    }
    

    做了一个很小的判断,拖动距离大于8的时候开始相应。结合这段代码一眼还不容易看出来是怎么计算的,但是不管怎么计算始终就应该是:当前初始角度 = 最初的初始角度 + 旋转后的角度 - 旋转前的角度。但是由于整个是一个拖动的过程当前初始角度不停的在变,并且和最初的初始角度用的同一个属性,所以最后的计算就变成了:当前的初始角度 = 旋转后的角度 - (旋转前的角度 - 最初的初始角度),就会看到 self.startMoveAngle -= self.startAngle这句比较让人懵逼的代码。好了整个拖动也就结束了,最后记得加上这个回调事件就行

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        self.startMoveAngle = startAngle
    }
    

    还有最后一个小功能,怎样你画的数据表示啥,需求不同样式也是各式使用图例的话,比较简单,找个空地自己画就完事了。如果用线牵出来表示这个就有需要计算了,示例中计算的代码如下,尽做参考,毕竟需求说了才是对的

    /// 画数据
    private func drawValue(context: CGContext) {
        let center = CGPoint(x: self.frame.size.width/2, y: self.frame.size.height/2)
        let angleArr = dataArr.map { progress in
            return progress * 360.0
        }
        ///设置边线
        let leftMaxX: CGFloat = 25 ; let rightMaxX: CGFloat = self.frame.size.width - 25
        
        context.saveGState()
        context.setLineWidth(1)
        context.setStrokeColor(UIColor.green.cgColor)
        context.setFillColor(UIColor.black.cgColor)
        var _startAngle = Double(self.startAngle)
        var endAngle = _startAngle
        for i in 0 ..< angleArr.count {
            endAngle = _startAngle + angleArr[i]
            var centerAngle = (_startAngle + endAngle)/2
            if centerAngle >= 360 {
                centerAngle = centerAngle - 360
            }
            centerAngle = Double.pi * centerAngle/180
            let p = CGPoint(x: center.x + self.radius * CGFloat(cos(centerAngle)), y: center.y + radius * CGFloat(sin(centerAngle)))
            
            context.fillEllipse(in: CGRect(x: p.x - 2, y: p.y - 2 , width: 4, height: 4))
            context.strokeEllipse(in: CGRect(x: p.x - 2, y: p.y - 2 , width: 4, height: 4))
            _startAngle = endAngle
            
            let p1 = CGPoint(x: center.x + (radius + 15) * CGFloat(cos(centerAngle)), y: center.y + (radius + 15) * CGFloat(sin(centerAngle)))
            // 画线
            context.move(to: p) ; context.addLine(to: p1)
            // 角度决定水平方向
            if (centerAngle >= 0 && centerAngle <= Double.pi/2) || (centerAngle >= 1.5 * Double.pi) {
                // 水平向右
                if p1.x <= rightMaxX {
                    let p2 = CGPoint(x: rightMaxX, y: p1.y)
                    context.addLine(to: p2)
                }else {
                    // 水平右边的距离不够
                    debugPrint("水平右边的距离不够")
                }
            } else {
                // 水平向左
                if p1.x >= leftMaxX {
                    let p2 = CGPoint(x: leftMaxX, y: p1.y)
                    context.addLine(to: p2)
                }else {
                     debugPrint("水平左边的距离不够")
                }
            }
            context.strokePath()
            
            if _startAngle >= 360 {
                _startAngle = _startAngle - 360
            }
            _startAngle = _startAngle * animation.phaseX
        }
        context.restoreGState()
    }
    

    和画扇形没什么太大的区别,主要是计算从扇形的中间位置画出来带着圆圈在。

    当看完Chars这段代码时,其实感觉也还好不是很难,但是源码真的很复杂,在作图这块,考虑的东西太多了,就如开始说到,很多用不到的功能要隐藏掉,需要设置好多参数。自己写的时候也是花了些时间去各种计算,数学很重要,着重强调。再提下源码整框架真心很不错,包括之前看的Alamofire,对功能模块的封装,模块之间的交互,抽象类的构建,接口的封装,真的值得学习和借鉴 。

    相关文章

      网友评论

        本文标题:Swift UI简单统计图绘制

        本文链接:https://www.haomeiwen.com/subject/dxqxwctx.html