JS前端使用Canvas快速實(shí)現(xiàn)手勢解鎖特效
前言
之前在公司開發(fā)活動(dòng)項(xiàng)目的時(shí)候,遇到一個(gè)項(xiàng)目需求要讓用戶使用手勢畫星位圖來解鎖星座運(yùn)勢,一看設(shè)計(jì)稿,這不就是我們平時(shí)的手機(jī)屏幕解鎖嗎?于是上網(wǎng)搜了一些關(guān)于手勢解鎖的文章,沒找到可以直接復(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畫布相對于整個(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)相對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)的對應(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)后頁面位置改變在canvas外其他位置觸發(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)手勢解鎖特效的詳細(xì)內(nèi)容,更多關(guān)于JS前端Canvas手勢解鎖的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
解析Javascript設(shè)計(jì)模式Revealing?Module?揭示模式單例模式
這篇文章主要為大家解析了Javascript設(shè)計(jì)模式Revealing?Module?揭示模式及Singleton單例模式示例,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-08-08
JS實(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-09
javascript中的箭頭函數(shù)基礎(chǔ)語法及使用場景示例
這篇文章主要為大家介紹了?javascript中的箭頭函數(shù)基礎(chǔ)語法及使用場景示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-07-07
JavaScript立即執(zhí)行函數(shù)用法解析
這篇文章主要介紹了JavaScript立即執(zhí)行函數(shù),我們知道,在一般情況下,函數(shù)必須先調(diào)用才能執(zhí)行,如下所示,我們定義了一個(gè)函數(shù),并且調(diào)用,下面一起進(jìn)入文章來接具體的使用方法吧2021-12-12

