教你一款模拟焦点切换的js插件,让遥控器愉快地操作网页

1,565 阅读4分钟

本文教你开发一款模拟焦点切换的 JavaScript 插件,简称焦点插件。它可以广泛应用在TV、机顶盒等终端设备的网页应用上,非常适用于采用遥控器或键盘为交互方式的使用场景中。

该插件主要功能和特点:

  • 支持手动指定目标焦点
  • 支持自动寻找最优焦点
  • 支持动态切换寻焦策略
  • 不依赖第三方库,体积小,使用简单

用到的主要技术栈:

  • TypeScript
  • rollup
  • 原生DOM API

效果演示

快速使用

<head>
    ...省略非重要代码...
    <style type="text/css">
        .item {
          width: 120px;
          height: 60px;
          line-height: 60px;
          background-color: #ced4da;
          text-align: center;
        }
        /* 焦点库默认落焦class */
        .btn-focus {background-color: #fab005;}
        #f1 {position: absolute;left: 100px;top: 200px;}
        #f2 {position: absolute;left: 500px;top: 200px;}
        #f3 {position: absolute;left: 300px;top: 300px;}
    </style>
</head>
<body>
    <!-- rightTarget="f3"的含义:当焦点在f1元素时,按向右键,焦点将落在id为f3元素上。 -->
    <div id="f1" class="item focus_btn" rightTarget="f3">F1(default)</div>
    <div id="f2" class="item focus_btn">F2</div>
    <div id="f3" class="item focus_btn">F3</div>

    <script type="text/javascript" src="js库路径"></script>
    <script>
        // 使用非常简单,圈定可落焦元素,且初始化落焦在id为f1元素上。
        ccFocus.init('focus_btn', 'f1')
    </script>
</body>

上面代码效果如下:

如何开发

1. 开发思路和说明

几点说明:

  • 可落焦元素:指定义了相同class,并在焦点插件初始化时设置该class的一组元素,它们将纳入焦点插件管理之中。注意:不可见元素会被排除
  • 指定焦点:元素设置upTarget、downTarget、leftTarget、rightTarget属性时,表示按相应方向键设置了指定焦点,属性值是元素id,不带 #号;
  • 自动寻焦:当元素没有指定焦点,按方向键时插件会根据寻焦策略寻找下一个落焦元素,这部分下节将详解;
  • 落焦样式:默认的落焦class选择器名称是btn-focus,也支持自定义class,需要在焦点插件初始化时设置;
  • 派发click事件:用遥控器按确定键或用键盘敲回车键时,会对落焦元素派发click事件,使用者可监听该事件做业务逻辑处理;
  • 其他事件:元素落焦时会派发focus事件,失焦时会派发blur事件,图中暂未体现。
2. 自动寻焦策略

目前焦点插件支持两种寻焦策略:距离优先策略(默认)重叠优先策略

下面举一个🌰,以操作"向右"为例(其他方向基本逻辑一致),对两种寻焦策略作一个对比说明:

上图中有4个可落焦元素,当前落焦元素是F1,其他3个可落焦元素F2F3F4均在F1右侧,蓝色斜杠区域表示F1F3Y轴上有重叠部分,此时用户操作"向右",下一落焦元素会是哪一个?

距离优先策略的寻焦流程:

结果显而易见,当采用距离优先策略时,下一个落焦元素是F4

重叠优先策略的寻焦流程:

  • 结果同样显而易见,当采用重叠优先策略时,下一个落焦元素是F3。当存在多个重叠元素时,距离最短的是最优元素。

当然,大家也可根据具体使用场景自定义自动寻焦策略。

3. 代码设计
// index.ts
class CCFocus {
    static instance: CCFocus
    private focusableEles: HTMLCollectionOf<Element> // 可落焦元素集合
    private curFocusEle: Element
    private focusStyleClass: string = 'btn-focus'
    private strategy: Strategy

    static get() {
        if (!CCFocus.instance) {
            CCFocus.instance = new CCFocus()
        }
        return CCFocus.instance
    }

    private constructor() {
        log.info('CCFocus constructor')
        this.strategy = StrategyDistance.get()
    }

    /**
     * @description: 焦点插件初始化
     * @param {string} focusClass 用于圈定落焦元素集合
     * @param {string} curEleId 初始化时的落焦元素
     * @param {string} focusStyle 指定落焦样式class名称
     * @return {*}
     */
    public init(focusClass: string, curEleId?: string, focusStyle?: string): CCFocus {

        // 监听onkeydown事件
        window && (window.onkeydown = (ev) => {
            this._onKeydown(ev)
        })
        // 设置可落焦元素集合
        this.focusableEles = document.getElementsByClassName(focusClass)
        if (this.focusableEles.length === 0) {
            // 当没找到可落焦元素时,则落焦到body上
            this.focusableEles = document.getElementsByTagName('body')
            log.warn('focusableEles\' cannot be found', this.focusableEles)
        }
        let _curElement: Element = document.getElementById(curEleId)
        if (!!_curElement) {
            for (let j = 0; j < this.focusableEles.length; j++) {
                if (this.focusableEles[j] == _curElement) {
                    this.curFocusEle = _curElement
                    break
                }
            }
        }
        // 如没找到指定落焦元素,则将可落焦元素集合中第一个可见元素设置为初始化落焦元素
        if (this.curFocusEle == null) {
            log.info('the curElement cannot be found')
            for (let k = 0; k < this.focusableEles.length; k++) {
                if (isVisible(this.focusableEles[k])) {
                    this.curFocusEle = this.focusableEles[k]
                    break
                }
            }
        }
        // 设置落焦样式
        this.focusStyleClass = focusStyle == null ? 'btn-focus' : focusStyle
        this._setFocusStyle()
        return this
    }
    // 处理按键事件
    private _onKeydown(ev): void {
        const lastFocusEle = this.curFocusEle
        switch (ev.keyCode) {
            case 37: // 向左
                this._moveLeft();
                ev.stopPropagation();
                break;
            case 38: // 向上
                this._moveUp();
                ev.stopPropagation();
                break;
            case 39: // 向右
                this._moveRight();
                ev.stopPropagation();
                break;
            case 40: // 向下
                this._moveDown();
                ev.stopPropagation();
                break;
            case 13: // 确定
                this._dispatchEvent(EVENT.CLICK)
                break;
        }
        if (lastFocusEle !== this.curFocusEle) {
            // 派发事件
            this._dispatchEvent(EVENT.BLUR, lastFocusEle)
            this._dispatchEvent(EVENT.FOCUS)
        }
    }
    private _dispatchEvent(evtName: EVENT, ele?: Element) {
        const ev = document.createEvent('HTMLEvents')
        ev.initEvent(evtName, false, false)
        if (ele != null) ele.dispatchEvent(ev)
        else this.curFocusEle.dispatchEvent(ev)
    }
    private _moveUp(): void {
        // 根据当前策略获取向上时最优落焦元素
        let _target = this.strategy.getUpTarget(this.focusableEles, this.curFocusEle) 
        if (_target !== null) {
            this.curFocusEle = _target
            this._setFocusStyle()
        } else {
            console.log('没找到向上目标元素')
        }
    }
    private _moveDown(): void {
        // 根据当前策略获取向下时最优落焦元素
        let _target = this.strategy.getDownTarget(this.focusableEles, this.curFocusEle)
        if (_target !== null) {
            this.curFocusEle = _target
            this._setFocusStyle()
        } else {
            console.log('没找到向下目标元素')
        }
    }
    private _moveLeft(): void {
        // 根据当前策略获取向左时最优落焦元素
        let _target = this.strategy.getLeftTarget(this.focusableEles, this.curFocusEle)
        if (_target !== null) {
            this.curFocusEle = _target
            this._setFocusStyle()
        } else {
            console.log('没找到向左目标元素')
        }
    }
    private _moveRight(): void {
        // 根据当前策略获取向右时最优落焦元素
        let _target = this.strategy.getRightTarget(this.focusableEles, this.curFocusEle)
        if (_target !== null) {
            this.curFocusEle = _target
            this._setFocusStyle()
        } else {
            console.log('没找到向右目标元素')
        }
    }
    // 设置元素落焦样式
    private _setFocusStyle(): void {
        if (this.curFocusEle == null) {
            for (let i = 0; i < this.focusableEles.length; i++) {
                if (isVisible(this.focusableEles[i])) {
                    this.curFocusEle = this.focusableEles[i]
                    break
                }
            }
        }
        for (let j = 0; j < this.focusableEles.length; j++) {
            this.focusableEles[j].classList.remove(this.focusStyleClass)
        }
        this.curFocusEle.classList.add(this.focusStyleClass)
    }
    /**
     * @description: 设置自动寻找最佳元素的策略
     * @param {number} 0: 距离优先策略(默认) 1: 重叠优先策略
     * @return 链式调用
     */
    public setStrategy(which: number): CCFocus {
        switch (which) {
            case 0:
                this.strategy = StrategyDistance.get()
                break
            case 1:
                this.strategy = strategyCollision.get()
                break
            case 2:
                // 可自定义寻焦策略
                break
            default:
                this.strategy = StrategyDistance.get()
        }
        return this
    }
}

export default CCFocus.get()
// strategyDistance 距离优先策略实现类
export class StrategyDistance extends Strategy {

    static instance: StrategyDistance
    static get() {
        if (!StrategyDistance.instance) {
            StrategyDistance.instance = new StrategyDistance()
        }
        return StrategyDistance.instance
    }
    private constructor() {
        super()
        log.info('StrategyDistance constructor')
    }
    /**
     * @description: 向右移动,寻找最优落焦元素
     * @param {HTMLCollectionOf} 可落焦元素列表
     * @param {Element} 当前落焦元素
     * @return 最佳落焦元素
     */
    getRightTarget(focusableEles: HTMLCollectionOf<Element>, curFocusEle: Element): null | Element {

        // 如显示声明rightTarget,则直接获取指定焦点,没找到直接返回,不再走自动寻焦逻辑
        if (curFocusEle.getAttribute(Direction.RIGHT)) {
            const targetId: string = curFocusEle.getAttribute(Direction.RIGHT)
            if (targetId === '#') return null
            if (!focusableEles.namedItem(targetId)) return null
            const target: Element = document.getElementById(targetId)
            if (!target) return null
            return target
        }

        // 自动寻焦逻辑,获取当前落焦元素位置和大小
        const _curFocusEleRect = curFocusEle.getBoundingClientRect()
        const _curFocusEleTop = _curFocusEleRect.top,
            _curFocusEleLeft = _curFocusEleRect.left,
            _curFocusEleHeight = _curFocusEleRect.height,
            _curFocusEleWidth = _curFocusEleRect.width,
            _curFocusEleX = _curFocusEleLeft + _curFocusEleWidth / 2,
            _curFocusEleY = _curFocusEleTop + _curFocusEleHeight / 2
        // 定义保存距离的变量
        let dist = 99999, _tempTarget = null
        const len = focusableEles.length
        for (let i = 0; i < len; i++) { // 遍历可落焦元素集合
            const _ele = focusableEles[i]
            let _dist = 0
            console.log('遍历的当前元素', i, _ele)
            if (_ele !== curFocusEle) {
                const _eleRect = _ele.getBoundingClientRect()
                const _eleTop = _eleRect.top,
                    _eleLeft = _eleRect.left,
                    _eleHeight = _eleRect.height

                let upCollision = _eleTop <= _curFocusEleTop && _eleTop + _eleHeight > _curFocusEleTop
                let downCollision = _eleTop >= _curFocusEleTop && _eleTop < _curFocusEleTop + _curFocusEleHeight
                if (_eleLeft > _curFocusEleLeft && (upCollision || downCollision)) {
                    _dist = _eleLeft - _curFocusEleX
                    if (_dist < dist) {
                        dist = _dist
                        _tempTarget = _ele
                    }
                    console.log('检测有重叠区域,只计算X轴距离', _dist, dist)
                } else if (_eleLeft > _curFocusEleLeft) {
                    //如果在目标上边,计算目标元素左下角与当前元素中心点距离;如果在下面,则计算目标元素左上角与当前元素中心点距离
                    if (_eleTop >= _curFocusEleTop)
                        _dist = getAbsDistance(_eleLeft, _eleTop, _curFocusEleX, _curFocusEleY)
                    else
                        _dist = getAbsDistance(_eleLeft, _eleTop + _eleHeight, _curFocusEleX, _curFocusEleY)

                    if (_dist < dist) {
                        dist = _dist
                        _tempTarget = _ele
                    }
                    console.log('检测无重叠区域,计算距离', _dist, dist)
                } else {
                    console.log('检测不在目标元素右边')
                }
            } else {
                console.log('遍历到自身元素,跳过')
            }
        }
        return _tempTarget
    }
    // 向上、向下、向左的代码实现此处省略,思路有向右处理一致。
    getUpTarget(focusableEles: HTMLCollectionOf<Element>, curFocusEle: Element): null | Element {}
    getDownTarget(focusableEles: HTMLCollectionOf<Element>, curFocusEle: Element): null | Element {}
    getLeftTarget(focusableEles: HTMLCollectionOf<Element>, curFocusEle: Element): null | Element {}
}

贴了一些关键代码,代码量不多,逻辑其实也挺简单,结合注释理解起来应该不难,完整代码请参见 github

4. 项目构建
// rollup.config.js
import typescript from 'rollup-plugin-typescript2'
import resolve from 'rollup-plugin-node-resolve'
import { terser } from 'rollup-plugin-terser'

export default {
    input: './src/index.ts',
    output: [{
        file: 'dist/index-umd.js',
        name: 'ccFocus',
        format: 'umd'
    },{
        file: 'dist/index-es.js',
        format: 'es'
    },{
        file: 'dist/index-cjs.js',
        format: 'cjs'
    }],
    
    plugins: [
        typescript(), //转译ts代码
        resolve(),  // 解析外部模块
        terser() // 压缩
    ]
}

  • 要生成打包文件,直接执行rollup -c或在package.json配置scripts都行;
  • 打包后的js文件可上传npmcdn后使用,也可直接本地引用。

结语

如果开发的网页要跑在TV,盒子等设备上,你可以使用该插件,它能满足常用的模拟焦点切换及操作的场景。 当然,该插件存在很多不足,在功能性能、兼容性、集成方式上还有很多可优化改善的地方,本篇文章仅抛砖引玉,提供参考,不喜勿喷。