JS前端使用Canvas快速實(shí)現(xiàn)手勢(shì)解鎖特效
前言
之前在公司開發(fā)活動(dòng)項(xiàng)目的時(shí)候,遇到一個(gè)項(xiàng)目需求要讓用戶使用手勢(shì)畫星位圖來解鎖星座運(yùn)勢(shì),一看設(shè)計(jì)稿,這不就是我們平時(shí)的手機(jī)屏幕解鎖嗎?于是上網(wǎng)搜了一些關(guān)于手勢(shì)解鎖的文章,沒找到可以直接復(fù)用的,于是只能自己打開canvas教程,邊學(xué)習(xí)邊設(shè)計(jì)實(shí)現(xiàn)了這個(gè)功能,同時(shí)兼容了移動(dòng)端和PC端,在這里把代碼分享出來,感興趣的可以看看。
Demo
需要實(shí)現(xiàn)的功能
- 在canvas畫布上展示指定行 * 列星星,并可設(shè)置隨機(jī)顯示位置
- 手指滑動(dòng)可連接畫布上任意亮點(diǎn)的星星
- 當(dāng)畫布上已經(jīng)有連接的星星時(shí),可以從已有星星的首部或者尾部開始繼續(xù)連接
- 同一顆星星不可重復(fù)連接,且需要限制連接星星數(shù)量的最大最小值
- 其他:兼容PC端、連接星星過程中禁止?jié)L動(dòng)等
初始化數(shù)據(jù)和頁面渲染
- 定義好連接星星的行列數(shù)目(starXNum 和 starYNum),和canvas畫布的寬高
- 根據(jù)定義好的行列和canvas畫布大小,計(jì)算好每顆星星的大小(starX)和橫豎間距(spaceX、spaceY),初始化星星, 這里一開始想通過canvas渲染星星到畫布上,但是由于呈現(xiàn)出的小圓點(diǎn)呈鋸齒狀,視覺體驗(yàn)不好,因此改成用常規(guī)div+css畫出所有的星星然后通過計(jì)算距離渲染(如上圖)
<div class="starMap" ref="starMap"> <canvas id="starMap" ref="canvas" class="canvasBox" :width="width" :height="height" :style="{ width, height }" @touchstart="touchStart" @touchmove="touchMove" @touchend="touchEnd" @mousedown="touchStart" @mousemove="touchMove" @mouseup="touchEnd" @mouseout="touchEnd" @mouseenter="touchStart" ></canvas> <div class="starsList"> <div v-for="n in starXNum" :key="n" class="starColBox" :style="{ marginBottom: `${spaceY}px` }"> <div v-for="j in starYNum" :key="j" class="starRow" :style="{ marginRight: `${spaceX}px` }"> <div :class="['starIcon', showStar(n, j) && 'show']" :style="{ width: `${starX}px`, height: `${starX}px` }"> <div :class="['starCenter', isSelectedStar(n, j) && `animate-${getRandom(0, 2, 0)}`]"></div> </div> </div> </div> </div> <canvas id="judgeCanvas" :width="width" :height="height" class="judgeCanvas" :style="{ width, height }"></canvas> </div>
/* * this.width=畫布寬 * this.height=畫布高 * this.starX=星星的大小,寬高相等不做區(qū)分 */ spaceX () { // 星星橫向間距 return (this.width - this.starX * this.starXNum) / 4 } spaceY () { // 星星縱向間距 return (this.height - this.starX * this.starYNum) / 4 }
初始化canvas畫布和基礎(chǔ)數(shù)據(jù)
- 通過 canvas.getContext('2d') ? 獲取繪圖區(qū)域
- 定義一個(gè)數(shù)組pointIndexArr來存儲(chǔ)最原始畫布上所有可能的星星,再定義數(shù)組 pointPos 存儲(chǔ)初當(dāng)前展示的所有星星的坐標(biāo)(以當(dāng)前canvas畫布左上角的坐標(biāo)為圓點(diǎn)),用于手指滑動(dòng)過程中判斷是否經(jīng)過某個(gè)點(diǎn)
- 定義數(shù)組 points 存放畫布上已經(jīng)連接的星星
- 設(shè)置canvas繪圖的樣式,如連接線的寬度 lineWidth,模糊度 lineBlurWidth,設(shè)置canvas連接線色值 strokeStyle = '#c9b8ff',連接線結(jié)束時(shí)為圓形的線帽 lineCap = 'round' 。
function setData () { // 初始化canvas數(shù)據(jù) this.initStarPos() this.lineWidth = 2 // 連接線寬度 this.lineBlurWidth = 6 // 連接線shadow寬 this.canvas = document.getElementById('starMap') if (!this.canvas) return console.error('starMap: this.canvas is null') this.ctx = this.canvas.getContext('2d') this.ctx.strokeStyle = '#c9b8ff' this.ctx.lineCap = 'round' this.ctx.lineJoin = 'bevel' const judgeCanvas = document.getElementById('judgeCanvas') this.judgeCtx = judgeCanvas.getContext('2d') } function initStarPos () { // 初始化星星位置 const arr = this.pointIndexArr = this.initPointShowArr() const pointPos = [] /** * spaceX=橫向間距;spaceY:縱向間距 * 星星中點(diǎn)x位置: 星星/2 + (星星的尺寸 + 橫向間距)* 前面的星星數(shù)量 * 星星中點(diǎn)y位置: 星星/2 + (星星的尺寸 + 豎向間距)* 前面的星星數(shù)量 * pointPos=所有頁面渲染的星星(x, y)坐標(biāo) */ arr.forEach(item => { let x = 0 let y = 0 x = this.starX / 2 + (this.starX + this.spaceX) * (item % this.starXNum) y = this.starX / 2 + (this.starX + this.spaceY) * Math.floor(item / this.starXNum) pointPos.push({ x, y, index: item }) }) this.pointPos = [...pointPos] } function initPointShowArr () { const result = [] const originArr = [] const arrLen = getRandom(25, this.starXNum * this.starYNum, 0) // 可選擇隨機(jī)選擇需要顯示星星的數(shù)量 getRandom(21, 25, 0) const starOriginLen = this.starXNum * this.starYNum for (let i = 0; i < starOriginLen; i++) { originArr.push(i) } // 獲取星星展示隨機(jī)數(shù)組后進(jìn)行排序重組 for (let i = 0; i < arrLen; i++) { const random = Math.floor(Math.random() * originArr.length) if (result.includes(originArr[random])) { continue } result.push(originArr[random]) originArr.splice(random, 1) } result.sort((a, b) => a - b) return result }
touchstart 手指開始觸摸事件
監(jiān)聽手指開始觸摸事件:
- 判斷手指開始觸摸的位置是否正好是某顆星星坐標(biāo)位置。這里首先需要通過 getBoundingClientRect ? 方法獲取canvas畫布相對(duì)于整個(gè)視口的圓點(diǎn) (x, y) ,然后將當(dāng)前觸摸點(diǎn)減去圓點(diǎn)位置,即可得當(dāng)前手指所在點(diǎn)的坐標(biāo);
- 通過 indexOfPoint 方法將當(dāng)前坐標(biāo)與 pointPos 數(shù)組中的星星坐標(biāo)進(jìn)行匹配,判斷是否要進(jìn)行canvas畫線,當(dāng)匹配成功,則添加到已連接星星數(shù)組中;
- 我們限制了每次連接星星的最大數(shù)量,因此每次開始連接點(diǎn)時(shí)需要 checkLimit() 校驗(yàn)是否超出最大限制。
- 變量 reconnectStart 來記錄是否是在畫布上已有星星的基礎(chǔ)上連接的星星
function touchStart (e) { if (this.checkLimit()) return this.lockScroll() const rect = this.$refs.canvas.getBoundingClientRect() // 此處獲取canvas位置,防止頁面滾動(dòng)時(shí)位置發(fā)生變化 this.canvasRect = { x: rect.left, y: rect.top, left: rect.left, right: rect.right, bottom: rect.bottom, top: rect.top } const [x, y] = this.getEventPos(e) const index = this.indexOfPoint(x, y) if (this.pointsLen) { this.reconnectStart = true } else { this.pushToPoints(index) } } function getEventPos (event) { // 當(dāng)前觸摸坐標(biāo)點(diǎn)相對(duì)canvas畫布的位置 const x = event.clientX || event.touches[0].clientX const y = event.clientY || event.touches[0].clientY return [x - this.canvasRect.x, y - this.canvasRect.y] } function indexOfPoint (x, y) { if (this.pointPos.length === 0) throw new Error('未找到星星坐標(biāo)') // 為了減少計(jì)算量,將星星當(dāng)初正方形計(jì)算 for (let i = 0; i < this.pointPos.length; i++) { if ((Math.abs(x - this.pointPos[i].x) < this.starX / 1.5) && (Math.abs(y - this.pointPos[i].y) < this.starX / 1.5)) { return i } } return -1 } function pushToPoints (index) { if (index === -1 || this.points.includes(index)) return false this.points.push(index) return true } function checkBeyondCanvas (e) { // 校驗(yàn)手指是否超出canvas區(qū)域 const x = e.clientX || e.touches[0].clientX const y = e.clientY || e.touches[0].clientY const { left, top, right, bottom } = this.canvasRect const outDistance = 40 // 放寬邊界的判斷 if (x < left - outDistance || x > right + outDistance || y < top - outDistance || y > bottom + outDistance) { this.connectEnd() return true } return false }
touchmove 監(jiān)聽手指滑動(dòng)事件
監(jiān)聽手指滑動(dòng)事件:
- 在手指滑動(dòng)過程中,獲取每個(gè)點(diǎn)的坐標(biāo)(x, y), 判斷該點(diǎn)是否正好為某顆星星的坐標(biāo)位置,再調(diào)用 draw() 方法畫線。
- a. 如果沒有移動(dòng)到星星的位置,則在畫布上畫出上一個(gè)連接星星到當(dāng)前點(diǎn)的對(duì)應(yīng)的軌跡
- b. 如果移動(dòng)到了某顆星星的坐標(biāo)范圍,則在上一顆星星和當(dāng)前星星之間畫一條直線,并將該點(diǎn)添加到 points 數(shù)組中
- draw 方法中,每次畫線前,需要調(diào)用canvas的API canvas.clearRect ? 清空畫布,抹除上一次的狀態(tài),重新調(diào)用 drawLine 方法按照 points 數(shù)組中的點(diǎn)順序繪制新的星星連線軌跡。
drawLine中涉及到一些canvas的基本方法和屬性:
canvas.beginPath() // 表示開始畫線或重置當(dāng)前路徑 canvas.moveTo(x, y) // 指定目標(biāo)路徑的開始位置,不創(chuàng)建線條 canvas.lineTo(x, y) // 添加一個(gè)新點(diǎn),創(chuàng)建從該點(diǎn)到畫布中最后指定點(diǎn)的線條,不創(chuàng)建線條 canvas.closePath() // 結(jié)束路徑,應(yīng)與開始路徑呼應(yīng) canvas.stroke() // 實(shí)際地繪制出通過 moveTo() 和 lineTo() 方法定義的路徑,默認(rèn)為黑色 const grd = canvas.createLinearGradient(x1, y1, x2, y2) // 創(chuàng)建線性漸變的起止坐標(biāo) grd.addColorStop(0, '#c9b8ff') // 定義從 0 到 1 的顏色漸變 grd.addColorStop(1, '#aa4fff') canvas.strokeStyle = grd
function touchMove (e) { console.log('touchMove', e) if (this.checkBeyondCanvas(e)) return // 防止touchmove移出canvas區(qū)域后不松手,滾動(dòng)后頁面位置改變?cè)赾anvas外其他位置觸發(fā)連接 if (this.checkLimit()) return this.lockScroll() // 手指活動(dòng)過程中禁止頁面滾動(dòng) const [x, y] = this.getEventPos(e) const idx = this.indexOfPoint(x, y) if (this.reconnectStart && (idx === this.points[this.pointsLen - 1] || idx !== this.points[0])) { this.reconnectStart = false idx === this.points[0] && this.points.reverse() } this.pushToPoints(idx) this.draw(x, y) } function draw (x, y) { if (!this.canvas) return this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) if (this.pointsLen === 0) return this.rearrangePoints(x, y) this.drawLine(x, y) } function drawLine (x, y) { this.ctx.lineWidth = this.lineWidth const startPos = this.getPointPos(0) const endPos = this.getPointPos(this.pointsLen - 1) for (let i = 1; i < this.pointsLen; i++) { const movePos = i === 1 ? startPos : this.getPointPos(i - 1) this.drawradientLine(movePos.x, movePos.y, this.getPointPos(i).x, this.getPointPos(i).y, true) } if (x !== undefined && y !== undefined) { this.drawradientLine(endPos.x, endPos.y, x, y, false) } else { this.ctx.stroke() } } drawradientLine (x1, y1, x2, y2, closePath) { // 漸變線條 if (!this.ctx) return this.ctx.beginPath() this.ctx.moveTo(x1, y1) // 開始位置 this.ctx.lineTo(x2, y2) // 畫到此處 const grd = this.ctx.createLinearGradient(x1, y1, x2, y2) // 線性漸變的起止坐標(biāo) grd.addColorStop(0, '#c9b8ff') grd.addColorStop(1, '#aa4fff') this.ctx.strokeStyle = grd this.ctx.shadowBlur = this.lineBlurWidth this.ctx.shadowColor = '#5a00ff' closePath && this.ctx.closePath() this.ctx.stroke() }
touchend 監(jiān)聽手指觸摸結(jié)束事件
手指離開屏幕時(shí), 當(dāng)前連接星星如果少于兩顆(至少連接兩個(gè)點(diǎn)),則清空數(shù)組,否則按照當(dāng)前已連接的點(diǎn)重新繪制線條,當(dāng)已連接的點(diǎn)小于最小限制時(shí),給用戶toast提示。
至此,連接星星的基本功能就完成了,還需要進(jìn)行一些細(xì)節(jié)的處理。
function touchEnd (e) { this.connectEnd(true) } connectEnd () { this.unlockScroll() if (this.pointsLen === 1) { this.points = [] } this.draw() if (this.pointsLen > 1 && this.pointsLen < this.minLength && !this.reconnectStart) { this.showToast(`至少連接${this.minLength}顆星星哦~`) } }
頁面滾動(dòng)處理
當(dāng)頁面有滾動(dòng)條是,連線過程中容易連帶著頁面滾動(dòng),導(dǎo)致觸摸點(diǎn)錯(cuò)位,并且用戶體驗(yàn)不好。解決方案是:每當(dāng)手指觸摸畫布區(qū)域開始連接時(shí),先禁止頁面的滾動(dòng),當(dāng)手指放開后或離開畫布后再恢復(fù)頁面滾動(dòng)。
具體代碼如下:
function lockScroll () { if (this.unlock) return this.unlock = lockScrollFunc() } function unlockScroll () { if (this.unlock) { this.unlock() this.unlock = null } } function unLockScrollFunc () { const str = document.body.getAttribute(INTERNAL_LOCK_KEY) if (!str) return try { const { height, pos, top, left, right, scrollY } = JSON.parse(str) document.documentElement.style.height = height const bodyStyle = document.body.style bodyStyle.position = pos bodyStyle.top = top bodyStyle.left = left bodyStyle.right = right window.scrollTo(0, scrollY) setTimeout(() => { document.body.removeAttribute(LOCK_BODY_KEY) document.body.removeAttribute(INTERNAL_LOCK_KEY) }, 30) } catch (e) {} } function lockScrollFunc () { if (isLocked) return unLockScrollFunc const htmlStyle = document.documentElement.style const bodyStyle = document.body.style const scrollY = window.scrollY const height = htmlStyle.height const pos = bodyStyle.position const top = bodyStyle.top const left = bodyStyle.left const right = bodyStyle.right bodyStyle.position = 'fixed' bodyStyle.top = -scrollY + 'px' bodyStyle.left = '0' bodyStyle.right = '0' htmlStyle.height = '100%' document.body.setAttribute(LOCK_BODY_KEY, scrollY + '') document.body.setAttribute(INTERNAL_LOCK_KEY, JSON.stringify({ height, pos, top, left, right, scrollY })) return unLockScrollFunc }
連接的兩顆星星之間有其他星星時(shí)
如上所示,當(dāng)連接的兩顆星星路徑上有其他的星星時(shí),視覺上四連接了4顆星星,實(shí)際上中間兩顆手指未觸摸過的星星并未加入到當(dāng)前繪制星星的數(shù)組中,這時(shí)候如果想要做最大最小星星數(shù)量的限制就會(huì)失誤,因此這里通過判斷方向,將中間兩顆星星也接入到已連接星星數(shù)組中,每次 draw() 時(shí)判斷一下。
如下列出了連接所有可能的8種情況和處理步驟:
判斷是否有多余的點(diǎn)
判斷方向 a.豎線: x1 = x2
- 從上到下: y1 < y2
- 從下到上: y1 > y2 b.橫線:y1 = y2
- 從左到右:x1 < x2
- 從右到左:x1 > x2 c.斜線()
- 從上到下:x1 < x2 y1 < y2
- 從下到上:x1 > x2 y1 > y2 d.斜線(/)
- 從上到下:x1 > x2 y1 < y2
- 從下到上:x1 < x2 y1 > y2
給點(diǎn)數(shù)組重新排序
與points合并
長度超出最大限制個(gè)則從末尾拋出
開始畫線
canvas.isPointInPath(x, y) // 判斷點(diǎn) (x, y)是否在canvas路徑的區(qū)域內(nèi)
function rearrangePoints () { // 根據(jù)最后兩個(gè)點(diǎn)之間連線,如果有多出的點(diǎn)進(jìn)行重排,否則不處理 if (this.pointsLen === 1) return const endPrevPos = this.getPointPos(this.pointsLen - 2) const endPos = this.getPointPos(this.pointsLen - 1) const x1 = endPrevPos.x const y1 = endPrevPos.y const x2 = endPos.x const y2 = endPos.y this.judgeCtx.beginPath() this.judgeCtx.moveTo(x1, y1) // 開始位置 this.judgeCtx.lineTo(x2, y2) // 畫到此處 const extraArr = [] const realArr = [] this.pointPos.forEach((item, i) => { if (this.judgeCtx.isPointInStroke(item.x, item.y)) realArr.push(i) if (this.judgeCtx.isPointInStroke(item.x, item.y) && !this.points.includes(i)) { extraArr.push(i) } }) if (!extraArr.length) return const extraPosArr = extraArr.map(item => { return { ...this.pointPos[item], i: item } }) const getExtraSortMap = new Map([ [[0, -1], (a, b) => a.y - b.y], [[0, 1], (a, b) => b.y - a.y], [[-1, 0], (a, b) => a.x - b.x], [[1, 0], (a, b) => b.x - a.x], [[-1, -1], (a, b) => (a.x - b.x) && (a.y - b.y)], [[1, 1], (a, b) => (b.x - a.x) && (b.y - a.y)], [[1, -1], (a, b) => (b.x - a.x) && (a.y - b.y)], [[-1, 1], (a, b) => (a.x - b.x) && (b.y - a.y)] ]) const extraSortArr = extraPosArr.sort(getExtraSortMap.get([this.getEqualVal(x1, x2), this.getEqualVal(y1, y2)])) this.points.splice(this.pointsLen - 1, 0, ...(extraSortArr.map(item => item.i))) this.pointsLen > this.maxLength && this.points.splice(this.maxLength, this.pointsLen - this.maxLength) } function getEqualVal (a, b) { return a - b === 0 ? 0 : a - b > 0 ? 1 : -1 }
最后找了個(gè)星空背景的demo貼到代碼中,功能就完成了,關(guān)于星空背景的實(shí)現(xiàn)感興趣的可以自己研究一下。
以上就是JS前端使用Canvas快速實(shí)現(xiàn)手勢(shì)解鎖特效的詳細(xì)內(nèi)容,更多關(guān)于JS前端Canvas手勢(shì)解鎖的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
解析Javascript設(shè)計(jì)模式Revealing?Module?揭示模式單例模式
這篇文章主要為大家解析了Javascript設(shè)計(jì)模式Revealing?Module?揭示模式及Singleton單例模式示例,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-08-08JS實(shí)現(xiàn)將圖片URL轉(zhuǎn)base64示例詳解
這篇文章主要為大家介紹了JS實(shí)現(xiàn)將圖片URL轉(zhuǎn)base64示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-03-03微信小程序(二十二)action-sheet組件詳細(xì)介紹
這篇文章主要介紹了微信小程序action-sheet組件詳細(xì)介紹的相關(guān)資料,需要的朋友可以參考下2016-09-09javascript中的箭頭函數(shù)基礎(chǔ)語法及使用場(chǎng)景示例
這篇文章主要為大家介紹了?javascript中的箭頭函數(shù)基礎(chǔ)語法及使用場(chǎng)景示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-07-07JavaScript立即執(zhí)行函數(shù)用法解析
這篇文章主要介紹了JavaScript立即執(zhí)行函數(shù),我們知道,在一般情況下,函數(shù)必須先調(diào)用才能執(zhí)行,如下所示,我們定義了一個(gè)函數(shù),并且調(diào)用,下面一起進(jìn)入文章來接具體的使用方法吧2021-12-12