當你按下方向鍵,電視是如何尋找下一個焦點的

街角小林發表於2023-02-15

我工作的第一家公司主要做的是一個在智慧電視上面執行的APP,其實就是一個安卓APP,也是混合開發的應用,裡面很多頁面是H5開發的。

電視我們都知道,是透過遙控器來操作的,沒有滑鼠也不能觸屏,所以“點選”的操作變成了按遙控器的“上下左右確定”鍵,那麼必然需要一個“焦點”來告訴使用者當前聚焦在哪裡。

當時開發頁面使用的是一個前人開發的焦點庫,這個庫會自己監聽方向鍵並且自動計算下一個聚焦的元素。

為什麼時隔多年會突然想起這個呢,其實是因為最近在給我開源的思維導圖新增方向鍵導航的功能時,想到其實和電視聚焦功能很類似,都是按方向鍵,來計算並且自動聚焦到下一個元素或節點:

那麼如何尋找下一個焦點呢,結合我當時用的焦點庫的原理,接下來實現一下。

1.最簡單的演算法

第一種演算法最簡單,根據方向先找出當前節點該方向所有的其他節點,然後再找出直線距離最近的一個,比如當按下了左方向鍵,下面這些節點都是符合要求的節點:

從中選出最近的一個即為下一個聚焦節點。

節點的位置資訊示意如下:

focus(dir) {
    // 當前聚焦的節點
    let currentActiveNode = this.mindMap.renderer.activeNodeList[0]
    // 當前聚焦節點的位置資訊
    let currentActiveNodeRect = this.getNodeRect(currentActiveNode)
    // 尋找的下一個聚焦節點
    let targetNode = null
    let targetDis = Infinity
    // 儲存並維護距離最近的節點
    let checkNodeDis = (rect, node) => {
        let dis = this.getDistance(currentActiveNodeRect, rect)
        if (dis < targetDis) {
            targetNode = node
            targetDis = dis
        }
    }
    // 1.最簡單的演算法
    this.getFocusNodeBySimpleAlgorithm({
        currentActiveNode,
        currentActiveNodeRect,
        dir,
        checkNodeDis
    })
    // 找到了則讓目標節點聚焦
    if (targetNode) {
        targetNode.active()
    }
}

無論哪種演算法,都是先找出所有符合要求的節點,然後再從中找出和當前聚焦節點距離最近的節點,所以維護最近距離節點的函式是可以複用的,透過引數的形式傳給具體的計算函式。

// 1.最簡單的演算法
getFocusNodeBySimpleAlgorithm({
    currentActiveNode,
    currentActiveNodeRect,
    dir,
    checkNodeDis
}) {
    // 遍歷思維導圖節點樹
    bfsWalk(this.mindMap.renderer.root, node => {
        // 跳過當前聚焦的節點
        if (node === currentActiveNode) return
        // 當前遍歷到的節點的位置資訊
        let rect = this.getNodeRect(node)
        let { left, top, right, bottom } = rect
        let match = false
        // 按下了左方向鍵
        if (dir === 'Left') {
            // 判斷節點是否在當前節點的左側
            match = right <= currentActiveNodeRect.left
            // 按下了右方向鍵
        } else if (dir === 'Right') {
            // 判斷節點是否在當前節點的右側
            match = left >= currentActiveNodeRect.right
            // 按下了上方向鍵
        } else if (dir === 'Up') {
            // 判斷節點是否在當前節點的上面
            match = bottom <= currentActiveNodeRect.top
            // 按下了下方向鍵
        } else if (dir === 'Down') {
            // 判斷節點是否在當前節點的下面
            match = top >= currentActiveNodeRect.bottom
        }
        // 符合要求,判斷是否是最近的節點
        if (match) {
            checkNodeDis(rect, node)
        }
    })
}

效果如下:

基本可以工作,但是可以看到有個很大的缺點,比如按上鍵,我們預期的應該是聚焦到上面的兄弟節點上,但是實際上聚焦到的是子節點:

因為這個子節點確實是在當前節點上面,且距離最近的,那麼怎麼解決這個問題呢,接下來看看第二種演算法。

2.陰影演算法

該演算法也是分別處理四個方向,但是和前面的第一種演算法相比,額外要求節點在指定方向上的延伸需要存在交叉,延伸處可以想象成是節點的陰影,也就是名字的由來:

找出所有存在交叉的節點後也是從中找出距離最近的一個節點作為下一個聚焦節點,修改focus方法,改成使用陰影演算法:

focus(dir) {
    // 當前聚焦的節點
    let currentActiveNode = this.mindMap.renderer.activeNodeList[0]
    // 當前聚焦節點的位置資訊
    let currentActiveNodeRect = this.getNodeRect(currentActiveNode)
    // 尋找的下一個聚焦節點
    // ...
    // 儲存並維護距離最近的節點
    // ...

    // 2.陰影演算法
    this.getFocusNodeByShadowAlgorithm({
        currentActiveNode,
        currentActiveNodeRect,
        dir,
        checkNodeDis
    })

    // 找到了則讓目標節點聚焦
    if (targetNode) {
        targetNode.active()
    }
}
// 2.陰影演算法
getFocusNodeByShadowAlgorithm({
    currentActiveNode,
    currentActiveNodeRect,
    dir,
    checkNodeDis
}) {
    bfsWalk(this.mindMap.renderer.root, node => {
        if (node === currentActiveNode) return
        let rect = this.getNodeRect(node)
        let { left, top, right, bottom } = rect
        let match = false
        if (dir === 'Left') {
            match =
                left < currentActiveNodeRect.left &&
                top < currentActiveNodeRect.bottom &&
                bottom > currentActiveNodeRect.top
        } else if (dir === 'Right') {
            match =
                right > currentActiveNodeRect.right &&
                top < currentActiveNodeRect.bottom &&
                bottom > currentActiveNodeRect.top
        } else if (dir === 'Up') {
            match =
                top < currentActiveNodeRect.top &&
                left < currentActiveNodeRect.right &&
                right > currentActiveNodeRect.left
        } else if (dir === 'Down') {
            match =
                bottom > currentActiveNodeRect.bottom &&
                left < currentActiveNodeRect.right &&
                right > currentActiveNodeRect.left
        }
        if (match) {
            checkNodeDis(rect, node)
        }
    })
}

就是判斷條件增加了是否交叉的比較,效果如下:

可以看到陰影演算法成功解決了前面的跳轉問題,但是它也並不完美,比如下面這種情況按左方向鍵找不到可聚焦節點了:

因為左側沒有存在交叉的節點,但是其實可以聚焦到父節點上,怎麼辦呢,我們先看一下下一種演算法。

3.區域演算法

所謂區域演算法也很簡單,把當前聚焦節點的四周平分成四個區域,對應四個方向,尋找哪個方向的下一個節點就先找出中心點在這個區域的所有節點,再從中選擇距離最近的一個即可:

focus(dir) {
    // 當前聚焦的節點
    let currentActiveNode = this.mindMap.renderer.activeNodeList[0]
    // 當前聚焦節點的位置資訊
    let currentActiveNodeRect = this.getNodeRect(currentActiveNode)
    // 尋找的下一個聚焦節點
    // ...
    // 儲存並維護距離最近的節點
    // ...

    // 3.區域演算法
    this.getFocusNodeByAreaAlgorithm({
        currentActiveNode,
        currentActiveNodeRect,
        dir,
        checkNodeDis
    })

    // 找到了則讓目標節點聚焦
    if (targetNode) {
        targetNode.active()
    }
}
// 3.區域演算法
getFocusNodeByAreaAlgorithm({
    currentActiveNode,
    currentActiveNodeRect,
    dir,
    checkNodeDis
}) {
    // 當前聚焦節點的中心點
    let cX = (currentActiveNodeRect.right + currentActiveNodeRect.left) / 2
    let cY = (currentActiveNodeRect.bottom + currentActiveNodeRect.top) / 2
    bfsWalk(this.mindMap.renderer.root, node => {
        if (node === currentActiveNode) return
        let rect = this.getNodeRect(node)
        let { left, top, right, bottom } = rect
        // 遍歷到的節點的中心點
        let ccX = (right + left) / 2
        let ccY = (bottom + top) / 2
        // 節點的中心點座標和當前聚焦節點的中心點座標的差值
        let offsetX = ccX - cX
        let offsetY = ccY - cY
        if (offsetX === 0 && offsetY === 0) return
        let match = false
        if (dir === 'Left') {
            match = offsetX <= 0 && offsetX <= offsetY && offsetX <= -offsetY
        } else if (dir === 'Right') {
            match = offsetX > 0 && offsetX >= -offsetY && offsetX >= offsetY
        } else if (dir === 'Up') {
            match = offsetY <= 0 && offsetY < offsetX && offsetY < -offsetX
        } else if (dir === 'Down') {
            match = offsetY > 0 && -offsetY < offsetX && offsetY > offsetX
        }
        if (match) {
            checkNodeDis(rect, node)
        }
    })
}

比較的邏輯可以參考下圖:

結合陰影演算法和區域演算法

前面介紹陰影演算法時說了它有一定侷限性,區域演算法計算出的結果則可以對它進行補充,但是理想情況下陰影演算法的結果是最符合我們的預期的,那麼很簡單,我們可以把它們兩個結合起來,調整一下順序,先使用陰影演算法計算節點,如果陰影演算法沒找到,那麼再使用區域演算法尋找節點,簡單演算法也可以加在最後:

focus(dir) {
    // 當前聚焦的節點
    let currentActiveNode = this.mindMap.renderer.activeNodeList[0]
    // 當前聚焦節點的位置資訊
    let currentActiveNodeRect = this.getNodeRect(currentActiveNode)
    // 尋找的下一個聚焦節點
    // ...
    // 儲存並維護距離最近的節點
    // ...

    // 第一優先順序:陰影演算法
    this.getFocusNodeByShadowAlgorithm({
        currentActiveNode,
        currentActiveNodeRect,
        dir,
        checkNodeDis
    })

    // 第二優先順序:區域演算法
    if (!targetNode) {
        this.getFocusNodeByAreaAlgorithm({
            currentActiveNode,
            currentActiveNodeRect,
            dir,
            checkNodeDis
        })
    }

    // 第三優先順序:簡單演算法
    if (!targetNode) {
        this.getFocusNodeBySimpleAlgorithm({
            currentActiveNode,
            currentActiveNodeRect,
            dir,
            checkNodeDis
        })
    }

    // 找到了則讓目標節點聚焦
    if (targetNode) {
        targetNode.active()
    }
}

效果如下:

1.gif

是不是很簡單呢,詳細體驗可以點選思維導圖

相關文章