当你按下方向键,电视是如何寻找下一个焦点的

时间:2023-02-14 21:55:57

我工作的第一家公司主要做的是一个在智能电视上面运行的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()
    }
}

效果如下:

当你按下方向键,电视是如何寻找下一个焦点的

是不是很简单呢,详细体验可以点击思维导图