基於 HTML5 Canvas 的拓撲組件開發

在現在前端圈大行其道的 React 和 Vue 中,可複用的組件可能是他們大受歡迎的原因之一,

在 HT 的產品中也有組件的概念,不過在 HT 中組件的開發是依託於 HTML5 Canvas 的技術去實現的,

也就是說如果你有過使用 Canvas 的開發經驗你就可以來封裝自己的組件。

下面我以一個進度環爲例,來探究一下如何使用ht.js封裝出一個拓撲組件。

效果圖

1

代碼實現

前置知識

自定義組件

除了HT預定義的組件類型外,用戶還可以自定義擴展類型,自定義有兩種方式:

  • 直接將type值設置成繪製函數:function(g, rect, comp, data, view){}
  • 通過ht.Default.setCompType(name, funtion(g, rect, comp, data, view){})註冊組件類型,矢量type值設置成相應的註冊名

在這裏我選用第一種通過形如

ht.Default.setImage('circle-progress-bar', {
    width: 100,
    height: 100,
    comps: [
        {
            type: function(g, rect, comp, data, view) {
                // ...
            }
        }
    ]
});

這樣的方式完成組件的聲明,那麼 function(g, rect, comp, data, view) { }中的內容就是我們接下來需要關注的了

準備工作

  1. 抽象並聲明出幾個 Coding 中需要的變量

    • 進度百分比 progressPercentage {百分比}
    • 圓環漸變色 linearOuter {顏色數組}
    • 內圓漸變色 linearInner {顏色數組}
    • 字體縮放比例 fontScale {數字}
    • 顯示原始值 showOrigin {布爾}
    • 進度條樣式 progressLineCap {線帽樣式}
  2. 變量的聲明和賦值了

    var x = rect.x;
    var y = rect.y;
    var rectWidth = rect.width;
    var rectHeight = rect.height;
    var width = rectWidth < rectHeight ? rectWidth : rectHeight;
    var progressPercentage = parseFloat((data.a('progressPercentage') * 100).toFixed(10));
    var fontScale = data.a('fontScale');
    var showOrigin = data.a('showOrigin');
    var backgroundColor = data.a('backgroundColor');
    var progressLineCap = data.a('progressLineCap');
    var fontSize = 16; // 字體大小
    var posX = x + rectWidth / 2; // 圓心 x 座標
    var posY = y + rectHeight / 2; // 圓心 y 座標
    var circleLineWidth = width / 10; // 圓環線寬
    var circleRadius = (width - circleLineWidth) / 2; // 圓環半徑
    var circleAngle = {sAngle: 0, eAngle: 2 * Math.PI}; // 繪製背景圓和圓環內圓所需的角度
    var proStartAngel = Math.PI; // 進度環起始角度
    var proEndAngel = proStartAngel + ((Math.PI * 2) / 100) * progressPercentage; // 進度環結束角度
  3. 創建漸變色樣式

    var grd = context.createLinearGradient(x1, y1, x2, y2);
    grd.addColorStop(0, 'red');   
    grd.addColorStop(1, 'blue');

    在 Canvas 中的漸變色是按照如上方式來創建的,但是在一個組件中去如果一個一個去添加顯然是去組件的理念是背道而馳的,所以我選擇封裝一個函數根據顏色數組中的各個顏色來生成漸變色樣式

    // 創建漸變色樣式函數
    function addCreateLinear(colorsArr) {
        var linear = rectWidth < rectHeight
            ? g.createLinearGradient(x, posY - width / 2, width, posY + width / 2)
            : g.createLinearGradient(posX - width / 2, y, posX + width / 2, width);
        var len = colorsArr.length;
        for (var key in colorsArr) {
            linear.addColorStop((+key + 1) / len, colorsArr[key]);
        }
        return linear;
    }
    // 創建漸變填充顏色
    var linearOuter = addCreateLinear(data.a('linearOuter'));
    var linearInner = addCreateLinear(data.a('linearInner'));

開始 Coding

準備工作結束後下面就是 Canvas 的時間了

  1. 繪製背景圓

    g.beginPath();
    g.arc(posX, posY, circleRadius, circleAngle.sAngle, circleAngle.eAngle);
    g.closePath();
    g.fillStyle = backgroundColor;
    g.fill();
    g.lineWidth = circleLineWidth;
    g.strokeStyle = backgroundColor;
    g.stroke();

    2

  2. 繪製進度環

    g.beginPath();
    g.arc(posX, posY, circleRadius, proStartAngel, proEndAngel);
    g.strokeStyle = linearOuter;
    g.lineWidth = circleLineWidth;
    g.lineCap = progressLineCap;
    if (progressPercentage !== 0) g.stroke();

    3

  3. 繪製中心圓

    g.beginPath();
    g.fillStyle = linearInner;
    g.arc(posX, posY, circleRadius - circleLineWidth / 2 - 1, 0, Math.PI * 2, false);
    g.strokeStyle = '#0A2E44';
    g.fill();
    g.lineWidth = 2;
    g.stroke();

    4

  4. 繪製文字

    g.fillStyle = 'white';
    g.textAlign = 'center';
    g.font = fontSize + 'px Arial';
    g.translate(posX * (1 - fontScale), posY * (1 - fontScale));
    g.scale(fontScale, fontScale);
    showOrigin
        ? g.fillText(progressPercentage / 100, posX, posY + fontSize / 3)
        : g.fillText(progressPercentage + '%', posX, posY + fontSize / 3);

    5

    最後通過簡單的配置就可以在網頁上呈現出這個進度環了

    var dataModel = new ht.DataModel();
    var graphView = new ht.graph.GraphView(dataModel);
    var circle1 = new ht.Node();
    circle1.setPosition(150, 150);
    circle1.setSize(200, 200);
    circle1.setImage('circle-progress-bar');
    circle1.a({
        progressPercentage: 0.48,
        linearOuter: ['#26a67b', '#0474d6'],
        linearInner: ['#004e92', '#000000'],
        fontScale: 1,
        showOrigin: true,
        progressLineCap: 'butt',
        backgroundColor: 'rgb(61,61,61)'
    });
    dataModel.add(circle1);
    // 這次多生成幾個 不過代碼相似 在此就不贅述了

    6

    完整代碼如下

    ht.Default.setImage('circle-progress-bar', {
        width: 100,
        height: 100,
        comps: [
            {
                type: function(g, rect, comp, data, view) {
                    // 獲取屬性值
                    var x = rect.x;
                    var y = rect.y;
                    var rectWidth = rect.width;
                    var rectHeight = rect.height;
                    var width = rectWidth < rectHeight ? rectWidth : rectHeight;
                    var progressPercentage = parseFloat((data.a('progressPercentage') * 100).toFixed(10));
                    var fontScale = data.a('fontScale');
                    var showOrigin = data.a('showOrigin');
                    var backgroundColor = data.a('backgroundColor');
                    var progressLineCap = data.a('progressLineCap');
                    var fontSize = 16;
    
                    // 定義屬性值
                    var posX = x + rectWidth / 2;
                    var posY = y + rectHeight / 2;
                    var circleLineWidth = width / 10;
                    var circleRadius = (width - circleLineWidth) / 2;
                    var circleAngle = {
                        sAngle: 0,
                        eAngle: 2 * Math.PI
                    };
                    var proStartAngel = Math.PI;
                    var proEndAngel = proStartAngel + ((Math.PI * 2) / 100) * progressPercentage;
    
                    // 創建漸變背景色
                    function addCreateLinear(colorsArr) {
                        var linear = rectWidth < rectHeight ? g.createLinearGradient(x, posY - width / 2, width, posY + width / 2) : g.createLinearGradient(posX - width / 2, y, posX + width / 2, width);
                        var len = colorsArr.length;
                        colorsArr.forEach(function(item, index) {
                            linear.addColorStop((index + 1) / len, item);
                        });
                        return linear;
                    }
                    // 創建漸變填充顏色
                    var linearOuter = addCreateLinear(data.a('linearOuter'));
                    var linearInner = addCreateLinear(data.a('linearInner'));
    
                    // 0.保存繪製前狀態
                    g.save();
    
                    // 1.背景圓
                    g.beginPath();
                    g.arc(posX, posY, circleRadius, circleAngle.sAngle, circleAngle.eAngle);
                    g.closePath();
                    g.fillStyle = backgroundColor;
                    g.fill();
                    g.lineWidth = circleLineWidth;
                    g.strokeStyle = backgroundColor;
                    g.stroke();
    
                    // 2.進度環
                    g.beginPath();
                    g.arc(posX, posY, circleRadius, proStartAngel, proEndAngel);
                    g.strokeStyle = linearOuter;
                    g.lineWidth = circleLineWidth;
                    g.lineCap = progressLineCap;
                    if (progressPercentage !== 0) g.stroke();
    
                    // 3.繪製中心圓
                    g.beginPath();
                    g.fillStyle = linearInner;
                    g.arc(posX, posY, circleRadius - circleLineWidth / 2 - 1, 0, Math.PI * 2, false);
                    g.strokeStyle = '#0A2E44';
                    g.fill();
                    g.lineWidth = 2;
                    g.stroke();
    
                    // 4.繪製文字
                    g.fillStyle = 'white';
                    g.textAlign = 'center';
                    g.font = fontSize + 'px Arial';
                    g.translate(posX * (1 - fontScale), posY * (1 - fontScale));
                    g.scale(fontScale, fontScale);
                    showOrigin ? g.fillText(progressPercentage / 100, posX, posY + fontSize / 3) : g.fillText(progressPercentage + '%', posX, posY + fontSize / 3);
    
                    // 5.恢復繪製前狀態
                    g.restore();
                }
            }
        ]
    });

幾點心得

聲明屬性

在這個部分有幾點可供參考

  • 使用小駝峯對屬性進行命名,並且少用縮寫儘量語義化

    舉個栗子:

    • fontScale 字體縮放比例
    • progressPercentage 進度百分比
  • 屬性值類型的選擇也要儘量貼合屬性的含義

    舉個栗子:

    • 一個存儲着幾個顏色值字符串的數組,用顏色數組就比單純的數組更爲貼切
    • 一個表示畫筆線帽種類的字符串,用線帽樣式就比字符轉更爲貼切

使用屬性

由於進度環是一個圓形的組件,那麼在這裏有兩點供參考

  • 當組件的 rect.widthrect.height 不相等的時候我們需要自己來設定一個 width,

    讓圓在這個以 width 爲邊的正方形中繪製,而 width 的值就是 rect.widthrect.height 中較短的一邊,

    而這麼做的理由是這樣繪製圓自適應性能力會更好,並且圓心也直會在 (rect.width/2, rect.height/2)這一點上。

    var rectWidth = rect.width;
    var rectHeight = rect.height;
    var width = rectWidth < rectHeight ? rectWidth : rectHeight;
  • 由於我們自己設定了一個 width ,那麼在設置漸變顏色的參數上就需要注意一下了。

    當 rect.width 不等於 rect.height 的時候。

    如果按照 g.createLinearGradient(0, 0, rect.width, rect.height) 設置漸變色就會出現下面的效果,右下方的藍色不見了。

7

不過如果按照如下代碼的方式設置漸變色就會出現下面的效果就會出現預期的效果了。

var posX = rectWidth / 2;
var posY = rectHeight / 2;
var linear = rectWidth < rectHeight
        ? g.createLinearGradient(0, posY - width / 2, width, posY + width / 2)
        : g.createLinearGradient(posX - width / 2, 0, posX + width / 2, width);

8

原因其實很簡單,就是漸變顏色方向的起點和終點並沒有隨着 width 的改變而改變。

如圖所示以rectWidth > rectHeight 爲例

10

繪製組件

在繪製組件的過程中,我們需要把一些邊界條件和特殊情況考慮到,來保持組件的擴展性和穩定性

下面就是一些我的心得

  • 在做了 g 操作的頭尾分別使用 saverestore ,以此來保障 g 操作的不影響後續的擴展開發。

    g.save()
    // g 操作
    // ...
    // ...
    g.restore()
    save/restore

    設想一下,我們正在用 10 像素寬,顏色爲紅色的筆畫圖,然後把畫筆設置成1像素寬,顏色變成綠色。綠色畫完之後呢,我們想接着用10像素的紅色來畫,如果沒有 save 與 restore,那我們就不得不重新設置一遍畫筆——如果畫筆狀態過多,那我們的代碼就會大量增加;而且,這些設置過程是重複而乏味的。

    最後保存的最先還原!restore 總是還原離他最近的 save 點(已經還原的不能第2次還原到他)。

    另外 save 和 restore 一般是改變了 transform 或 clip 才需要,大部分情況下不需要,例如你設置了顏色、寬度等等參數,下次要繪製這些的人會自己再設置這些,所以能儘量不用 save/restore 的地方可以儘量不用,那也是有代價的

  • 當進度值爲 0 且 線帽樣式爲圓角的時候進度環會變成一個圓點,正確的做法使需要對進度值爲 0 的時候進行特殊處理。

    // 進度環
    g.beginPath();
    g.arc(posX, posY, circleRadius, proStartAngel, proEndAngel);
    g.strokeStyle = linearOuter;
    g.lineCap = progressLineCap;
    if (progressPercentage !== 0) g.stroke();
  • 由於 Chrome 瀏覽器的限制(Chrome 顯示最小字體爲 12 px),所以不能通過 12px這樣的數值設定文字大小,只能通過縮放來控制文字的大小了。

    當你高高興興的的使用 scale 對文字進行縮放的時候

    var fontScale = 0.75
    g.fillStyle = 'white';
    g.textAlign = 'center';
    g.font = fontSize + 'px Arial';
    g.scale(fontScale, fontScale);
    g.fillText(progressPercentage + '%', posX, posY + fontSize / 3);

    你會得到這樣的結果

    10

造成這個結果的原因是 scale 操作的參考點位置不對

下面我們使用矩形的例子詳細解釋一下

// 原矩形
ctx.save();
ctx.beginPath();
ctx.strokeRect(0, 0, 400, 400);
ctx.restore();
// 縮放後的矩形
ctx.save();
ctx.beginPath();
ctx.scale(0.75, 0.75);
ctx.strokeRect(0, 0, 400, 400);
ctx.restore();

11

這時scale的參考點是(0,0)所以,中心縮放沒有按照我們預期的進行

當修改參考點的座標爲(50,50)之後,中心縮放就正常了

12

那麼這個(50,50)是怎麼得來的?

根據上圖我們不難看出這個距離其實就是 (縮放前的邊長 - 縮放後的邊長) / 2得到得

公式就是 width * (1 - scale) / 2

在這個例子中套用一下就是 400 * (1 - 0.75) / 2 = 50

// 原矩形
ctx.save();
ctx.beginPath();
ctx.strokeRect(0, 0, 400, 400);
ctx.restore();
// 縮放後的矩形
ctx.save();
ctx.beginPath();
ctx.translate(50, 50)
ctx.scale(0.75, 0.75);
ctx.strokeRect(0, 0, 400, 400);
ctx.restore();

我們把上面得公式在做進一步的擴展,讓它的適用性更強

width * (1 - scale) / 2   -> width / 2 * (1 - scale)  -> posX * (1 - scale)
height * (1 - scale) / 2  -> height / 2 * (1 - scale) -> posY * (1 - scale)

在這裏也需要明確一點 posX = x + (width / 2) posY = y + (height / 2)

在進一步抽象成函數

function centerScale(ctx, posX, posY, scaleX, scaleY) {
    ctx.translate(posX * (1 - scaleX), posY * (1 - scaleY));
    ctx.scale(scaleX, scaleY);
}

那麼其中的文字縮放也是如出一轍

var fontScale = 0.75
g.fillStyle = 'white';
g.textAlign = 'center';
g.font = fontSize + 'px Arial';
g.translate(posX * (1 - fontScale), posY * (1 - fontScale));
g.scale(fontScale, fontScale);
g.fillText(progressPercentage + '%', posX, posY + fontSize / 3);

當然結果也是很不錯的😉,文字的縮放功能實現了

14

在實現上如果大家有什麼問題可以直接留言或者私信或者直接去官網hightopo上查閱相關的資料

結語

這個進度環組件的開發就到此就結束了,相信小夥伴們通過我的這篇學習筆記也是可以通過ht.js獨立開發一個拓撲組件了。後續我還會不定期的分享我的學習心得,希望小夥伴們也能給出自己的建議。

ps:如果大家有興趣想試試 HT 的產品歡迎訪問 http://www.hightopo.com/reque...

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章