1. 背景
在智家电视项目中,主要操作方式不是触摸,而是遥控器,通过Focus进行移动,确定点击进行的交互,所以在电视项目中焦点、选中、确定、返回这几个交互比较重要。由于电视屏比较大,在一些复杂页面中会存在一级Tab选择,二级选择,三级选择等,这就涉及到了焦点与选中的联动实现业务逻辑。这块的逻辑比较复杂,在做好了一个页面后,把这块的内容记录一下,同时提炼出了一个辅助类,MultiLevelFocusHelper,后续可进行复用。
2. 基本使用:遥控器+焦点控制
2.1 使用原则
Android原生就能比较好的支持Focus及切换,使用时只要按照它本身的逻辑使用就好,如果碰到不能很好支撑业务的时候再进行扩展,如下是我们小组实践过后,总结出来的几项原则,实际效果很好:
-
不进行过度控制,使用默认规则
-
使用focusable、descendantFocusability把XML中的控件按照父控件统一管控,如必须下放时再进行子控件控制
-
nextFocusUp、nextFocusDown、nextFocusLeft、nextFocusRight、nextFocusForward这几个属性不要轻易使用,只要在需要定制的复杂页面才有可能用到
2.2 View中涉及到焦点的几个属性
属性 | 使用场景说明 |
focusable | 物理按键时获得焦点的属性 android:focusable="false" android:focusable="true" |
descendantFocusability | 该属性是当一个view获取焦点时,定义viewGroup和其子控件两者之间的关系,属性的值有三种:
|
nextFocusUp nextFocusDown nextFocusLeft nextFocusRight | android:nextFocusUp-定义当点up键时,哪个控件将获得焦点 android:nextFocusDown-定义当点down键时,哪个控件将获得焦点 android:nextFocusLeft-定义当点left键时,哪个控件将获得焦点 android:nextFocusRight--定义当点right键时,哪个控件将获得焦点 |
nextFocusForward | 我是谁,我有什么用??? |
2.3 如何使用
1. XML中从顶到细,一层一层的看,如果此View及其子View不需要获得焦点,则直接把它的焦点屏蔽掉
android:focusable="false"
android:descendantFocusability="blocksDescendants"
2. 如果只有此ViewGroup需要获得焦点,它的子View不需要,则设置如下
android:focusable="true"
android:descendantFocusability="blocksDescendants"
3. RecyclerView或ListView,根据需要,如果是简单的能自动处理的则只修改XML即可,否则可以XML+代码进行控制
// 1. 第一种情况:recyclerView的 xml 设置 recyclerView 不获得焦点,子控件获得焦点
android:focusable="false"
android:descendantFocusability="afterDescendants"
// recyclerView的item 布局中添加
android:focusable="true"
android:descendantFocusability="blocksDescendants"
// 2. 第二种情况:代码控制时, recyclerView先获得焦点,然后根据需要,再在它的OnFocusChangeListener中进行焦点转移
android:focusable="true"
android:descendantFocusability="beforeDescendants"
4. 至此,如果没有特殊的需求,只是简单的焦点控制,通过XML配置属性+Android强大的默认功能即可完成
3. 高级用法:增加层级
3.1 层级是什么? 为什么要有三态?
如图,感兴趣的往下看,一切尽在图中,祭镇楼图
-
图中的设备列表与全屋节能信息构成了一级焦点,后边的节电数据范围是二级焦点,它俩是一个整体,这里暂且起名叫节能数据查看
-
其中全屋节能信息是一个ViewGroup,下边的设备列表是一个RecyclerView
-
图中的帮助按钮是另一个可欺获得焦点的控件,与上边的节能数据查看是并列关系
-
根据以上分析,得出:层级就是 完成同一个功能的多级多控件的可分别获得焦点的聚合体,特点如下:
-
焦点可在多级中的多个控件中自由流转,同时只有一个控件具备焦点
-
在同一级中,如果没有焦点,则需要有一个控件具备已选中状态,由此引出了三态:有焦点、无焦点选中、无焦点未选中
-
焦点在多级流转时有一定的规则,大部分情况下是从一级流向另一级时,优先流到已选中的控件上
-
多级具备方向性,比如1->2->3-4, 或 4->3->2->1, 在这个模型中,不可以跨级流转,如果后续有跨级流转的业务需求,再另说(产品经理不要搞太复杂呀...)
-
3.2 自定义的层级管理辅助类:MultiLevelFocusHelper
基于以上的层级焦点定义,我封装了一个辅助类,MultiLevelFocusHelper,可用于简化层级焦点的操作实现,它主要实现的功能有:
-
当某一层级的控件获得焦点时,通过它可记录最新的有焦点控件,并同时设置其中选中状态
-
设置当前层级有焦点的控件往下一级流转时的按键,并精准定位到下一级的选中控件上
-
获得所有层级的当前控件对应的附加数据
-
遵循了最小实现、不过渡设计的原则,当前只实现了两级,如果将来需要支持更多的级数,可扩展此类
代码如下:
class MultiLevelFocusHelper(private val totalLevel: Int) {
private var mCurLevel1View: View? = null
private var mCurLevel1ViewId: Int? = null
private var mCurLevel1Data: Any? = null
private var mCurLevel2View: View? = null
private var mCurLevel2ViewId: Int? = null
private var mCurLevel2Data: Any? = null
/**
* 某一个控件得到了焦点
* @param level: 得到焦点的控件的层级
* @param view: 得到焦点的控件
* @param viewId: 得到焦点的控件的Id,如果是recycleView,则设置它的父控件的Id,主要是为的是上下级Level切换
* @param extraData: 得到焦点的控件对应的附属数据,暂存一下,后续业务需要时可直接get
* @param nextLevelMoveDirect:
*/
fun receiveFocus(level: Int, view: View, viewId: Int, extraData: Any) {
if (level > totalLevel) return
when(level) {
1 -> {
if (mCurLevel1View != null) {
mCurLevel1View!!.isSelected = false
}
mCurLevel1View = view
mCurLevel1View!!.isSelected = true
mCurLevel1ViewId = viewId
mCurLevel1Data = extraData
}
2 -> {
if (mCurLevel2View != null) {
mCurLevel2View!!.isSelected = false
}
mCurLevel2View = view
mCurLevel2View!!.isSelected = true
mCurLevel2ViewId = viewId
mCurLevel2Data = extraData
}
else -> {
// nothing
}
}
}
/**
* 设置某一层级当前选中View的 nextFocusLeftId, nextFocusDownId 等
* @param moveDirect, 移动方向,用于决定设置当前级Level的哪个属性 nextFocusLeftId 等。 传入值:使用本类中定义的四个常量值,多个方向时可进行&运算再传进来
* @param moveCommander, 移动命令,是前进还是后退,用于确定要设置的Value是上一级还是下一步当前选中的View
* 为 null,忽略,如果是头尾的,只有一个方向,直接设就行。 如果是中间的则忽略不进行设置了。
*/
fun setDirectToCurrentView(level: Int, moveDirect: Int, moveCommander: MoveCommander? = null) {
if (level > totalLevel) return
when(level) {
1 -> {
// 第一层,只能往下移,不能回移
setNextMoveTarget(mCurLevel1View, moveDirect, mCurLevel2ViewId)
}
2 -> {
if (level < totalLevel) {
if (moveCommander != null) {
if (moveCommander == MoveCommander.forward) {
// TODO, 当 totalLevel 大于等于 3 的时候,加上这一个分支, 它应该往 3 去移动了
// setNextMoveTarget(mCurLevel2View, moveDirect, mCurLevel3ViewId)
} else {
setNextMoveTarget(mCurLevel2View, moveDirect, mCurLevel1ViewId)
}
}
} else {
// 这是最后一层, 只有一个方向
setNextMoveTarget(mCurLevel2View, moveDirect, mCurLevel1ViewId)
}
}
else -> {
// nothing
}
}
}
/**
* 所有控件失去焦点, 暂时应该没有场景调到它,如果有的话,需要考虑一下行为是否正确
*/
fun clearAllFocus() {
if (mCurLevel1View != null) {
mCurLevel1View!!.isSelected = false
}
mCurLevel1Data = null
if (mCurLevel2View != null) {
mCurLevel2View!!.isSelected = false
}
mCurLevel2Data = null
}
/**
* 获得某一层当前选中控件对应的 View
*/
fun getView(level: Int): View? {
if (level > totalLevel) return null
return when(level) {
1 -> {
mCurLevel1View
}
2 -> {
mCurLevel2View
}
else -> {
null
}
}
}
/**
* 获得某一层当前选中控件对应的数据
*/
fun getData(level: Int): Any? {
if (level > totalLevel) return null
return when(level) {
1 -> {
mCurLevel1Data
}
2 -> {
mCurLevel2Data
}
else -> {
null
}
}
}
private fun setNextMoveTarget(view: View?, direct: Int?, nextViewId: Int?) {
if (view == null || direct == null || nextViewId == null) {
return
}
if (direct and Direct_Up > 0) {
view.nextFocusUpId = nextViewId
}
if (direct and Direct_Right > 0) {
view.nextFocusRightId = nextViewId
}
if (direct and Direct_Down > 0) {
view.nextFocusDownId = nextViewId
view.nextFocusDownId
}
if (direct and Direct_Left > 0) {
view.nextFocusLeftId = nextViewId
}
}
}
3.3 MultiLevelFocusHelper要点说明
-
构造函数中的参数 totalLevel
-
总级数,从1开始的, 比如totalLevel为3, 则所有级别即为1,2,3
-
目前 totalLevel 最大为 2,超过2 按 2 计算
-
-
对外函数receiveFocus(level: Int, view: View, viewId: Int, extraData: Any)
-
当层级中的某一个控件获得焦点时调用此函数
-
参数说明
-
@param level: 得到焦点的控件的层级
-
@param view: 得到焦点的控件
-
@param viewId: 得到焦点的控件的Id,如果是recycleView,则设置它的父控件的Id,主要是为的是上下级Level切换
-
@param extraData: 得到焦点的控件对应的附属数据,暂存一下,后续业务需要时可直接get
-
-
这里的 viewId 可以是 view 的Id,也可以不是, 基本用法是,如果是ListView或RecyclerView,则可以把viewId设置为 recyclerView 的Id,这样再在业务代码的 recyclerView 获得焦点事件中转一下即可
-
-
层级流转
-
level 移动顺序: 目前是一个约定,不能自定义。 1->2->3->4, 或 4->3->2->1。 如果后续有不同需求,可以再进行扩充
-
两个概念:MoveCommander, MoveDirect:
// 层级移动命令,向前进,还是后退,参考按照类说明了中的移动顺序 enum class MoveCommander { forward, back } // 焦点移动方向,比如按了遥控器上的上下左右, 使用Int值表示, 多个方向时可以进行&运算 val Direct_Up = 0x01 val Direct_Right = 0x02 val Direct_Down = 0x04 val Direct_Left = 0x08
-
对外函数:setDirectToCurrentView(level: Int, moveDirect: Int, moveCommander: MoveCommander? = null)
-
设置某一层级当前选中View的 nextFocusLeftId, nextFocusDownId 等,当某一个控件获得焦点后,再马上调用此函数设置一下
-
参数说明
-
moveDirect, 移动方向,用于决定设置当前级Level的哪个属性 nextFocusLeftId 等。 传入值:使用本类中定义的四个常量值,多个方向时可进行&运算再传进来
-
moveCommander, 移动命令,是前进还是后退,用于确定要设置的Value是上一级还是下一步当前选中的View为 null,忽略,如果是头尾的,只有一个方向,直接设就行。 如果是中间的则忽略不进行设置了
-
-
-
4. 使用实例
这里附上全屋节能的使用示例,它结合了 MultiLevelFocusHelper,并在Activity中实现了业务关联的一部分代码
4.1 相关控件的XML设置
-
设置所有没有焦点的控件中的属性, focusable 和 descendantFocusability
-
有焦点的控件属性设置上, focusable 和 descendantFocusability
-
recyclerView 设置为: android:focusable="true" android:descendantFocusability="beforeDescendants"
4.2 帮助按钮的Focus监听不必设置,使用系统默认的即可
4.3 初始化时,把默认的Focus给到 一级中的全屋信息
mMultiLevelFocusHelper.receiveFocus(1, mFullHouseSaveInfo, mFullHouseSaveInfo.id, "all") // 初始一化一下 mMultiLevelFocusChangeManager 中的状态
mMultiLevelFocusHelper.setDirectToCurrentView(1, MultiLevelFocusHelper.Direct_Right)
mMultiLevelFocusHelper.receiveFocus(2, mTextViewSaveElectricDurationLastMonth, mTextViewSaveElectricDurationLastMonth.id, ElectricIndexDateRange.LAST_MONTH)
mMultiLevelFocusHelper.setDirectToCurrentView(2, MultiLevelFocusHelper.Direct_Down)
mFullHouseSaveInfo.requestFocus()
4.4 RecyclerView 和 它的 item 设置 OnFocusChangeListener
mRecyclerViewDeviceDetailInfo.setOnFocusChangeListener(object : OnFocusChangeListener {
override fun onFocusChange(v: View?, hasFocus: Boolean) {
if (v == null) return
if (!hasFocus) return
val view = mMultiLevelFocusHelper.getView(1)
val tag = view?.getTag() // 看它有没有存 tag 来判断它是不是 recyclerView 的 item
if (view == null || tag == null) {
// 没有上一次的View 或 上一次的第一层View 不是 recyclerView的 item 时
if (mRecyclerViewDeviceDetailInfo.getChildAt(0) != null) {
mRecyclerViewDeviceDetailInfo.getChildAt(0).requestFocus()
}
} else {
view.requestFocus()
}
}
})
// 这里的最后一个参数 OnFocusChangeListener, 内部又传给了 item, 当它有 FocusChange事件时,再转调用此参数实例
mAdapterDeviceDetailInfo = SaveEnergyAdapterDeviceDetailInfo(
mViewModal.getAllSavingDevice(),
mViewModal.getAllSavingDeviceRank(),
mViewModal.getAllSavingSwitchStatus(),
object: OnFocusChangeListener {
// 给 设备列表的 recycleview item 设置焦点移动回调
override fun onFocusChange(v: View?, hasFocus: Boolean) {
if (v == null) {
return
}
if (!hasFocus) {
return
}
val deviceId = v.getTag()
mMultiLevelFocusHelper.receiveFocus(1, v, mRecyclerViewDeviceDetailInfo.id, deviceId)
mMultiLevelFocusHelper.setDirectToCurrentView(1, MultiLevelFocusHelper.Direct_Right)
initSavingElectricData()
}
})
这里啰嗦一下,RecyclerView拿到焦点时,把焦点转给它下边的之前具有焦点的控件;item中的view有一个tag,存的是业务数据(deviceId),当它拿到焦点时,取到此业务数据,传入到了 mMultiLevelFocusHelper 中
4.5 设置全屋信息 和 所有二级控件的 setOnFocusChangeListener,代码略
5. 总结
-
如果没有特殊的需求,只是简单的焦点控制,通过XML配置属性+Android强大的默认功能即可完成。
-
如果具有多个层级,焦点需要在多层级间进行流转并需要记忆功能,则可使用MultiLevelFocusHelper类,经过实践检验,可完美应用于此场景。
6. 团队介绍
「三翼鸟数字化技术平台-场景设计交互平台」主要负责设计工具的研发,包括营销设计工具、家电VR设计和展示、水电暖通前置设计能力,研发并沉淀素材库,构建家居家装素材库,集成户型库、全品类产品库、设计方案库、生产工艺模型,打造基于户型和风格的AI设计能力,快速生成算量和报价;同时研发了门店设计师中心和项目中心,包括设计师管理能力和项目经理管理能力。实现了场景全生命周期管理,同时为水,空气,厨房等产业提供商机管理工具,从而实现了以场景贯穿的B端C端全流程系统。