天天看點

swift:打造你自己的折線圖

看到蘋果Health裡的折線圖了嗎。我們就是要打造一個這樣的折線圖。沒看過的請看下圖。

我們的主題在于折線圖本身。其他的包括步數、日平均值等描述類的内容這裡就不涉及了。

首先觀察,這個圖種包含些什麼組成部分。線?這個太明顯都看見了。還有每個節點的小圓圈,還有折線圖裡從上到下的漸變。這裡是白色的從上到下逐漸透明的效果。還有一條虛線。這個暫時先不考慮了。你能繪制出來最下面的x軸标尺,繪制個虛線還不是小菜?

為什麼說是繪制呢,因為顯然我們不想用一個UIView把像素設定為1,背景色設定為UIColor.whiteColor(),然後設定View 的傾斜度的方式來堆砌這個line chat。首先必須嚴重的鄙視這種做法。在開發中不能光是把各種UIButton、UILabel什麼的設定好了frame就網上沒完沒了的堆。或者更有 甚者直接拖動這些控件到Storyboard上。擺個位置,設定個寬和高别的就完全不管了。autolayout什麼的一概不問,使用了 storyboard也适配不了多分辨率。這樣的結果是誰維護代碼誰遭殃。

正确的做法是提升代碼。有多個地方都用到同樣的組合控件的時候,比如多選框、單選框,就自定義一個。這樣,至少可以達到一改全改的效果。代碼維護簡 單了很多。同時需要考慮效率的問題。比如我們的line chart,就使用Core Graphics和QuartzCore架構中的CAShapeLayer繪制。這樣執行效率明顯比堆砌UIView的方法效率高--占用資源少,執行 快。

看看CALayer的定義:

class CALayer : NSObject, NSCoding, CAMediaTiming      

再看看UIView的定義:

class UIView : UIResponder, NSCoding, UIAppearance, NSObjectProtocol, UIAppearanceContainer, UIDynamicItem, UITraitEnvironment, UICoordinateSpace      

你就應該知道為什麼完全不能用UIView來堆砌這個圖了。

言歸正傳!畫線可以用Core Graphics一點點的畫,也可以用CALayer來話,準确的說是CAShapeLayer更友善,是以我們用CAShapeLayer來畫線。用 CAShapeLayer畫線灰常之簡單。總的來說就是設定路線(Path),然後把這個路線指派給這個layer畫線就完成了。比如,初始化一條貝塞爾 曲線,然後指定好center point和半徑,起始角度和結束角度,然後“BANG”。“BANG”是一個象聲詞,龍珠裡很多。指定你的CAShapeLayer執行個體的path屬性 值為這個path。此處略去一堆什麼給你的view.layer.addsublayer什麼的細節。運作後你就會看到一個從起始角度到結束角度的一個半 圓。

運作起來之後,你會看到這個半圓和你需要的起始角度、結束角度差很多。是以,還是畫一個正圓比較容易一些。尤其現在我們才剛剛開始接觸這個神秘的東 東。等下還有更神秘的。。。要畫正圓隻要指定起始角度為0(這裡需要嚴重說明一下,角度都是弧度制的,比如,π、2π什麼的)。結束角度為2π,也就是(M_PI * 2)。半徑随便,圓心最好設定在螢幕的中心,也就是:

UIScreen.mainScreen().bounds.height / 2和UIScreen.mainScreen().bounds.width / 2。這樣就是在螢幕中心點,以你給定的值為半徑畫了一個圓圈。效果如圖:

給的貝塞爾曲線是這樣的:

UIBezierPath(arcCenter: centerPoint, radius: CGRectGetWidth(bounds) / 2 - 30.0, startAngle: 0, endAngle: CGFloat(M_PI * 2.0), clockwise: true).CGPath      

這裡需要注意的是一定要在最後調用屬性CGPath,這個才是CAShapeLayer可以接受的Path的類型。直接指派是會報錯的。在貝塞爾曲 線初始化的過程中角度值需要使用CGFloat類型。M_PI是Double類型的。這裡需要類型轉換一下。否則報錯會報在radius的身上,但是起始 是角度的類型問題。

圓是畫出來了,但是我們要繪制的是line chart,是直線。該如何解決呢。這裡就需要說明一下繪制線的一般感性認識。首先CAShapeLayer需要知道繪制的起始點在哪裡,其次,從哪一點 到哪一點繪制一條線。對于圓的貝塞爾曲線來說自然是從角度為0的,半徑長度和圓心來開始畫線,線一直延續到結束角度2π(PI)。對于一條直線就簡單多 了。起點是指定的一個點。然後,添加一條線到另一個點。來看看如何具體的用代碼畫一條線。

​​

swift:打造你自己的折線圖
var path = CGPathCreateMutable()
        var x = UIScreen.mainScreen().bounds.width / 2, y = UIScreen.mainScreen().bounds.height / 5
        CGPathMoveToPoint(path, nil, 0, y * 2)      

          CGPathAddLineToPoint(path, nil, 0, 0)

          CGPathAddLineToPoint(path, nil, x - kRadiusLength, 0)

CGPathAddLineToPoint(path, nil, bounds.size.width, bounds.size.height)

        progressLayer.path = path      
swift:打造你自己的折線圖
swift:打造你自己的折線圖

線就是這麼畫出來的。有線了以後就需要考慮另一個問題了,線下面的漸變色。這個就需要用到另一種Layer:CAGradientLayer。CAGradientLayer有一個屬性可以做到這一點,這個屬性就是colors。給這個屬性多少顔色,CAGradientLayer就會出現多少從一個顔色到另一個顔色的漸變。注意一點,這裡需要的顔色都是UIColor.yellowColor().CGColor。看到這個CGColor了嗎?一定要這個顔色才行。否則,不報錯,也不顯示任何的顔色!

代碼:

var gradientLayer2 = CAGradientLayer()
        gradientLayer2.startPoint = CGPointMake(0.5, 1.0)
        gradientLayer2.endPoint = CGPointMake(0.5, 0.0)
        gradientLayer2.frame = CGRectMake(0, 0, bounds.size.width, bounds.size.height)
        gradientLayer2.colors = [UIColor.yellowColor().CGColor, UIColor.blueColor().CGColor, UIColor.greenColor().CGColor]
        self.view.layer.addSublayer(gradientLayer2)      

這效果就出來了:

swift:打造你自己的折線圖

到這裡你應該就明白了。圖一種的白色到透明的漸變其實就是不同alpha的白色指派給了colors屬性。 

gradientLayer2.colors = [UIColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 0.0).CGColor,
            UIColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 0.5).CGColor,
            UIColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 0.8).CGColor]      
swift:打造你自己的折線圖

看效果,白色從上到下的漸變填充已經出來了。畫線前面已經講過。現在的問題就是讓這個填充按照畫得線剪裁。這個非常簡單。

我們來給上面的CAShapeLayer這樣的一個路線:

var path = CGPathCreateMutable()
        CGPathMoveToPoint(path, nil, 0, UIScreen.mainScreen().bounds.height)
        CGPathAddLineToPoint(path, nil, 0, 0)
        CGPathAddLineToPoint(path, nil, x - kRadiusLength, 0)
        CGPathAddLineToPoint(path, nil, bounds.size.width, bounds.size.height / 2)      

然後,就讓CAGradientLayer的mask屬性為這個CAShapeLayer。

gradientLayer.mask = progressLayer      

這樣一來。效果就出來了。

swift:打造你自己的折線圖

但是。。仔細一個,填充的漸變白色圖是有了,那麼線呢?白色的線沒有。CAShapeLayer的線最終都隻是成為CAGradientLayer的剪裁線。要解決這個問題就要上下面的重頭戲了。

為了解決這個問題,我們不得不祭出Core Graphics神器了。總體的構造思路是在Controller中添加一個View,在這個View中使用Core Graphics來畫線,之後在上面添加我們上文說到的兩個Layer。也就是下面畫線,然後用Layer來完成漸變色的填充和對這個填充色的剪裁。

Core Graphics畫線比CALayer還是麻煩一些的,但是思路總體上一緻。也是把畫筆放到起始點(在哪裡開始畫線)。之後也是從哪裡到哪裡畫線。總體來說,畫線的思路就是這樣。

首先,需要在Core Graphics中鋪上畫布:

var context = UIGraphicsGetCurrentContext()      

2. 指定線的顔色和線的寬度:

CGContextSetStrokeColorWithColor(context, UIColor.whiteColor().CGColor)
CGContextSetLineWidth(context, 1.0)      

3. 開始畫線:

CGContextMoveToPoint(context, kBottomMargin, CGRectGetHeight(rect) - kBottomMargin)
CGContextAddLineToPoint(context, CGRectGetWidth(rect) - kBottomMargin, CGRectGetHeight(rect) - kBottomMargin)      

這裡必須補充一點。在畫線的時候,我們需要一些列的點坐标。暫時,隻是用模拟的方式實作。var x = calculateX(0)和var y = calculateY(0)就是第一個點得x,y坐标的計算方法。具體的代碼在後面。這些給定的點需要映射到你的畫布的坐标系中。calculateX、Y就是做這個映射的。雖然省略了一些步驟。但是你應該可以從國中的數學基礎中明白這個是怎麼回事的,是以此處隻做解釋其他省略。

func calculateX(i: Int) -> CGFloat {
        var x = kBottomMargin + CGFloat(i) * kUnitLabelWidth!
        return x
    }      

kBottomMargin是x點在左側的一個margin。隻是展示需要,不用關心。 CGFloat(i) * kUnitLabelWidth!,i是第幾個點,也就是x軸上的index。kUnitLabelWidth!是x軸上兩點之間的距離,至于感歎号就不多解釋了,那個是swift的基礎。

swift:打造你自己的折線圖
func calculateY(i: Int) -> CGFloat {
        var y: CGFloat = 0
        switch(i){
        case 0:
            y = kTotalYValue! * 0.5
            break
        case 1:
            y = kTotalYValue! * 0.3
            break
        case 2:
            y = kTotalYValue! * 0.7
            break
        case 3:
            y = kTotalYValue! * 0.7
            break
        case 4:
            y = kTotalYValue! * 0.2
            break
        case 5:
            y = kTotalYValue! * 0.8
            break
        default:
            y = 0
            break
        }
        return y
    }      
swift:打造你自己的折線圖

這裡主要計算,每個x點對應的y點(這裡就摸你了y值對應在畫布坐标系的方法)。

有了以上的隻是就可以畫出折線圖了。具體的方法如下:

swift:打造你自己的折線圖
override func drawRect(rect: CGRect) {
        println("drawRect")

        var context = UIGraphicsGetCurrentContext()

//        CGContextSetStrokeColorWithColor(context, UIColor.blueColor().CGColor)
//        CGContextSetLineWidth(context, 4.0)
//        CGContextMoveToPoint(context, kBottomMargin, kBottomMargin)
//        CGContextAddLineToPoint(context, CGRectGetWidth(rect) - kBottomMargin, CGRectGetHeight(rect) - kBottomMargin)
//        CGContextStrokePath(context)

        CGContextSetStrokeColorWithColor(context, UIColor.whiteColor().CGColor)
        CGContextSetLineWidth(context, 1.0)
        CGContextMoveToPoint(context, kBottomMargin, CGRectGetHeight(rect) - kBottomMargin)
        CGContextAddLineToPoint(context, CGRectGetWidth(rect) - kBottomMargin, CGRectGetHeight(rect) - kBottomMargin)
//        CGContextStrokePath(context)

        CGContextSetFillColorWithColor(context, UIColor.orangeColor().CGColor)

        var x = calculateX(0)
        var y = calculateY(0)
        var prePoint: CGPoint = CGPointMake(x, y)

        for var index = 0; index < 6; index++ {
            var x = calculateX(index)
            var y = calculateY(index)
            var textY = CGRectGetHeight(rect) - kBottomMargin + 3

            CGContextMoveToPoint(context, x, CGRectGetHeight(rect) - kBottomMargin)
            CGContextAddLineToPoint(context, x, CGRectGetHeight(rect) - kBottomMargin + kUnitLabelHeight)

            var labelString = NSString(string: "\(kBaseLabelString) \(index)")
            labelString.drawAtPoint(CGPointMake(x + kUnitLabelHeight, textY), withAttributes: [NSFontAttributeName: kLabelFont, NSForegroundColorAttributeName: kLabelFontColor])

            CGContextStrokePath(context)

            CGContextMoveToPoint(context, x, y)
//            CGContextSetLineWidth(context, 2.0)
            var path = UIBezierPath(arcCenter: CGPointMake(x, y), radius: kCircleRadiusLength, startAngle: CGFloat(0.0)
                , endAngle: CGFloat(2 * M_PI), clockwise: true)
            CGContextAddPath(context, path.CGPath)
//            CGContextFillPath(context)

            CGContextStrokePath(context)

//            var offset: CGFloat = kCircleRadiusLength * CGFloat(sin(M_PI_4))
            var offset = calculateOffset(prePoint.x, prePoint.y, x, y, kCircleRadiusLength)
            if prePoint.x != x /*&& prePoint.y != y*/ {

                if y > prePoint.y {
                    CGContextMoveToPoint(context, prePoint.x + offset.offsetX, prePoint.y + offset.offsetY)
                    CGContextAddLineToPoint(context, x - offset.offsetX, y - offset.offsetY)
                }
                else if y < prePoint.y {
                    CGContextMoveToPoint(context, prePoint.x + offset.offsetX, prePoint.y - offset.offsetY)
                    CGContextAddLineToPoint(context, x - offset.offsetX, y + offset.offsetY)
                }
                else{
                    CGContextMoveToPoint(context, prePoint.x + offset.offsetX, prePoint.y)
                    CGContextAddLineToPoint(context, x - offset.offsetX, y)
                }

                CGContextStrokePath(context)

                prePoint = CGPointMake(x, y)
            }
        }

//        CGContextMoveToPoint(context, x, y)
        CGContextSetLineWidth(context, 3)
        CGContextSetStrokeColorWithColor(context, UIColor.greenColor().CGColor)
        CGContextSetFillColorWithColor(context, UIColor.blueColor().CGColor)
        var circleRect = CGRectMake(x, y, 15, 15)
        circleRect = CGRectInset(circleRect, 3, 3)
        CGContextFillEllipseInRect(context, circleRect)
        CGContextStrokeEllipseInRect(context, circleRect)
    }      
swift:打造你自己的折線圖

這一段代碼:

var path = UIBezierPath(arcCenter: CGPointMake(x, y), radius: kCircleRadiusLength, startAngle: CGFloat(0.0)
                , endAngle: CGFloat(2 * M_PI), clockwise: true)
            CGContextAddPath(context, path.CGPath)      

就是用來在各條線之間畫圓圈的。

以幾乎略有不同的算法可以在calayer上繪制出CAGradientLayer的mask路線。也就是在core graphics裡畫得白線和在紙上鋪上去的mask以後的gradient layer可以嚴絲合縫的組合在一起。這是看起來才能和蘋果的health app一樣的效果。這裡需要說明,在添加了圓圈之後,每次畫線的時候需要考慮要把線縮短。如果直接按照原來的方式的話,會優先穿過圓圈。