看到蘋果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)。對於一條直線就簡單多了。起點是指定的一個點。然后,添加一條線到另一個點。來看看如何具體的用代碼畫一條線。
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
線就是這么畫出來的。有線了以后就需要考慮另一個問題了,線下面的漸變色。這個就需要用到另一種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)
這效果就出來了:
到這里你應該就明白了。圖一種的白色到透明的漸變其實就是不同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]
看效果,白色從上到下的漸變填充已經出來了。畫線前面已經講過。現在的問題就是讓這個填充按照畫得線剪裁。這個非常簡單。
我們來給上面的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
這樣一來。效果就出來了。
但是。。仔細一個,填充的漸變白色圖是有了,那么線呢?白色的線沒有。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的基礎。
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 }
這里主要計算,每個x點對應的y點(這里就摸你了y值對應在畫布坐標系的方法)。
有了以上的只是就可以畫出折線圖了。具體的方法如下:
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) }
這一段代碼:
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一樣的效果。這里需要說明,在添加了圓圈之后,每次畫線的時候需要考慮要把線縮短。如果直接按照原來的方式的話,會優先穿過圓圈。