转发请注明出处: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 = 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 + [email protected]//每个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就是因为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一个再走~~)