本次分享一個本身採用Canvas繪製一個可配置的刻度(尺)組件。 組件應用:主要經常使用於移動端數值、金額等的滑動選擇,加強用戶交互體驗。javascript
文章涉及開發思路、代碼拆解、問題解決、刻度組件庫封裝等多方面的知識。有須要的童鞋能夠適當參考,開發出更絢麗、更完善的組件。css
歡迎到github地址,多多start:github.com/now1then/ca…;html
也歡迎訪問 演示地址體驗功能:rnlvwyx.cn:3333/#/demo;vue
刻度組件 demo,效果圖: java
組件開發將完成如下功能:git
import scale from './scale.js'; // 引入scale.js文件
// or
npm install canvas-scale
import scale from 'canvas-scale';
複製代碼
scale模塊對外暴露一個init()初始化方法; scale.init()函數 :
github
document.querySelector()
獲取到的HTML節點;/** * scale 刻度函數 * @param {String} el html節點 * @param {Object} options 配置信息 * @param {Function} callBack 刻度變動回調函數 * @returns { Object} */
// 繪製刻度尺
const myScale = scale.init('#myScale', {height: 50, start: 10000, end: 2000},callBack);
function callBack(value) {
console.log(value);
}
複製代碼
update(value)
:傳入最新的刻度值,更新畫布顯示。value:最新刻度值
clear()
:清除當前畫布。resize(option)
:重置畫布,可傳入最新須要重置的配置信息。option:刻度配置
myScale.update(1000); // 更新刻度值
myScale.clear(); // 清除畫布
myScale.resize(); // 重置刻度畫布
複製代碼
不熟悉canvas api的童鞋,能夠訪問 Canvas API中文網 學習。 我本身也總結概括了一遍關於Canvas開發及常見問題的文章:vue-cli
這裏講解一下開發思路,主要拆分爲如下幾個步驟:npm
組件支持的配置項,目前爲如下配置:canvas
// 默認配置
const default_conf = {
// width: '', // 不支持設置,取容器寬度
height: 50, // 畫布高度
start: 1000, // 刻度開始值
end: 10000, // 刻度結束值
// def: 100, // 中心線停留位置 刻度值
unit: 10, // 刻度間隔 'px'
capacity: 100, // 刻度容量值
background: '#fff', // 設置顏色則背景爲對應顏色虛幻效果,不設置默認爲全白。
lineColor: '#087af7', // 中心線顏色
openUnitChange: true, // 是否開啓間隔刻度變動
sign: '@nowThen', // 簽名,傳入空不顯示簽名
fontColor: '#68ca68', // 刻度數值顏色, 刻度線顏色暫未提供設置
fontSize: '16px SimSun, Songti SC', // 刻度數值 字體樣式
};
複製代碼
經過scale.init()
的第二個參數傳入配置項,不然將使用以上默認配置項。
根據傳入的容器繪製canvas:
// 根據傳入的容器繪製canvas
const container = document.querySelector(el);
container.appendChild(canvas);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
複製代碼
中間線一直停留在畫布中間,標識當前刻度值。
// 繪製中心線
function drawMidLine() {
const mid_x = Math.floor(config.width / 2);
ctx.beginPath();
ctx.fillStyle = config.lineColor;
ctx.fillRect(mid_x - 1, 0, 2, config.height);
ctx.stroke();
ctx.moveTo(mid_x, 8);
ctx.lineTo(mid_x - 5, 2);
ctx.lineTo(mid_x - 5, 0);
ctx.lineTo(mid_x + 5, 0);
ctx.lineTo(mid_x + 5, 2);
ctx.fill();
ctx.moveTo(mid_x, config.height - 8);
ctx.lineTo(mid_x - 5, config.height - 2);
ctx.lineTo(mid_x - 5, config.height);
ctx.lineTo(mid_x + 5, config.height);
ctx.lineTo(mid_x + 5, config.height - 2);
ctx.fill();
ctx.closePath();
}
複製代碼
背景色config.background
: 背景爲傳入的顏色值,且額外爲畫布 營造一些畫布兩邊刻度線虛幻的效果。 傳入空,則整個畫布爲透明色,且無兩端虛化效果。
簽名config.sign
顯示在畫布右上角,傳入空值就不顯示簽名。
// 設置簽名及背景
function drawSign() {
// 背景 虛化效果、、
if (config.background) {
ctx.beginPath();
var gradient1 = ctx.createLinearGradient(0, 0, config.width, 0);
gradient1.addColorStop(0, 'rgba(255, 255, 255, 0.95)');
gradient1.addColorStop(0.45, 'rgba(255, 255, 255, 0)');
gradient1.addColorStop(0.55, 'rgba(255, 255, 255, 0)');
gradient1.addColorStop(1, 'rgba(255, 255, 255, 0.95)');
ctx.fillStyle = gradient1;
ctx.fillRect(0, 0, config.width, config.height);
ctx.closePath();
}
// 簽名
if (config.sign) {
ctx.beginPath();
ctx.font = '10px Arial';
var gradient = ctx.createLinearGradient(config.width, 0, config.width - 50, 0);
gradient.addColorStop(0, 'rgba(255, 0, 0, 0.3)');
gradient.addColorStop(1, 'rgba(0, 128, 0, 0.3)');
ctx.fillStyle = gradient;
ctx.textAlign = 'right';
ctx.fillText(config.sign, config.width - 10, 10);
ctx.closePath();
ctx.fillStyle = 'transparent';
}
}
// 在繪製刻度尺時,設置背景值
ctx_bg.fillStyle = _config.background || 'transparent'; // 背景色
複製代碼
這裏想到了兩個可行思路:
在實際開發時,這兩種方案都嘗試並實現了。也暴露了一些問題:
步驟分爲如下:
1/5 * height
高,每隔5刻度1/3 * height
高,每隔10刻度 1/2 * height
高並繪製刻度值;context.getImageData(sx, sy, sWidth, sHeight);
截取刻度尺圖像區域,經過context.putImageData(imageData, 0, 0);
將上面截取的ImageData對象的數據繪製到主畫布上。使用context.drawImage()
也能達到相同效果。
在滑動時,根據滑動的位置,截取對應的刻度尺區域圖形,繪製在主畫布上。
// 建立新的刻度畫布 做爲底層圖片
const canvas_bg = document.createElement('canvas');
const ctx_bg = canvas_bg.getContext('2d');
// 繪製刻度尺
function drawScale() {
const mid = config.end - config.start + 1; // 取值範圍
const scale_len = Math.ceil(mid / config.capacity); // 刻度條數
const space = Math.floor(config.width / 2); //左右兩邊間隙,根據該值計算整數倍刻度值畫線
const beginNum = Math.ceil(config.start / config.capacity) * config.capacity;
const st = (Math.ceil(config.start / config.capacity) - config.start / config.capacity) * config.unit;
// 設置canvas_bg寬高
canvas_bg.width = (config.unit * (scale_len - 1) + config.width) * dpr;
canvas_bg.height = config.height * dpr;
ctx_bg.scale(dpr, dpr);
ctx_bg.beginPath();
ctx_bg.fillStyle = config.background || 'transparent'; // 背景色
ctx_bg.fillRect(0, 0, canvas_bg.width, config.height);
ctx_bg.closePath();
// 底線
ctx_bg.beginPath();
ctx_bg.moveTo(0, config.height);
ctx_bg.lineTo(canvas_bg.width, config.height);
ctx_bg.strokeStyle = config.scaleLineColor || '#9E9E9E';
ctx_bg.lineWidth = 1;
ctx_bg.stroke();
ctx_bg.closePath();
// 繪製刻度線
for (let i = 0; i < scale_len; i++) {
ctx_bg.beginPath();
ctx_bg.strokeStyle = config.scaleLineColor || "#9E9E9E";
ctx_bg.font = config.fontSize;
ctx_bg.fillStyle = config.fontColor;
ctx_bg.textAlign = 'center';
ctx_bg.shadowBlur = 0;
const curPoint = i * config.unit + space + st;
const curNum = i * config.capacity + beginNum;
if (curNum % (config.capacity * 10) === 0) {
ctx_bg.moveTo(curPoint, (config.height * 1) / 2);
ctx_bg.strokeStyle = config.scaleLineColor || "#666";
ctx_bg.shadowColor = '#9e9e9e';
ctx_bg.shadowBlur = 1;
ctx_bg.fillText(
curNum,
curPoint,
(config.height * 1) / 3
);
} else if (curNum % (config.capacity * 5) === 0) {
ctx_bg.moveTo(curPoint, (config.height * 2) / 3);
ctx_bg.strokeStyle = config.scaleLineColor || "#888";
if (scale_len <= 10) {
ctx_bg.font = '12px Helvetica, Tahoma, Arial';
ctx_bg.fillText(
curNum,
curPoint,
(config.height * 1) / 2
);
}
} else {
ctx_bg.moveTo(curPoint, (config.height * 4) / 5);
if (i === 0 || i === scale_len - 1) {
ctx_bg.font = '12px Helvetica, Tahoma, Arial';
ctx_bg.fillText(
curNum,
curPoint,
(config.height * 2) / 3
);
}
}
ctx_bg.lineTo(curPoint, config.height);
ctx_bg.stroke();
ctx_bg.closePath();
}
point_x = (config.def - config.start) / config.capacity * config.unit; //初始化開始位置
const imageData = ctx_bg.getImageData(point_x * dpr, 0, config.width * dpr, config.height * dpr)
ctx.putImageData(imageData, 0, 0);
}
複製代碼
步驟分爲如下:
1/5 * height
高,每隔5刻度1/3 * height
高,每隔10刻度 1/2 * height
高並繪製刻度值;context.drawImage()
繪製圖像到主畫布區域;在滑動時,實時計算當前中間刻度值,並依據上面的繪製步驟,重繪整個畫布。
// 建立新的刻度畫布 做爲底層圖片
const canvas_bg = document.createElement('canvas');
const ctx_bg = canvas_bg.getContext('2d');
// 繪製刻度尺
function drawScale() {
// 設置canvas_bg寬高
canvas_bg.width = (config.unit * (scale_len - 1) + config.width) * dpr;
canvas_bg.height = config.height * dpr;
ctx_bg.scale(dpr, dpr);
// 以中點刻度爲基準,獲取最左側刻度值
let begin_num = current_def - (config.width / 2) * (config.capacity / config.unit);
let cur_x = 0;
let cur_num = 0;
const scale_len = Math.ceil((config.width + 1) / config.unit); // 刻度條數
const real_len = Math.ceil((config.end - config.start + 1) / config.capacity); // 實際可繪製的刻度條數
ctx_bg.fillStyle = config.background || 'transparent'; // 背景色
ctx_bg.fillRect(0, 0, config.width, config.height);
ctx_bg.closePath();
// 底線
ctx_bg.beginPath();
ctx_bg.moveTo(0, config.height);
ctx_bg.lineTo(config.width, config.height);
ctx_bg.strokeStyle = config.scaleLineColor || '#9E9E9E';
ctx_bg.lineWidth = 1;
ctx_bg.stroke();
ctx_bg.closePath();
let space_num = Math.ceil(begin_num / config.capacity) * config.capacity - begin_num;
let space_x = space_num * (config.unit / config.capacity);
// 繪製刻度線
for (let i = 0; i < scale_len; i++) {
cur_num = (Math.ceil(begin_num / config.capacity) + i) * config.capacity;
if (cur_num < config.start) {
continue;
} else if (cur_num > config.end) {
break;
}
ctx_bg.beginPath();
ctx_bg.strokeStyle = config.scaleLineColor || "#9E9E9E";
ctx_bg.font = config.fontSize;
ctx_bg.fillStyle = config.fontColor;
ctx_bg.textAlign = 'center';
ctx_bg.shadowBlur = 0;
cur_x = space_x + i * config.unit;
if (cur_num % (config.capacity * 10) === 0) {
ctx_bg.moveTo(cur_x, (config.height * 1) / 2);
ctx_bg.strokeStyle = config.scaleLineColor || "#666";
ctx_bg.shadowColor = '#9e9e9e';
ctx_bg.shadowBlur = 1;
ctx_bg.fillText(
cur_num,
cur_x,
(config.height * 1) / 3
);
} else if (cur_num % (config.capacity * 5) === 0) {
ctx_bg.moveTo(cur_x, (config.height * 2) / 3);
ctx_bg.strokeStyle = config.scaleLineColor || "#888";
if (real_len <= 10) {
ctx_bg.font = '12px Helvetica, Tahoma, Arial';
ctx_bg.fillText(
cur_num,
cur_x,
(config.height * 1) / 2
);
}
} else {
ctx_bg.moveTo(cur_x, (config.height * 4) / 5);
}
ctx_bg.lineTo(cur_x, config.height);
ctx_bg.stroke();
ctx_bg.closePath();
}
ctx.drawImage(canvas_bg, 0, 0, config.width * dpr, config.height * dpr, 0, 0, config.width, config.height);
}
複製代碼
第一種方案: 在實現難度上更簡單;這個刻度尺畫布只需繪製一次,滑動時無需重繪刻度尺畫布;也更能直觀體現刻度移動, 但在繪製刻度區間跨度大時,性能很差,且canvas畫布尺寸過大,會出現繪製空白的問題。 第二種方案: 比較難定位可視區域刻度尺的初始值、結束值,且一滑動,整個畫布都從新計算每一個繪製點。 咋一看實現更麻煩,滑動時整個畫布都得實時繪製,但相比於第一種方案的致命缺陷,效果、性能及兼容性更佳。
這樣,初始的刻度尺樣式就生成了。
開發過程當中遇到的問題(在文章後面有較詳細的說明):
監聽左右滑動事件,獲取每次手指滑動的距離,計算刻度須要移動的距離,並從新繪製canvas畫布。
移動端獲取當前觸摸點X座標:e.touches[0].pageX
;PC端獲取鼠標X座標:e.pageX
。
window.requestAnimationFrame()
優化渲染頻率。ctx.clearRect()
先清空畫布再繪製,不然有繪製重疊。config.openUnitChange
決定是否只能間隔刻度移動,好比100、200、300...變動。傳入false
時,則按實際移動距離變動。效果以下圖:
// 事件交互 (第一種方案)
function addEvent() {
let begin_x = 0; // 手指x座標
let ifMove = false; // 是否開始交互
let moveDistance = 0;
// 註冊事件,移動端和PC端
const hasTouch = 'ontouchstart' in window;
const startEvent = hasTouch ? 'touchstart' : 'mousedown';
const moveEvent = hasTouch ? 'touchmove' : 'mousemove';
const endEvent = hasTouch ? 'touchend' : 'mouseup';
canvas.addEventListener(startEvent, start);
canvas.addEventListener(moveEvent, move);
canvas.addEventListener(endEvent, end);
function start(e) {
e.stopPropagation();
e.preventDefault();
ifMove = true;
if (!e.touches) {
begin_x = e.pageX;
} else {
begin_x = e.touches[0].pageX;
}
}
function move(e) {
e.stopPropagation();
e.preventDefault();
const current_x = e.touches ? e.touches[0].pageX : e.pageX;
if (ifMove) {
moveDistance = current_x - begin_x;
begin_x = current_x;
point_x = point_x - moveDistance; //刻度偏移量
const space = Math.floor(config.width / 2);
// 邊界值處理
if (point_x <= 0) {
point_x = 0;
} else if (point_x >= canvas_bg.width / dpr - config.width) {
point_x = canvas_bg.width / dpr - config.width;
}
window.requestAnimationFrame(moveDraw)
}
}
function end(e) {
ifMove = false;
}
}
function moveDraw() {
let now_x = point_x;
// 是否刻度移動
if (config.openUnitChange) {
const st = ( config.start / config.capacity - Math.floor(config.start / config.capacity)) * config.unit;
now_x = Math.round(this.point_x / config.unit) * config.unit - st;
}
ctx.clearRect(0, 0, config.width, config.height);
// ctx.drawImage(canvas_bg, now * dpr, 0, config.width * dpr, config.height * dpr, 0, 0, config.width, config.height);
var imageData = ctx_bg.getImageData(now_x * dpr, 0, config.width * dpr, config.height * dpr)
ctx.putImageData(imageData, 0, 0)
drawMidLine();
drawSign();
const value = now_x * config.capacity / config.unit + config.start;
if (typeof callBack === 'function') {
callBack(Math.round(value));
} else {
throw new Error('scale函數的第二個參數,必須爲正確的回調函數!')
}
}
複製代碼
上面的事件交互是第一種方案的代碼。未作平滑緩動處理。 在此基礎上改爲第二種方案的代碼,且增長平滑移動,緩動函數採用easeOut,先快後慢。
// easeOut 緩動函數
const slowActionfn = function (t, b, c, d) {
return c * ((t = t / d - 1) * t * t + 1) + b;
};
// 事件交互
function addEvent() {
let begin_x = 0; // 手指x座標
let last_x = 0; //上一次x座標
let ifMove = false; // 是否開始交互
let from_def = 0;
let lastMoveTime = 0;
let lastMove_x = 0;
// 註冊事件,移動端和PC端
const hasTouch = 'ontouchstart' in window;
const startEvent = hasTouch ? 'touchstart' : 'mousedown';
const moveEvent = hasTouch ? 'touchmove' : 'mousemove';
const endEvent = hasTouch ? 'touchend' : 'mouseup';
canvas.addEventListener(startEvent, start);
canvas.addEventListener(moveEvent, move);
canvas.addEventListener(endEvent, end);
function start(e) {
e.stopPropagation();
e.preventDefault();
ifMove = true;
if (!e.touches) {
last_x = begin_x = e.pageX;
} else {
last_x = begin_x = e.touches[0].pageX;
}
lastMove_x = last_x;
lastMoveTime = e.timeStamp || Date.now();
}
function move(e) {
e.stopPropagation();
e.preventDefault();
const current_x = e.touches ? e.touches[0].pageX : e.pageX;
if (ifMove) {
move_x = current_x - last_x;
current_def = current_def - move_x * (config.capacity / config.unit);
window.requestAnimationFrame(moveDraw);
last_x = current_x;
const nowTime = e.timeStamp || Date.now();
if (nowTime - lastMoveTime > 300) {
lastMoveTime = nowTime;
lastMove_x = last_x;
}
}
}
function end(e) {
const current_x = e.changedTouches ? e.changedTouches[0].pageX : e.pageX;
const nowTime = e.timeStamp || Date.now();
const v = -(current_x - lastMove_x) / (nowTime - lastMoveTime); //最後一段時間手指划動速度
ifMove = false;
let t = 0, d = 15;
if (Math.abs(v) >= 0.3) {
from_def = current_def;
step();
} else {
if (current_def < config.start) {
current_def = config.start;
} else if (current_def > config.end) {
current_def = config.end;
}
if (config.openUnitChange) {
current_def = Math.round(current_def / config.capacity) * config.capacity;
}
moveDraw();
}
function step() {
current_def = slowActionfn(t, from_def, (config.capacity) * v * 50, d);
if (current_def < config.start) {
current_def = config.start;
} else if (current_def > config.end) {
current_def = config.end;
}
if (config.openUnitChange) {
current_def = Math.round(current_def / config.capacity) * config.capacity;
}
moveDraw()
t++;
if (t <= d) {
// 繼續運動
window.requestAnimationFrame(step);
} else {
// 結束
}
}
}
}
function moveDraw() {
ctx.clearRect(0, 0, config.width, config.height);
drawScale();
drawMidLine();
drawSign();
if (typeof callBack === 'function') {
callBack(Math.round(current_def));
} else {
throw new Error('scale函數的第二個參數,必須爲正確的回調函數!')
}
}
複製代碼
在繪製過程當中,還須要處理各類邊界狀況:
這些邊界狀況在組件庫代碼中都作了簡單處理。
詳情歡迎查看本人另外一篇文章-「記錄canvas使用及常見問題」
以下圖:
關於移動端高清屏DPR、圖片模糊、移動端適配等問題,不清楚的童鞋能夠參考「關於移動端適配,你必需要知道的」這篇文章,講的比較詳細。這裏再也不贅述,本文章只處理移動端Canvas模糊問題。 在移動端高清屏幕上,常常會遇到Canvas圖形模糊的問題。本質上跟移動端圖片模糊問題是同樣的。canvas繪製成的圖像跟也是位圖,在dpr > 1
的屏幕上,位圖的一個像素可能由多個物理像素來渲染,然而這些物理像素點並不能被準確的分配上對應位圖像素的顏色,只能取近似值,因此在dpr > 1
的屏幕上就會模糊。 在PC端繪製canvas圖形,咱們都直接把1個canvas像素直接等於1px的css像素處理,這沒有問題,應該目前PC端屏幕dpr都是1。而在dpr > 1
的移動端屏幕上就不能直接這樣處理。
解決方案固然仍是從dpr入手。
window.devicePixelRatio
獲取當前設備屏幕的dpr;dpr = 2
時至關於擴大畫布2倍;context.scale(dpr, dpr)
縮放Canvas畫布的座標系。在dpr = 2
時至關於把canvas座標系也擴大了兩倍,這樣繪製比例放大了2倍,以後canvas的實際繪製像素就能夠按原先的像素值處理。在渲染到屏幕時,擴大的畫布圖形又等比例縮放渲染到canvas容器中。從而保證canvas圖形的質量。
// 獲取dpr
const dpr = window.devicePixelRatio;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 獲取Canvas容器的寬高
const { width: cssWidth, height: cssHeight } = canvas.getBoundingClientRect();
// 根據dpr,設置Canvas的寬高,使1個canvas像素和1個物理像素相等
canvas.width = dpr * cssWidth;
canvas.height = dpr * cssHeight;
// 根據dpr,設置canvas元素的寬高屬性
ctx.scale(dpr,dpr);
複製代碼
canvas的drawImage() 函數有個特別容易混淆搞錯的地方。它的5參數和9參數用法的參數位置是不一樣的。實際開發中沒注意到這一點,會讓本身特別困惑問題出在哪!汗!
drawImage()方法有一個很是怪異的地方,你們必定要注意,那就是5參數和9參數用法的參數位置是不同的,這個和通常的API有所不一樣。通常API可選參數是放在後面。可是,這裏的drawImage()使用9個參數時候,可選參數sx,sy,sWidth和sHeight是在前面的。若是不注意這一點,有些表現會讓你沒法理解。
且drawImage()函數插入的圖形在移動端dpr >1
屏幕一樣會有圖片模糊的問題。 在移動端經過drawImage()載入另外一個已繪製的Canvas元素時,也要注意對另外一個canvas元素作兼容處理,還須要注意二者座標系的不一樣。
// 設置canvas_bg寬高
canvas_bg.width = (config.unit * (scale_len - 1) + config.width) * dpr;
canvas_bg.height = config.height * dpr;
ctx_bg.scale(dpr, dpr);
...
// 初始化開始位置
point_x = (config.def - config.start) / config.capacity * config.unit;
//在主畫布ctx上,經過drawImage()插入另外一個canvas_bg畫布;
ctx.drawImage(canvas_bg, point_x * dpr, 0, config.width * dpr, config.height * dpr, 0, 0, config.width, config.height);
複製代碼
上面的代碼中, canvas_bg畫布一樣須要處理上面提到的canvas模糊問題;在主畫布ctx上,經過drawImage()插入另外一個canvas_bg畫布圖形時,須要注意此時二者座標系比例的不一樣,此時canvas_bg的座標系是根據dpr縮放後的。
在實際開發中遇到,canvas繪製尺寸或drawImage插入圖像、getImageDate獲取圖形資源等尺寸大於某個閾值時,渲染出來的圖片整個都是空白。這個具體的閾值不肯定,跟運行環境有關。但這應該也是drawImage繪製的一個不知什麼時候爆發的隱患。 好比下圖,繪製的刻度尺畫布尺寸過大,截取後渲染到主畫布上,整個刻度空白,但不影響交互。
以上是開發思路及主要代碼。接下來要封裝成開源的組件庫文件,能夠直接引入到項目中使用。 首先對代碼進行適當改造,封裝成類,操做實例的方式構建代碼。以提升代碼複用及避免屢次引入使用的相互影響。具體代碼請移步到項目中查看。
爲了簡便,直接使用vue-cli3提供的的構建庫模式構建組件庫。構建的庫會輸出 CommonJS 包、UMD包等版本。
github源碼地址:github.com/now1then/ca…;
文章-語雀:www.yuque.com/nowthen/lon…;
組件demo體驗地址:rnlvwyx.cn:3333/#/demo;
有問題歡迎探討...