圖表CanvasChartView(一):Canvas繪制

前言

愉快的五一假期說沒就沒届谈,又到了上班的日子徙融,今天不是特別忙,趕緊寫點什么涡戳。

前一陣都在看源碼了浪规,看的頭昏腦脹或听,突然想起來去年的時候,以前的同事問我對于類似天氣這種app笋婿,那種溫度圖表是怎么做的誉裆,當(dāng)時很忙,就直接讓他找開源庫用一下缸濒,今天就來自己寫一個足丢。

正文

說道比較火的天氣app粱腻,我想到的就是墨跡天氣了,先貼張圖:

image

我們研究的主題就是這中間的這兩條線斩跌,再最終的效果上绍些,我們會慢慢向他靠近。

首先我們來簡單分析一下我們需要哪些準(zhǔn)備工作:

  1. 肯定要有一只畫筆Paint
  2. 曲線的顏色和寬度
  3. 文字的顏色和寬度
  4. 每一個數(shù)據(jù)之間有虛線耀鸦,虛線的顏色和寬度
  5. 每一個數(shù)據(jù)有圓點柬批,圓點的顏色和半徑
  6. 繪制了多條曲線,所以保存曲線的事List或者是Set袖订,我覺得list這里更合適氮帐。
  7. 準(zhǔn)備一個數(shù)據(jù)適配器DataAdapter, 用來處理和刷新數(shù)據(jù)

繪制主要分為兩步:

  1. 繪制坐標(biāo)軸;
  2. 繪制數(shù)據(jù)曲線
 * Created by li.zhipeng on 2018/5/2.
 */
class CanvasChartView(context: Context, attributes: AttributeSet?, defStyleAttr: Int)
    : View(context, attributes, defStyleAttr) {

    constructor(context: Context, attributes: AttributeSet?) : this(context, attributes, 0)

    constructor(context: Context) : this(context, null)

    /**
     * 畫筆
     *
     * 設(shè)置抗鋸齒和防抖動
     * */
    private val paint: Paint by lazy {
        val field = Paint()
        field.isAntiAlias = true
        field.isDither = true
        field
    }

    /**
     * 繪制X軸和Y軸的顏色
     *
     *  默認(rèn)是系統(tǒng)自帶的藍(lán)色
     * */
    var lineColor: Int = Color.BLUE

    /**
     * 繪制X軸和Y軸的寬度
     * */
    var lineWidth = 5f

    /**
     * 圖表的顏色
     * */
    var chartLineColor: Int = Color.RED

    /**
     * 圖表的寬度
     * */
    var chartLineWidth: Float = 3f

    /**
     * 圓點的寬度
     * */
    var dotWidth = 15f

    /**
     * 圓點的顏色
     * */
    var dotColor: Int = Color.BLACK

    /**
     * 虛線的顏色
     * */
    var dashLineColor: Int = Color.GRAY

    /**
     * 虛線的顏色
     * */
    var dashLineWidth: Float = 2f

    /**
     * x軸的刻度間隔
     *
     * 因為x周是可以滑動的洛姑,所以只有刻度的數(shù)量這一個屬性
     * */
    var xLineMarkCount: Int = 5

    /**
     * y軸的最大刻度
     * */
    var yLineMax: Int = 100

    /**
     * 繪制文字的大小
     * */
    var textSize: Float = 40f

    /**
     * 繪制文字的顏色
     * */
    var textColor: Int = Color.BLACK

    /**
     * 文字和圓點之間的間距
     * */
    var textSpace: Int = 0

    /**
     * 數(shù)據(jù)適配器
     * */
    var adapter: BaseDataAdapter? = null
        set(value) {
            field = value
            invalidate()
            value?.addObserver { _, _ ->
                // 當(dāng)數(shù)據(jù)發(fā)生改變的時候上沐,立刻重繪
                invalidate()
            }
        }
 override fun onDraw(canvas: Canvas) {super.onDraw(canvas)
      // 繪制X軸和Y軸
      drawXYLine(canvas)
      // 繪制數(shù)據(jù)
      drawData(canvas)</pre>
 }

基本的變量都已經(jīng)準(zhǔn)備完畢了, 并且在onDraw方法里預(yù)先創(chuàng)建了繪制坐標(biāo)軸和數(shù)據(jù)曲線的方法楞艾,我們先從簡單畫起参咙,例如先畫坐標(biāo)軸:

/**
     * 繪制X軸和Y軸
     *
     * x軸位于中心位置,值為0
     * y軸位于最最左邊硫眯,與x軸交叉蕴侧,交叉點為0
     * */
    private fun drawXYLine(canvas: Canvas) {
        // 設(shè)置顏色和寬度
        paint.color = lineColor
        paint.strokeWidth = lineWidth
        paint.style = Paint.Style.STROKE
        drawXLine(canvas)
        drawYLine(canvas)
    }

    /**
     * 畫X軸
     * */
    private fun drawXLine(canvas: Canvas) {
        val width = width.toFloat()
        // 計算y方向上的中心位置
        val yCenter = (height - lineWidth) / 2
        // 繪制X軸
        canvas.drawLine(0f, yCenter, width, yCenter, paint)
    }

    /**
     * 畫Y軸
     * */
    private fun drawYLine(canvas: Canvas) {
        // 計算一下Y軸的偏移值
        val offsetY = lineWidth / 2
        // 繪制Y軸
        canvas.drawLine(offsetY, 0f, offsetY, height.toFloat(), paint)
        // 繪制每一條數(shù)據(jù)之間的間隔虛線
        drawDashLine(canvas)
    }

    /**
     * 繪制數(shù)據(jù)之間
     * */
    private fun drawDashLine(canvas: Canvas) {
        // 畫條目之間的間隔虛線
        var index = 1
        // 通過x軸的刻度數(shù)量,計算x軸坐標(biāo)
        val xItemSpace = width / xLineMarkCount.toFloat()
        paint.color = dashLineColor
        paint.strokeWidth = dashLineWidth
        paint.pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 1f)
        while (index < xLineMarkCount) {
            val startY = xItemSpace * index
            val path = Path()
            path.moveTo(startY, 0f)
            path.lineTo(startY, height.toFloat())
            canvas.drawPath(path, paint)
            index++
        }
    }

繪制坐標(biāo)軸算是最簡單的事情了舟铜,但是仍然有幾點需要注意:

  • X軸在高度的最中間戈盈,Paint在繪制邊框Border的時候,會以坐標(biāo)為準(zhǔn)谆刨,左右同時變粗塘娶,所以要減去Border的寬度的一半;
  • Y軸同理痊夭,在x方向上偏移Border的寬度的一半刁岸,否則你會發(fā)現(xiàn)線會比設(shè)置的要細(xì)。
  • 繪制Path的時候注意:設(shè)置Paint.style = Paint.Style.STROKE她我,否則繪制的線條有問題

虛線是數(shù)據(jù)之間的間隔虹曙,所以最后一條不需要畫。

繪制完虛線番舆,就可以直接繪制數(shù)據(jù)曲線了酝碳,之前我們創(chuàng)建了DataAdapter,在里面設(shè)置和保存數(shù)據(jù)恨狈,看一下代碼:

/**
 * Created by li.zhipeng on 2018/5/2.
 *
 *      圖標(biāo)的數(shù)據(jù)適配器
 */
class BaseDataAdapter : Observable() {

    /**
     * 保存數(shù)據(jù)
     * */
    private val dataList: ArrayList<List<Int>> = ArrayList()

    /**
     * 添加數(shù)據(jù)
     * */
    fun addData(data: List<Int>) {
        dataList.add(data)
        notifyDataSetChanged()
    }

    fun removeAt(index: Int) {
        dataList.removeAt(index)
        notifyDataSetChanged()
    }

    fun remove(data: List<Int>) {
        dataList.remove(data)
        notifyDataSetChanged()
    }

    fun getData(): ArrayList<List<Int>> = dataList

    fun notifyDataSetChanged() {
        setChanged()
        notifyObservers()
    }
}

非常的簡單疏哗,因為要繪制多條曲線,所以是addData禾怠,還有刪除remove方法返奉,notifyDataSetChanged()當(dāng)數(shù)據(jù)發(fā)生改變的時候贝搁,通知View刷新,回顧一下View的代碼:

/**
     * 數(shù)據(jù)適配器
     * */
    var adapter: BaseDataAdapter? = null
        set(value) {
            field = value
            invalidate()
            value?.addObserver { _, _ ->
                // 當(dāng)數(shù)據(jù)發(fā)生改變的時候芽偏,立刻重繪
                invalidate()
            }
        }

這里使用了一個系統(tǒng)自帶的觀察者模式雷逆,當(dāng)adapter中的數(shù)據(jù)發(fā)生改變了,View進(jìn)行重繪污尉。

最后是接下來就是最重要的繪制數(shù)據(jù)曲線了膀哲,現(xiàn)在我們要去完善之前定義好的drawData方法:

/**
     * 繪制數(shù)據(jù)曲線
     * */
    private fun drawData(canvas: Canvas) {
        // 設(shè)置畫筆樣式
        paint.pathEffect = null
        // 得到數(shù)據(jù)列表, 如果是null,取消繪制
        val dataList = adapter?.getData() ?: return
        // 繪制每一條數(shù)據(jù)列表
        for (item in dataList) {
            drawItemData(canvas, item)
        }
    }

    /**
     * 繪制一條數(shù)據(jù)曲線
     * */
    private fun drawItemData(canvas: Canvas, data: List<ChartBean>) {
        // 通過x軸的刻度間隔十厢,計算x軸坐標(biāo)
        val xItemSpace = width / xLineMarkCount
        val path = Path()
        val dotPath = Path()
        for ((index, item) in data.withIndex()) {
            // 計算每一個點的位置
            val xPos = (xItemSpace / 2 + index * xItemSpace).toFloat()
            val yPos = calculateYPosition(item)
            if (index == 0) {
                path.moveTo(xPos, yPos)
            } else {
                path.lineTo(xPos, yPos)
            }
            dotPath.addCircle(xPos, yPos, dotWidth, Path.Direction.CW) // 保存圓點的坐標(biāo)信息
            // 繪制文字
            drawText(canvas, item, xPos, yPos)
        }
        // 繪制曲線
        paint.style = Paint.Style.STROKE
        paint.color = chartLineColor
        paint.strokeWidth = chartLineWidth
        canvas.drawPath(path, paint)
        // 繪制圓點
        paint.color = dotColor
        paint.style = Paint.Style.FILL
        canvas.drawPath(dotPath, paint)
    }

    /**
     * 計算每一個數(shù)據(jù)點在Y軸上的坐標(biāo)
     * */
    private fun calculateYPosition(value: ChartBean): Float {
        // 計算比例
        val scale = value.number / yLineMax
        // 計算y方向上的中心位置
        val yCenter = (height - lineWidth) / 2
        // 如果小于0
        return yCenter - yCenter * scale
    }

    /**
     * 繪制文字
     * */
    private fun drawText(canvas: Canvas, item: ChartBean, xPos: Float, yPos: Float) {
        val text = item.text
        paint.textSize = textSize
        paint.color = textColor
        paint.style = Paint.Style.FILL
        val textWidth = paint.measureText(text)
        val fontMetrics = paint.fontMetrics
   // 文字自帶的間距等太,不理解的可以查一下:如何繪制文字居中
      val offset = fontMetrics.ascent + (fontMetrics.ascent - fontMetrics.top)
      if (item.number > 0) {
           // 要把文字自帶的間距減去,統(tǒng)一和圓點之間的間距
          canvas.drawText(text, xPos - textWidth / 2, yPos - dotWidth - fontMetrics.descent - textSpace, paint)
      } else {
          // 要把文字自帶的間距減去蛮放,統(tǒng)一和圓點之間的間距
          canvas.drawText(text, xPos - textWidth / 2, yPos + dotWidth  - offset + textSpace, paint)
      }
}

繪制曲線有幾點注意的地方:

  • 因為之前畫的是虛線,所以我們先把虛線的效果去掉奠宜,Paint.pathEffect = null;
  • 因為圓點是在曲線的上面包颁,所以創(chuàng)建了兩個Path,分別保存Path路徑压真,并且先繪制了曲線后繪制圓點娩嚼;

重點說明一下文字繪制的部分:

文字的繪制一直是比較蛋疼的問題,網(wǎng)上相關(guān)的資料也有很多滴肿,我在這里簡單的做一個總結(jié)岳悟,我們設(shè)置的canvas.drawText()中的坐標(biāo),實際上是繪制文字的基線的坐標(biāo)泼差,我直接從別處截了一張圖:

image

仔細(xì)觀察上圖文字區(qū)域贵少,我們會發(fā)現(xiàn)文字區(qū)域中有5條顏色不同的線。按著從上到下的順序堆缘,他們的名字分別是:

top:淺灰色

ascent:黃色

baseline:紅色

descent:藍(lán)色

bottom:綠色

這5條線的值是以baseline為基準(zhǔn)的滔灶,baseline等于0, baseline上面的線是負(fù)數(shù)吼肥,baseline下面的線是正數(shù)录平。

當(dāng)數(shù)據(jù)在標(biāo)準(zhǔn)線(x軸y軸交叉點)以上時:

yPos - dotWidth // 得到的是繪制文字的基線

基線就是紅線,所以我們的文字缀皱,例如“g”這個字母的小尾巴正好被擋住了斗这,所以我們要把文字向上偏移descent的距離。textSpace是我們自定義的間距啤斗,這里就直接忽略了

// 要把文字自帶的間距減去表箭,統(tǒng)一和圓點之間的間距
canvas.drawText(text, xPos - textWidth / 2, yPos - dotWidth - fontMetrics.descent - textSpace, paint)

當(dāng)數(shù)據(jù)在標(biāo)準(zhǔn)線(x軸y軸交叉點)以下時:

// 文字自帶的間距,不理解的可以查一下:如何繪制文字居中
val offset = fontMetrics.ascent + (fontMetrics.ascent - fontMetrics.top)
canvas.drawText(text, xPos - textWidth / 2, yPos + dotWidth  - offset + textSpace, paint)

我們設(shè)置的基線正好是圓點的底部争占,所以我們要偏移accent的距離燃逻,再加上accent和top之間的距離序目。

最后就是一張效果圖了:

image

總結(jié)

今天的內(nèi)容就到此為止了,我們完成了基本的繪制功能伯襟,下一篇我們來增加手勢滑動的功能猿涨。

補(bǔ)充

竟然忘記把源碼鏈接發(fā)出來了,趕緊補(bǔ)上https://github.com/li504799868/CanvasChart/tree/7d7caeb0e565d785249aceb6a6ff94358bd12e19姆怪。

代碼更新速度比內(nèi)容要快叛赚,所以會有些不同,但是核心功能沒變稽揭。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末俺附,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子溪掀,更是在濱河造成了極大的恐慌事镣,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,941評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件揪胃,死亡現(xiàn)場離奇詭異璃哟,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)喊递,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,397評論 3 395
  • 文/潘曉璐 我一進(jìn)店門随闪,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人骚勘,你說我怎么就攤上這事铐伴。” “怎么了俏讹?”我有些...
    開封第一講書人閱讀 165,345評論 0 356
  • 文/不壞的土叔 我叫張陵当宴,是天一觀的道長。 經(jīng)常有香客問我藐石,道長即供,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,851評論 1 295
  • 正文 為了忘掉前任于微,我火速辦了婚禮逗嫡,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘株依。我一直安慰自己驱证,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 67,868評論 6 392
  • 文/花漫 我一把揭開白布恋腕。 她就那樣靜靜地躺著抹锄,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上伙单,一...
    開封第一講書人閱讀 51,688評論 1 305
  • 那天获高,我揣著相機(jī)與錄音,去河邊找鬼吻育。 笑死念秧,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的布疼。 我是一名探鬼主播摊趾,決...
    沈念sama閱讀 40,414評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼游两!你這毒婦竟也來了砾层?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,319評論 0 276
  • 序言:老撾萬榮一對情侶失蹤贱案,失蹤者是張志新(化名)和其女友劉穎肛炮,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體宝踪,經(jīng)...
    沈念sama閱讀 45,775評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡铸董,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,945評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了肴沫。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,096評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡蕴忆,死狀恐怖颤芬,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情套鹅,我是刑警寧澤站蝠,帶...
    沈念sama閱讀 35,789評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站卓鹿,受9級特大地震影響菱魔,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜吟孙,卻給世界環(huán)境...
    茶點故事閱讀 41,437評論 3 331
  • 文/蒙蒙 一澜倦、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧杰妓,春花似錦藻治、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,993評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春雏节,著一層夾襖步出監(jiān)牢的瞬間胜嗓,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,107評論 1 271
  • 我被黑心中介騙來泰國打工钩乍, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留辞州,地道東北人。 一個月前我還...
    沈念sama閱讀 48,308評論 3 372
  • 正文 我出身青樓件蚕,卻偏偏與公主長得像孙技,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子排作,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,037評論 2 355

推薦閱讀更多精彩內(nèi)容