首頁 > 軟體

JS前端使用Canvas快速實現手勢解鎖特效

2022-09-24 14:01:20

前言

之前在公司開發活動專案的時候,遇到一個專案需求要讓使用者使用手勢畫星點陣圖來解鎖星座運勢,一看設計稿,這不就是我們平時的手機螢幕解鎖嗎?於是上網搜了一些關於手勢解鎖的文章,沒找到可以直接複用的,於是只能自己開啟canvas教學,邊學習邊設計實現了這個功能,同時相容了行動端和PC端,在這裡把程式碼分享出來,感興趣的可以看看。

Demo

前往我的github檢視原始碼

需要實現的功能

  • 在canvas畫布上展示指定行 * 列星星,並可設定隨機顯示位置
  • 手指滑動可連線畫布上任意亮點的星星
  • 當畫布上已經有連線的星星時,可以從已有星星的首部或者尾部開始繼續連線
  • 同一顆星星不可重複連線,且需要限制連線星星數量的最大最小值
  • 其他:相容PC端、連線星星過程中禁止捲動等

初始化資料和頁面渲染

  • 定義好連線星星的行列數目(starXNum 和 starYNum),和canvas畫布的寬高
  • 根據定義好的行列和canvas畫布大小,計算好每顆星星的大小(starX)和橫豎間距(spaceX、spaceY),初始化星星, 這裡一開始想通過canvas渲染星星到畫布上,但是由於呈現出的小圓點呈鋸齒狀,視覺體驗不好,因此改成用常規div+css畫出所有的星星然後通過計算距離渲染(如上圖)
<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=星星的大小,寬高相等不做區分
*/
spaceX () { // 星星橫向間距
  return (this.width - this.starX * this.starXNum) / 4
}
spaceY () { // 星星縱向間距
  return (this.height - this.starX * this.starYNum) / 4
}

初始化canvas畫布和基礎資料

  • 通過 canvas.getContext('2d') ➚ 獲取繪圖區域
  • 定義一個陣列pointIndexArr來儲存最原始畫布上所有可能的星星,再定義陣列 pointPos 儲存初當前展示的所有星星的座標(以當前canvas畫布左上角的座標為圓點),用於手指滑動過程中判斷是否經過某個點
  • 定義陣列 points 存放畫布上已經連線的星星
  • 設定canvas繪圖的樣式,如連線線的寬度 lineWidth,模糊度 lineBlurWidth,設定canvas連線線色值 strokeStyle = '#c9b8ff',連線線結束時為圓形的線帽 lineCap = 'round' 。
function setData () { // 初始化canvas資料
  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:縱向間距
   * 星星中點x位置: 星星/2 + (星星的尺寸 + 橫向間距)* 前面的星星數量
   * 星星中點y位置: 星星/2 + (星星的尺寸 + 豎向間距)* 前面的星星數量
   * pointPos=所有頁面渲染的星星(x, y)座標
   */
  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) // 可選擇隨機選擇需要顯示星星的數量 getRandom(21, 25, 0)
  const starOriginLen = this.starXNum * this.starYNum
  for (let i = 0; i < starOriginLen; i++) {
    originArr.push(i)
  }
  // 獲取星星展示亂陣列後進行排序重組
  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 手指開始觸控事件

監聽手指開始觸控事件:

  • 判斷手指開始觸控的位置是否正好是某顆星星座標位置。這裡首先需要通過 getBoundingClientRect ➚ 方法獲取canvas畫布相對於整個視口的圓點 (x, y) ,然後將當前觸控點減去圓點位置,即可得當前手指所在點的座標;
  • 通過 indexOfPoint 方法將當前座標與 pointPos 陣列中的星星座標進行匹配,判斷是否要進行canvas畫線,當匹配成功,則新增到已連線星星陣列中;
  • 我們限制了每次連線星星的最大數量,因此每次開始連線點時需要 checkLimit() 校驗是否超出最大限制。
  • 變數 reconnectStart 來記錄是否是在畫布上已有星星的基礎上連線的星星
function touchStart (e) {
  if (this.checkLimit()) return
  this.lockScroll()
  const rect = this.$refs.canvas.getBoundingClientRect() // 此處獲取canvas位置,防止頁面捲動時位置發生變化
  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) { // 當前觸控座標點相對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('未找到星星座標')
  // 為了減少計算量,將星星當初正方形計算
  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) { // 校驗手指是否超出canvas區域
  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 監聽手指滑動事件

監聽手指滑動事件:

  • 在手指滑動過程中,獲取每個點的座標(x, y), 判斷該點是否正好為某顆星星的座標位置,再呼叫 draw() 方法畫線。
  • a. 如果沒有移動到星星的位置,則在畫布上畫出上一個連線星星到當前點的對應的軌跡
  • b. 如果移動到了某顆星星的座標範圍,則在上一顆星星和當前星星之間畫一條直線,並將該點新增到 points 陣列中
  • draw 方法中,每次畫線前,需要呼叫canvas的API canvas.clearRect ➚ 清空畫布,抹除上一次的狀態,重新呼叫 drawLine 方法按照 points 陣列中的點順序繪製新的星星連線軌跡。

drawLine中涉及到一些canvas的基本方法和屬性:

  canvas.beginPath() // 表示開始畫線或重置當前路徑
  canvas.moveTo(x, y) // 指定目標路徑的開始位置,不建立線條
  canvas.lineTo(x, y) // 新增一個新點,建立從該點到畫布中最後指定點的線條,不建立線條
  canvas.closePath() // 結束路徑,應與開始路徑呼應
  canvas.stroke() // 實際地繪製出通過 moveTo() 和 lineTo() 方法定義的路徑,預設為黑色
  const grd = canvas.createLinearGradient(x1, y1, x2, y2) // 建立線性漸變的起止座標
  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區域後不鬆手,捲動後頁面位置改變在canvas外其他位置觸發連線
  if (this.checkLimit()) return
  this.lockScroll() // 手指活動過程中禁止頁面捲動
  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) // 線性漸變的起止座標
  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 監聽手指觸控結束事件

手指離開螢幕時, 當前連線星星如果少於兩顆(至少連線兩個點),則清空陣列,否則按照當前已連線的點重新繪製線條,當已連線的點小於最小限制時,給使用者toast提示。

至此,連線星星的基本功能就完成了,還需要進行一些細節的處理。

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}顆星星哦~`)
  }
}

頁面捲動處理

當頁面有卷軸是,連線過程中容易連帶著頁面捲動,導致觸控點錯位,並且使用者體驗不好。解決方案是:每當手指觸控畫布區域開始連線時,先禁止頁面的捲動,當手指放開後或離開畫布後再恢復頁面捲動。

具體程式碼如下:

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
}

連線的兩顆星星之間有其他星星時

如上所示,當連線的兩顆星星路徑上有其他的星星時,視覺上四連線了4顆星星,實際上中間兩顆手指未觸控過的星星並未加入到當前繪製星星的陣列中,這時候如果想要做最大最小星星數量的限制就會失誤,因此這裡通過判斷方向,將中間兩顆星星也接入到已連線星星陣列中,每次 draw() 時判斷一下。

如下列出了連線所有可能的8種情況和處理步驟:

判斷是否有多餘的點

判斷方向 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

給點陣列重新排序

與points合併

長度超出最大限制個則從末尾丟擲

開始畫線

 canvas.isPointInPath(x, y) // 判斷點 (x, y)是否在canvas路徑的區域內
function rearrangePoints () { // 根據最後兩個點之間連線,如果有多出的點進行重排,否則不處理
  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
}

最後找了個星空背景的demo貼到程式碼中,功能就完成了,關於星空背景的實現感興趣的可以自己研究一下。

以上就是JS前端使用Canvas快速實現手勢解鎖特效的詳細內容,更多關於JS前端Canvas手勢解鎖的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com