×

Android TV开发笔记(一) TV导航菜单

96
MrTangFB
2018.03.22 14:32* 字数 1881

  转发请注明出处:https://www.jianshu.com/p/cf818a09f756
  在正文前来个小介绍,笔者的现公司原是做电视实业的,最近公司打算进军智能电视操作系统,笔者负责前端桌面开发,所以对于一些有异于移动端的地方,做一些心得,抱着互相学习的心态,如果有误或者有更好的处理方法,请留言互相交流,如果你喜欢本文章,小礼物就别破费了,给个喜欢,给个关注,是对我最大的支持。
  用过智能电视的都了解,首页无论如何变换,都会有一个导航栏,这个导航栏的作用不仅仅是描述当前页面所属的栏目,还能让用户有目的性的去选择自己所需要的分栏进行切换,可以说是桌面最主要的一个控件,今天我们分析的就是导航栏,首先我们来看一下效果图。

导航栏效果图

  有人看到这效果,可能会嘀咕了,不就是TabLayout么,这么简单,我三句代码就搞定了,你先别忙着右上角把我×了,听我仔细分析下。
  其一,TV端与移动端最大的区别就是交互,在移动端的交互主要是touch事件,而TV端则是key事件,导航栏不止要处理自己内部的key事件,还要与其他内容区域衔接,例如在其上的状态栏,其下的内容区域,同时导航栏自身有无焦点时的状态处理,如果让导航栏每个分栏都单独处理key事件,这无疑会增加很多不可控,因为分栏的数目是会改变的。
  其二,导航栏的下标是一张UI切图,在栏目切换的时候,做缩放旋转位移动画,并随着导航栏有无焦点的状态而显隐,TabLayout并没有对下标做拓展,不去重写的情况下,只能用一条线并且只能控制其高度无法控制宽度。
  所以综上所述,本文将介绍适用于电视的自定义导航栏NavigationLinearLayout,主要还是阐述思路,及在开发过程所遇到的问题。

一、模块初始化

  导航栏与光标看似是一整个模块,但实际做法,文字部分是主要组件,负责排布展示分栏和改变分栏状态,光标则是作为一个附属组件,只根据分栏状态做动画。
  初始化NavigationLinearLayout的时候在xml文件会定义好必要的属性,这个是属于自定义view的一些基础,不熟悉的可以去找下资料,这里就不多说,主要有两个属性需要说明:

    var orderMode: String = ""//item排列模式,"same":固定宽模式,"self":自适应宽模式
    var itemSpace: Int = 0//"same":item宽度,"self":item距左或右宽度(实际每个item间距是两个itemSpace值)

  同时保存了一个map集合,存储的是每个item中点到父布局NavigationLinearLayout左边的距离:

    var mToLeftMap: MutableMap<Int, Int> = HashMap()//存储每个item中点到父布局左边的距离

  数据初始化与数据改变时重新初始化的操作是一样的,遍历数据长度增加删除item,item根据xml所赋值的属性生成并动态设置selector,还原所有的状态,对item进行赋值,同时对每个item的绘制做监听,得到每个item中点到父布局左边的距离,最后根据默认展示的item进行处理:

    private fun initView() {
        if (mToLeftMap.isNotEmpty()) mToLeftMap.clear()//还原状态
        if (mDataList.size > childCount) {
        ...
        } else if (mDataList.size < childCount) {
        ...
        }
        if (mNowPos != -1 && mNowPos < childCount) changeItemState(mNowPos, STATE_NO_SELECT)//还原状态
        for (i in 0..(childCount - 1)) {
            ...
            child.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
                override fun onGlobalLayout() {
                    ...
                    mToLeftMap[i] = child.width / 2 + child.left + this@NavigationLinearLayout.left//每个item中点到父布局左边的距离
                    if (defaultPos == i) {//TODO 如果编辑导航后不要重置pos,可根据实际修改逻辑
                        mNowPos = defaultPos//默认要展示的pos
                        changeItemState(mNowPos, STATE_HAS_SELECT_HAS_fOCUS)//修改默认要展示的pos的状态
                        mToLeftMap[mNowPos]?.let { mNavigationCursorView?.fsatJumpTo(it) }//移动光标
                        mNavigationListener?.onNavigationChange(mNowPos, KeyEvent.KEYCODE_DPAD_LEFT)//展示内容数据,仅仅展示数据,写左右都没问题
                    }
                }
            })
        }
    }

  要说明下,根据默认pos的完成最后初始化做了三步操作:
  (1)changeItemState()这个方法就是改变item的状态,状态有三种:

    const val STATE_NO_SELECT = 666//默认状态
    const val STATE_HAS_SELECT_NO_fOCUS = 667//选中无焦点
    const val STATE_HAS_SELECT_HAS_fOCUS = 668//选中有焦点
    private fun changeItemState(pos: Int, state: Int) {
        ...
            when (state) {
                STATE_NO_SELECT -> {
                    //if (child.scaleX != 1f) ViewCompat.animate(child).scaleX(1f).scaleY(1f).translationZ(0f).start()//TODO BUG
                    ViewCompat.animate(child).scaleX(1f).scaleY(1f).translationZ(0f).start()
                    (child as TextView).setShadowLayer(0f, 0f, 0f, fontColorLight)
                    child.isSelected = false
                }
                STATE_HAS_SELECT_NO_fOCUS -> {
                    if (child.scaleX != 1f) ViewCompat.animate(child).scaleX(1f).scaleY(1f).translationZ(0f).start()
                    if (!child.isSelected) {
                        (child as TextView).setShadowLayer(25f, 0f, 0f, fontColorLight)
                        child.isSelected = true
                    }
                }
                STATE_HAS_SELECT_HAS_fOCUS -> {
                    ViewCompat.animate(child).scaleX(enlargeRate).scaleY(enlargeRate).translationZ(0f).start()
                    if (!child.isSelected) {
                        (child as TextView).setShadowLayer(25f, 0f, 0f, fontColorLight)
                        child.isSelected = true
                    }
                }
            }
    }

  在这遇到一个问题,在代码里面用TODO BUG标明,当焦点在导航栏,比如从影视切换到教育,这时候影视分栏状态是从STATE_HAS_SELECT_HAS_fOCUS变成STATE_NO_SELECT,起初考虑到性能问题,做了判断,只有字体放大过才做还原动画(注释了的那句代码),此时出现bug了:

Bug演示

  在长按快速滑动的时候,放大动画乱了,其实这个bug就是因为item在STATE_HAS_SELECT_HAS_fOCUS状态准备开始做放大动画的瞬间,又马上转变成STATE_NO_SELECT状态,此时child.scaleX是等于1f,加了判断导致缩放动画直接忽略了,而放大动画则开始执行,就导致了出现这个bug,解决办法就是把判断去掉,还是交给系统去自己处理好了,而STATE_HAS_SELECT_NO_fOCUS与STATE_HAS_SELECT_HAS_fOCUS之间的切换因为不涉及到在导航栏长按,亲测过是不会出现那种问题,即使按的速度再快,在还原之前都已经有放大了(PS:我讨厌长按,明明如此完美的逻辑)。
  (2)移动光标,这块在下面介绍光标的时候再说,在这里只需要知道做了这步操作。
  (3)初始化内容数据,在这写了一个listener回调出去专门处理与外界的逻辑,pos用于设置内容数据,keyCode方便控制焦点:

    interface NavigationListener {
        /**
         * @param pos     选中的序号
         * @param keyCode 点击的按键
         */
        fun onNavigationChange(pos: Int, keyCode: Int)
    }

二、key与focus事件设置

  并不是每个分栏单独获取焦点,整个导航栏只有父布局NavigationLinearLayout能获取焦点,事件全部也由父布局处理。
  key事件我把他分为三类:
  (1)切换分栏刷新数据:分栏内切换左、右;
  (2)会导致焦点变化:上、下、左上、右上及跳出导航栏的左右事件等等;
  (3)其他事件:menu,source等不会导致焦点有变化的事件;
  所有事件如果return true了则无系统按键音,需手动调用,同样受到系统设置声音大小或静音的控制(系统源码的按键音也同样是调用了此方法)。

    override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
        if (event?.action == KeyEvent.ACTION_DOWN) {
            when (keyCode) {
                KeyEvent.KEYCODE_DPAD_LEFT -> {
                    if (mNowPos > 0) {
                        changeItemState(mNowPos, STATE_NO_SELECT)
                        changeItemState(--mNowPos, STATE_HAS_SELECT_HAS_fOCUS)
                        mToLeftMap[mNowPos]?.let { mNavigationCursorView?.jumpTo(it) }
                        mNavigationListener?.onNavigationChange(mNowPos, keyCode)
                    }//如果有跳出导航栏的左右事件需求可在次此处else回调出去
                    SoundUtil.playClickSound(this@NavigationLinearLayout)
                    return true//TODO 系统声音会被屏蔽掉
                }
                ...
                KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN -> {//TODO 方向类型的事件,不想系统自动找焦点,可试试return true
                    mNavigationListener?.onNavigationChange(mNowPos, keyCode)
                }
                KeyEvent.KEYCODE_MENU -> {//TODO 非方向类型事件
                    mNavigationListener?.onNavigationChange(mNowPos, keyCode)
                    return true//TODO bug
                }
            }
        }
        ...
    }

  这里又有一个小插曲,具体原因还没搞明白,如果menu事件不返回true,即使不做任何处理,第一次按完menu键,就会导致绝大部分的按键事件全部失效的bug,只有再次按menu或者返回,才恢复正常,我猜是因为系统弹了一层属于menu的view出来,虽然看不到,但是把最上层view改变了所以导致这个bug,这里我直接返回ture,有需要的时候在回调处理即可。
  focus事件的处理相对简单点,只做了改变item状态及控制下标的显隐的操作:

    override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
        changeItemState(mNowPos, if (gainFocus) STATE_HAS_SELECT_HAS_fOCUS else STATE_HAS_SELECT_NO_fOCUS)
        mNavigationCursorView?.visibility = if (gainFocus) View.VISIBLE else View.INVISIBLE
        super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
    }

三、下标的设置

  上文提过下标NavigationCursorView作为导航栏的一个组件,与activity没有任何逻辑交互,只根据导航栏的切换做动画,这里把下标单独在layout文件里面写一个控件,是方便控制下标的距离导航栏的位置,甚至可以与导航栏垂直居中,作为背景展示不一样的效果,如果是不需要光标的时候,xml里面注释掉控件,再把关联的那句代码注释,搞定,并不需要修改其他任何的地方,再者,如果有两个地方都需要用到导航栏,并且不一样动画,直接继承重写一下生成动画的方法即可,拓展起来比较方便。
  NavigationCursorView里面比较简单,只有3个方法,fsatJumpTo()方法是初始化的时候用的,jumpTo()是正常切换的时候调用,还有一个就是createAnimator()生成动画的方法。
  每次初始化或切换时,会传目标分栏中点距离NavigationLinearLayout左边的值,用于确定光标中点做位移动画的目标位置,同时保存本地作为下次位移的初始值使用,实际上做动画的时候,由于动画的相对坐标是控件的左上角(0,0)坐标,因此实际位置还需减去光标的宽度的一半,才是设置给位移动画的目标位移值。

    val realLocation = location - width / 2

四、在activity的调用

  调用非常简单,就三行代码,调用顺序已经做了兼容处理所以怎么调都行,光标默认是隐藏可以在需要的时候再设置展示出来,也可以先初始化好导航栏,需要设置数据的时候再设置监听(这个是YY出来的,一般没这种需求吧)。

    mNavigationLinearLayout_id.mDataList = arrayListOf("我的电视", "影视", ...)
    mNavigationLinearLayout_id.mNavigationListener = mNavigationListener
    mNavigationLinearLayout_id.mNavigationCursorView = mNavigationCursorView_id

  同时还模拟了在内容区域切换分栏,分栏切换时刷新内容区域,模拟用户重新编辑导航栏数据后刷新的场景,这里的状态栏跟内容区域只用了一个TextView模拟,实际上是要复杂得多,这个就看各自的产品需求然后各自各精彩吧。

五、后记

  到此整个控件已经介绍完毕,再砸一个彩蛋,如果你的需求是导航栏不止一屏,需要滑动的话,臣妾做不到!这就是把整个导航栏当作一个view来获取焦点的弊端,后期有空再研究改进,接下来要先写桌面开发的其他模块了,毕竟公司的开发进度要紧,后面还会整理然后写一系列关于TV开发的文章。

整体效果图演示

  最重要的当然是效果图及源码啦,没源码说个蛋,是吧。
  源码截我Java和Kotlin双版本 (还不习惯Kotln的可以看Java版源码)
  (都看到这了,客官何不star一个再走~~)

Android TV开发笔记
Web note ad 1