在移動端用canvas技術實現一個畫板的工程實例

最新更新時間:2020年03月11日22:07:14

《猛戳-查看我的博客地圖-總有你意想不到的驚喜》

本文內容:在研發C端教育類產品的時候,會遇到在作答題目的時候需要驗算或者做草稿,因此誕生了一個草稿本或者小黑板的功能模塊,本文統稱爲畫板。本文基於canvas技術,深入討論實現一個畫板功能的原理和核心代碼。

成品示例圖

畫板的功能包括兩部分:控制面板區和繪畫區,控制面板區如下圖上半部分,集成了關閉畫板彈層、清空繪畫區、撤銷和重做功能;繪畫區實現單手指的書寫功能。

在這裏插入圖片描述

繪畫區的原理介紹

繪畫區由上下兩個canvas元素組成,稱作canvas_top_domcanvas_bottom_dom,兩個元素都是絕對定位,z-index屬性分別爲1和0

  • canvas_top_dom用來監聽用戶的三個交互行爲:onTouchStart、onTouchMove、onTouchEnd,接收用戶的單次繪製動作(手指按下屏幕、手指在屏幕上移動、手指離開屏幕),在單次繪製動作完成時將繪製的線條路徑轉化爲base64格式的圖片
  • canvas_bottom_dom用來展示用戶所有歷史單次繪製路徑的總數據,當canvas_top_dom元素單次繪製動作完成時,將這次線條路徑的base64格式的圖片疊加繪製在canvas_bottom_dom
  • 用戶在屏幕上移動手指繪製的過程中,此時看到繪畫區的圖案是由canvas_top_domcanvas_bottom_dom組成,當用戶手指離開屏幕,此時看到繪畫區的圖案僅僅是canvas_bottom_dom渲染出的數據

控制面板區的原理介紹

控制面板區集成的功能,從左到右依次是關閉畫板彈層、清空繪畫區、撤銷和重做的功能

  • 關閉畫板彈層,隱藏或銷燬整個畫板組件DOM元素
  • 清空繪畫區,清空canvas_top_domcanvas_bottom_dom上的數據即可
  • 撤銷功能,第一步,需要對用戶的每一次單次繪製動作(手指按下屏幕、手指在屏幕上移動、手指離開屏幕)進行記錄和保存,當canvas_top_dom元素單次繪製動作完成時,將這次線條路徑的base64格式的圖片疊加繪製在canvas_bottom_dom上,然後將canvas_bottom_dom上的所有數據轉化爲一個大的base64字符串,最後將這個大的base64字符串存儲在canvas_bottom_dom歷史狀態數組canvas_bottom_base64_arr的末尾;第二步,聲明一個撤銷記錄的指針變量undoIndex,這個變量用來存儲用戶的撤銷次數,當用戶撤銷一次,undoIndex++,此時更新canvas_bottom_dom渲染的數據爲canvas_bottom_base64_arr中的倒數第二條數據,以此類推
  • 重做功能,當用戶撤銷一次,此時更新canvas_bottom_dom渲染的數據爲canvas_bottom_base64_arr中的倒數第二條數據,當用戶重做一次,undoIndex--,此時更新canvas_bottom_dom渲染的數據爲canvas_bottom_base64_arr中的最後一條數據即可

用到的canvas相關API

HTMLCanvasElement.getContext() 方法返回canvas 的上下文,如果上下文沒有定義則返回 null .

Canvas 2D API 中的 CanvasRenderingContext2D.drawImage() 方法提供了多種方式在Canvas上繪製圖像。

The CanvasRenderingContext2D.clearRect() 是 Canvas 2D API 設置指定矩形區域內(以 點 (x, y) 爲起點,範圍是(width, height) )所有像素變成透明,並擦除之前繪製的所有內容的方法。

HTMLCanvasElement.toDataURL() 方法返回一個包含圖片展示的 data URI 。可以使用 type 參數其類型,默認爲 PNG 格式。圖片的分辨率爲96dpi。

CanvasRenderingContext2D.beginPath() 是 Canvas 2D API 通過清空子路徑列表開始一個新路徑的方法。 當你想創建一個新的路徑時,調用此方法。

CanvasRenderingContext2D.moveTo() 是 Canvas 2D API 將一個新的子路徑的起始點移動到(x,y)座標的方法。

CanvasRenderingContext2D.lineTo() 是 Canvas 2D API 使用直線連接子路徑的終點到x,y座標的方法(並不會真正地繪製)。

CanvasRenderingContext2D.stroke() 是 Canvas 2D API 使用非零環繞規則,根據當前的畫線樣式,繪製當前或已經存在的路徑的方法。

核心代碼

本文的畫板組件採用react框架開發

  • DOM佈局
<div className={styles.canvasBox}>
	{/*操控區*/}
	<div className={styles.controler}>
		{/*關閉按鈕*/}
		<img
			src={require('../../assets/blackboard/close.png')}
			onClick={()=>{this.closeDrawingBoard()}}
		/>
		{/*清空按鈕*/}
		<img
			src={require('../../assets/blackboard/delete.png')}
			onClick={()=>{this.clear()}}
		/>
		{/*撤銷高亮 撤銷置灰*/}
		{this.state.displayUndo ?
			<img
				className={styles.preHighlight}
				src={require('../../assets/blackboard/pre.png')}
				onClick={()=>{this.undo()}}
			/> :
			<img
				className={styles.preGrey}
				src={require('../../assets/blackboard/next.png')}
			/>
		}
		{/*重做高亮 重做置灰*/}
		{this.state.displayRedo ?
			<img
				className={styles.nextHighlight}
				src={require('../../assets/blackboard/pre.png')}
				onClick={()=>{this.redo()}}
			/> :
			<img
				className={styles.nextGrey}
				src={require('../../assets/blackboard/next.png')}
			/>
		}
	</div>
	{/*畫板區*/}
	<div className={styles.board}>
		<canvas
			id="canvas_top_dom"
			width={375}
			height={this.canvasHeight}
			className={styles.top}
			onTouchStart={(e)=>{this.brushDown(e)}}
			onTouchMove={(e)=>{this.brushmove(e)}}
			onTouchEnd={(e)=>{this.brushUp(e)}}
		>瀏覽器不支持canvas</canvas>
		<canvas
			id="canvas_bottom_dom"
			width={375}
			height={this.canvasHeight}
			className={styles.bottom}
		>瀏覽器不支持canvas</canvas>
	</div>
</div>
  • CSS樣式
.canvasBox{
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(1,1,1,0);
  z-index: 2;
  .controler{
    height: 48px;
    background: #FFFFFF;
    display: flex;
    align-items: center;justify-content: space-around;
    img{
      width: 24px;
      height: 24px;
    }
    .preGrey, .nextHighlight{
      transform: rotateY(180deg);//圖片水平鏡像翻轉
    }
  }
  .board{
    position: absolute;
    top: 48px;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(141,141,141,0.6);
    canvas{
      position: absolute;
      top: 0;
      left: 0;
    }
    .bottom{
      z-index: 2;
    }
    .top{
      z-index: 3;
    }
  }
}
  • JavaScript

說明:全局多處使用數字48,48爲畫板頂部操控區高度

// react組件構造函數
constructor(props) {
    super(props);
    //UI數據
    this.state = {
      displayUndo: false,//撤銷按鈕狀態
      displayRedo: false,//重做按鈕狀態
    };
    //非UI數據
    this.canvas_top_dom = null;//上層canvas DOM元素
    this.ctx_top = null//上層canvas 2D 渲染上下文
    this.canvas_bottom_dom = null;//下層canvas DOM元素
    this.ctx_bottom = null;//下層canvas 2D 渲染上下文
    this.startAxisX = 0;//畫筆X軸起始座標
    this.startAxisY = 0;//畫筆Y軸起始座標
    this.hasInitHeight = false;//初始化canvas DOM元素高度 狀態
    this.canvasWidth = 375;//canvas DOM元素高度 默認寬度
    this.canvasHeight = 1000;//canvas DOM元素高度 默認高度
    this.canvas_bottom_base64_arr = [];//下層canvas每個狀態的數據 每個狀態爲一個base64
    this.undoIndex = 0;//撤銷次數
    this.hasMove = false;//手指是否移動,如果沒有移動,攔截brushUp函數。主要用來區分觸摸事件和滑動事件
}

/**
 * 初始化canvas
 * @param
 * @return
 */
initCanvas(){
	this.canvas_top_dom = document.getElementById('canvas_top_dom');
	this.canvas_bottom_dom = document.getElementById('canvas_bottom_dom');
	this.ctx_top = this.canvas_top_dom.getContext("2d");
	this.ctx_bottom = this.canvas_bottom_dom.getContext("2d");
	//根據屏幕高度設置canvas可用高度
    if(!this.hasInitHeight){
		this.hasInitHeight = true;
		this.canvas_top_dom.height = window.innerHeight - 48;
		this.canvas_bottom_dom.height = window.innerHeight - 48;
		this.canvasHeight = window.innerHeight - 48;
    }
}

/**
 * 手指剛按下畫板 初始化畫筆座標
 * @param
 * @return
 */
brushDown(e){
	this.startAxisX = e.touches[0].pageX;
	this.startAxisY = e.touches[0].pageY - 48;
}

/**
 * 手指從畫板擡起
 * @param
 * @return
 */
brushUp(){
	let _this = this;
	if(!this.hasMove)
		return
	this. hasMove = false;
	//以圖片形式在下層canvas繪製數據
	let image = new Image();
	//將上層canvas內容轉化爲base64格式的圖片數據
	image.src = this.canvas_top_dom.toDataURL();//如果有問題 image.src 需要再 image.onload 下面
	image.onload = function(){
		//如果用戶畫了一筆 重做按鈕高亮並且撤銷次數大於0,丟棄撤銷記錄
		if(_this.state.displayRedo && _this.undoIndex > 0){
			_this.canvas_bottom_base64_arr = _this.canvas_bottom_base64_arr.slice(0, _this.canvas_bottom_base64_arr.length - _this.undoIndex);
			_this.undoIndex = 0;
		}
		//如果用戶畫了一筆 撤銷按鈕高亮 重做按鈕置灰
		_this.setState({
			displayUndo: true,
			displayRedo: false
		})
		//下層canvas渲染數據
		_this.ctx_bottom.drawImage(image , 0 ,0 , image.width , image.height , 0 ,0 , _this.canvasWidth , _this.canvasHeight);
		//上層canvas清空數據
		_this.ctx_top.clearRect(0,0,_this.canvasWidth,_this.canvasHeight);
		//下層canvas歷史數據數組 新增數據
		_this.canvas_bottom_base64_arr.push(_this.canvas_bottom_dom.toDataURL());
	}
}

/**
 * 手指在畫板上移動
 * @param
 * @return
 */
brushmove(e){
	this. hasMove = true;
	//畫筆移動時X軸 實時座標
	let stopAxisX = e.changedTouches[0].pageX;
	//畫筆移動時Y軸 實時座標
	let stopAxisY = e.changedTouches[0].pageY - 48;
	this.ctx_top.beginPath();
	this.ctx_top.moveTo(this.startAxisX,this.startAxisY);
	this.ctx_top.lineTo(stopAxisX,stopAxisY);
	this.ctx_top.strokeStyle = '#000000';
	this.ctx_top.lineWidth = 1;
	this.ctx_top.lineCap = "round";
	this.ctx_top.stroke();
	this.startAxisX = stopAxisX;
	this.startAxisY = stopAxisY;
}

/**
 * 撤銷
 * @param
 * @return
 */
undo(){
	if(!this.state.displayUndo){
		return
	}
	let _this = this;
	this.undoIndex++;
	//下層canvas清空數據
	this.ctx_bottom.clearRect(0,0,this.canvasWidth,this.canvasHeight);
	//需要渲染的數據索引
	let renderIndex = this.canvas_bottom_base64_arr.length -1 - this.undoIndex;
	//撤銷之前只有一條數據
	if(renderIndex <= -1){
		//撤銷按鈕置灰 重做按鈕高亮
		this.setState({
			displayUndo: false,
			displayRedo: true
		})
		return
	}
	//以圖片形式在下層canvas繪製數據
	let image = new Image();
	image.src = this.canvas_bottom_base64_arr[renderIndex];
	image.onload = function(){
		_this.ctx_bottom.drawImage(image , 0 ,0 , image.width , image.height , 0 ,0 , _this.canvasWidth , _this.canvasHeight);
	}
	//重做按鈕高亮
	this.setState({
		displayRedo: true
	})
}

/**
 * 重做
 * @param
 * @return
 */
redo(){
	if(!this.state.displayRedo){
		return
	}
	let _this = this;
	this.undoIndex--;
	//下層canvas清空數據
	this.ctx_bottom.clearRect(0,0,this.canvasWidth,this.canvasHeight);
	//以圖片形式在下層canvas繪製數據
	let image = new Image();
	//需要渲染的數據索引
	let renderIndex = this.canvas_bottom_base64_arr.length -1 - this.undoIndex;
	image.src = this.canvas_bottom_base64_arr[renderIndex];
	//重做次數用完
	if(renderIndex >= this.canvas_bottom_base64_arr.length -1){
		//重做按鈕置灰
		this.setState({
			displayRedo: false
		})
	}
	//撤銷按鈕按鈕置灰
	if(renderIndex >= 0){
		this.setState({
			displayUndo: true
		})
	}
	image.onload = function(){
		_this.ctx_bottom.drawImage(image , 0 ,0 , image.width , image.height , 0 ,0 , _this.canvasWidth , _this.canvasHeight);
	}
}

/**
 * 清空畫板
 * @param
 * @return
 */
clear(){
	//畫板歷史數據爲空 不做清空記錄
	if(this.canvas_bottom_base64_arr.length == 0)
		return
	this.setState({
		displayUndo: false,
		displayRedo: false
	})
	this.startAxisX = 0;//畫筆X軸起始座標
	this.startAxisY = 0;//畫筆Y軸起始座標
	this.canvas_bottom_base64_arr = [];//下層canvas每個狀態的數據 每個狀態爲一個base64
	this.undoIndex = 0;//撤銷次數
	this.hasMove = false;//手指是否移動,如果沒有移動,攔截brushUp函數。主要用來區分觸摸事件和滑動事件
	//上層canvas清空數據
	this.ctx_top.clearRect(0,0,this.canvasWidth,this.canvasHeight);
	//下層canvas清空數據
	this.ctx_bottom.clearRect(0,0,this.canvasWidth,this.canvasHeight);
}

/**
 * 關閉畫板
 * @param
 * @return
 */
closeDrawingBoard(){
	//丟棄撤銷記錄
	if(this.state.displayRedo && this.undoIndex > 0){
		this.canvas_bottom_base64_arr = this.canvas_bottom_base64_arr.slice(0, this.canvas_bottom_base64_arr.length - this.undoIndex);
		this.undoIndex = 0;
	}
	//保存當前畫板數據 this.canvas_bottom_base64_arr
	//關閉畫板
}

iOS端的兼容性問題

在畫板上手指上下移動時,iPhone上會出現橡皮筋效果,此時需要做如下特殊處理

/**
 * 手指剛按下畫板 初始化畫筆座標
 * @param
 * @return
 */
brushDown(e){
let _this = this;
//攔截iOS頁面滑動的橡皮筋效果
document.body.addEventListener('touchmove', _this.preventScroll, {passive: false});//passive 參數不能省略,用來兼容ios和android
}

/**
 * 手指從畫板擡起
 * @param
 * @return
 */
brushUp(){
	let _this = this;
	//關閉iOS頁面滑動的橡皮筋效果
	document.body.removeEventListener('touchmove', _this.preventScroll);
}

/**
 * 阻止頁面滾動
 * @param
 * @return
 */
preventScroll(e){
	e.preventDefault();
}

參考資料

感謝閱讀,歡迎評論^-^

打賞我吧^-^

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