Android软件适配遥控器需求-案例经验分享
不分大屏产品需要有遥控器功能,这里分享部分实战经验
文章目录
- 前言
- 一、案例部分效果图
- 二、项目基础架构
- 三、焦点基础知识
- 适配遥控器基础-焦点问题
- 焦点管理
- 明确焦点状态
- 布局实现
- 硬编码实现
- 引入第三方自定义组件实现
- 焦点顺序
- 作用
- 初始焦点 requestFocus
- 按键处理
- 获取当前焦点
- 四、实际开发技能分享
- 处理焦点注意实现
- RecycleView 案例分析
- 总结
前言
十多年的Android软件开发中,基本上都是做方案上的软件产品。 对于 电视、投影、闺蜜机 上面的软件 都有遥控器控制的需求,就需要自己的Android App能够受遥控器控制。 这里举一个案例,分享一下开发中的部分经验。 也方便自己下次开发直接复用经验,高效开发。
一、案例部分效果图
当前分享案例中部分效果图如下
二、项目基础架构
为什么要简单列举一下架构图,其实不同的UI架构会遇到各种不一样的问题,这里针对性的从列举项目上展示一下架构,方便分析和理解部分阐明的问题
三、焦点基础知识
适配遥控器基础-焦点问题
焦点管理
软件App 适配遥控器,需要用遥控器的功能,实际上就是处理焦点问题。当UI获取焦点时候、用遥控器上下左右按键移动到某一个UI图标识货,UI图标必须差异化显示出来,表示选中状态,进而遥控器点击ok 等,实际上就是点击这个UI的操作。
明确焦点状态
确保UI元素有清晰的焦点视觉效果(放大、边框、阴影等)
如上描述,其实就是一个UI组件选中的效果。 这里我理解有三种表现形式
布局实现
比如我们开发中常见的,在获取焦点 state_focused = true ,时候给于不同的背景、颜色 等突出显示出来。
<Buttonandroid:id="@+id/button"android:focusable="true"android:background="@drawable/button_background"/><!-- drawable/button_background.xml -->
<selector xmlns:android="http://schemas.android.com/apk/res/android"><item android:state_focused="true" android:drawable="@drawable/button_focused"/><item android:drawable="@drawable/button_normal"/>
</selector>
硬编码实现
这里举个例子如下,设置UI组件的FocusChange 事件,对获取焦点和失去焦点进行UI不同渲染,达到焦点选中效果,无交点正常显示效果。
binding.selectOk.setOnFocusChangeListener { v, hasFocus ->val roundView: RoundTextView = v as RoundTextViewif (hasFocus) {roundView.setStrokeWidthColor(5f, Color.parseColor("#EEEE00"))} else {roundView.setStrokeWidthColor(5f, Color.parseColor("#00000000"))}}holder.vb.root.setOnFocusChangeListener { v, hasFocus ->val roundView: RoundConstraintLayout = v as RoundConstraintLayoutif(hasFocus){roundView.setStrokeWidthColor(5f, Color.parseColor("#EEEE00"))focusPos=positionLog.d(TAG," focusPos:${focusPos}")RightAppInfoLiveData.value= RightAppInfo(focusPos,mCenterAppDataList.size)}else{roundView.setStrokeWidthColor(5f, Color.parseColor("#00000000"))Log.d(TAG,"no focusPos:${focusPos}")focusPos=-1RightAppInfoLiveData.value= RightAppInfo(focusPos,mCenterAppDataList.size)}}
引入第三方自定义组件实现
只是作为一个UI组件使用,第三方组件和核心功能就是在获取焦点时候突出显示而已,和 布局表现及 硬编码实现方式并无区别。
焦点顺序
设置合理的焦点移动顺序(android:nextFocusUp/Down/Left/Right),为什么要有这个东西呢? 举例在架构图中,分三页面,无论那个页面都有很多UI组件,如何实现遥控器按 上、下、左、右按键时候,UI组件选中按照自己意愿活着业务定义来切换不同UI选中状态呢?
这个时候,焦点移动顺序就起到作用了,下面列举一下实际用法,其实就是在布局文件中设置的。
作用
- 实现UI焦点移动顺序
- 对边角的UI组件,指向自己,这样就可以规避焦点不见了的问题,规避反人类的体验。
实际使用 简单 如下:
初始焦点 requestFocus
为什么会有这个方法, 为什么需要? 比如说 进行界面切换的时候,如架构图中从一个界面切换到另外一个界面、点击一个图标进入另外一个界面。 在新的界面焦点在哪里是不确定的或者说在新的界面,是没有焦点的。 那么最好初始化一个UI具备焦点。 这样遥控器按键时候直接上下左右进行切换,规避没有焦点时候或者焦点不确定时候需要多按好多次 才有UI获取焦点显示出来,体验和业务需要的。
比如,架构图中,第一页切换到第二页、第二页切换到第三页、第三页切换到第二页、第二页切换到第一页,如何实现焦点初始化呢?
在 页面onResume 方法中,让指定的UI初始化一次焦点,去获取焦点一次。
按键处理
我们为什么需要处理按键,遥控器的本身其实就是KeyEvent 事件,映射的其实就是物理按键的功能。 那么当keyevent 事件通过遥控器触发后,做什么业务逻辑,那就是上层需要处理的事情了。 这就是为什么我们需要按键处理了。
对于很多遥控器,基本功能通用;专用的KeyEvent 是通过定制实现(比如 点击遥控器一个按键就是要直接打开抖音、长按遥控器进行语音控制呀,这些就是定制的功能)
下面代码列举了部分源码,监听左右按键最核心的功能是 需要翻页的功能。 遥控器没有翻页的物理按键,通过当前焦点,是否是边角焦点结合按键监听方向,实现是否翻页、翻到哪一页的功能。
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {when (keyCode) {KeyEvent.KEYCODE_DPAD_UP -> {Log.d(TAG," KEYCODE_DPAD_UP")val rootview = window.decorViewval focusView = rootview.findFocus()Log.i(TAG, "===当前获取焦点的View===${focusView}")//return true // 返回true表示事件已被处理val focused = currentFocusfocusView?.let {if(viewPager.currentItem==1&&(focusView.id== R.id.cl_homeleft_second||focusView.id== R.id.cl_homeleft_center||focusView.id== R.id.cl_homeleft_first )){Log.d(TAG,"KEYCODE_DPAD_UP 在vp 第2 页面,但是焦点却在第一页面,那么 request 一次")//homeCenterFragment.binding.clTouping.requestFocus()try{homeCenterFragment.viewBinding?.let { vBing->vBing.clTouping.requestFocus()}}catch (e:Exception){Log.d(TAG," 暂时 未初始化 homeCenterFragment.viewBinding ")e.printStackTrace()}// viewPager.currentItem=1}}}KeyEvent.KEYCODE_DPAD_DOWN -> {Log.d(TAG," KEYCODE_DPAD_DOWN")val rootview = window.decorViewval focusView = rootview.findFocus()val focused = currentFocusLog.i(TAG, "===当前获取焦点的View===${focusView}")focusView?.let {if(viewPager.currentItem==1&&(focusView.id== R.id.cl_homeleft_second||focusView.id== R.id.cl_homeleft_center||focusView.id== R.id.cl_homeleft_first )){Log.d(TAG,"KEYCODE_DPAD_DOWN 在vp 第2 页面,但是焦点却在第一页面,那么 request 一次")//homeCenterFragment.binding.clTouping.requestFocus()// viewPager.requestFocus()try{homeCenterFragment.viewBinding?.let { vBing->vBing.clTouping.requestFocus()}}catch (e:Exception){Log.d(TAG," 暂时 未初始化 homeCenterFragment.viewBinding ")e.printStackTrace()}//viewPager.currentItem=1}}}KeyEvent.KEYCODE_DPAD_LEFT -> {Log.d(TAG," KEYCODE_DPAD_LEFT")val rootview = window.decorViewval focusView = rootview.findFocus()Log.i(TAG, "===当前获取焦点的View===${focusView}")focusView?.let {if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {if(focusView.id== R.id.cl_clock||focusView.id== R.id.cl_touping||focusView.id== R.id.cl_file){viewPager.currentItem=0}else if(focusView.id==R.id.cl_whiteboard){viewPager.currentItem=1}if(viewPager.currentItem==2){Log.d(TAG,"homeRightFragment.centerAppAdapter.focusPos focusPos:${homeRightFragment.centerAppAdapter.focusPos}")// if(homeRightFragment.centerAppAdapter.focusPos%8==0){if((homeRightFragment.centerAppAdapter.focusPos)%8==0){viewPager.currentItem=1}Log.d(TAG," 当前是在 vp currentItem =2 下")try {val rightAppInfo: RightAppInfo? = viewModel.rightAppInfoLiveData.valueLog.d(TAG," rightAppInfo:${Gson().toJson(rightAppInfo)}")rightAppInfo?.let { it->if( it.index%8==0){viewPager.currentItem=1}}} catch (e: Exception) {e.printStackTrace()}}} else if(resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) {if(focusView.id== R.id.cl_clock||focusView.id== R.id.cl_touping||focusView.id== R.id.cl_bizhi||focusView.id== R.id.cl_googleplay||focusView.id== R.id.cl_file){viewPager.currentItem=0}else if(focusView.id==R.id.cl_whiteboard){viewPager.currentItem=1}if(viewPager.currentItem==2){Log.d(TAG,"homeRightFragment.centerAppAdapter.focusPos focusPos:${homeRightFragment.centerAppAdapter.focusPos}")// if(homeRightFragment.centerAppAdapter.focusPos%4==0){if((homeRightFragment.centerAppAdapter.focusPos)%4==0){viewPager.currentItem=1}Log.d(TAG," 当前是在 vp currentItem =2 下")try {val rightAppInfo: RightAppInfo? = viewModel.rightAppInfoLiveData.valueLog.d(TAG," rightAppInfo:${Gson().toJson(rightAppInfo)}")rightAppInfo?.let { it->if( it.index%4==0){viewPager.currentItem=1}}} catch (e: Exception) {e.printStackTrace()}}}}}KeyEvent.KEYCODE_DPAD_RIGHT -> {Log.d(TAG," KEYCODE_DPAD_RIGHT ")val rootview = window.decorViewval focusView = rootview.findFocus()Log.i(TAG, "===当前获取焦点的View===${focusView}")focusView?.let {if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {if(focusView.id== R.id.cl_homeleft_second){viewPager.currentItem=1}else if(focusView.id== R.id.cl_home_listapp||focusView.id== R.id.cl_touping||focusView.id== R.id.cl_music){viewPager.currentItem=2}} else if(resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) {if(focusView.id== R.id.cl_homeleft_second||focusView.id== R.id.cl_homeleft_center||focusView.id== R.id.cl_homeleft_first ){viewPager.currentItem=1}else if(focusView.id== R.id.cl_home_listapp||focusView.id== R.id.cl_music||focusView.id== R.id.cl_touping||focusView.id== R.id.cl_home_healsound){viewPager.currentItem=2}}if(viewPager.currentItem==1){Log.d(TAG," 当前是在 vp currentItem =1 下")try {val centerShutIndexInfo: CenterShutIndexInfo? = viewModel.centerShutIndexInfoLiveData.valueLog.d(TAG," centerShutIndexInfo:${Gson().toJson(centerShutIndexInfo)}")centerShutIndexInfo?.let { it->if((it.index+1)==it.totalNum){viewPager.currentItem=2}}} catch (e: Exception) {e.printStackTrace()}}}}}return super.onKeyDown(keyCode, event)}
获取当前焦点
在开发遥控器控制过程中,最重要的就是知道当前焦点是哪里,这样才能分析各种不可变的bug,只有找到了焦点的位置,针对性解决焦点问题。
这里监听窗体的焦点事件,在监听KeyEvent 事件响应地方也有相关代码的。
window.decorView.findFocus()?.let { focusedView ->Log.d(TAG, "decorView 焦点 View 信息: 类名: ${focusedView.javaClass.name}")}
四、实际开发技能分享
假使你已经具备了上面的基础知识,实际在项目项目中还是会被焦点问题搞得焦头烂额、无从下手,遇到问题针对性解决。 这里给出自己的部分经验。
处理焦点注意实现
- 需要获取焦点UI组件,设置为android:focusable=“true” , 不需要获取焦点的组件设置为 false
- 给每个界面显示的时候设置初始化焦点,如上 onResume 方法中,给对应的UI组件 requestFocus() 一次
- 给组件设置holder.vb.root.setOnFocusChangeListener 监听事件,有焦点和无焦点情绪下显示不同效果。
- 监听onKeyDown 事件,结合自己的软件业务,实现不同的业务需求。
- 注意在边角的UI组件,RecycleView 的部分情形下,针对UI指定 上下左右焦点,保持焦点不外溢、丢失。
- 对Banner 类型UI组件,自己根据实际问题来解决,因为Banner 会轮训图片、视频 等导致焦点错乱丢失情况,可以具体问题具体分析
- RecycleView 对于边角问题处理,对于行位、行首、竖方向收尾、竖方向最后一位的焦点处理。 下面会具体分析。
RecycleView 案例分析
RecycleView 会有两种情况特别注意
- 比如你的RecycleView 焦点在四周,恰好是左边、右边、上边、下边 需要拦截,如果不拦截的话焦点丢失不见了
- 需要判断RecycleView 焦点是否在四周哪个方向,做对应的业务逻辑处理。比如网格布局情况下,在最左边情形下需要翻页、在最右要拦截、最下边要拦截。
情形一:网格布局情况下,横屏竖屏显示情况下,判断焦点是否在最左边,如果最左边就用viewPager 翻页处理
情形而:如下判断是否边角焦点,处理对应业务,比如底部不让事件传递,焦点定在那里,不然会失去焦点。
private fun handleLightViewTopBoundary(): Boolean {// 可以转移到其他View或保持焦点val first: View = viewBinding.lightRyView.getChildAt(0)if (first != null) {first.requestFocus()return true}return false}private fun handleLightViewRightBoundary(position: Int): Boolean {// 类似处理右边边界// val lastPos: Int = viewBinding.lightRyView.getAdapter()!!.getItemCount() - 1val last: View =viewBinding.lightRyView.findViewHolderForAdapterPosition(position)!!.itemViewif (last != null) {last.requestFocus()return true}return false}private fun handleLightViewLeftBoundary(position: Int): Boolean {// 类似处理左边边界// val lastPos: Int = viewBinding.lightRyView.getAdapter()!!.getItemCount() - 1val last: View =viewBinding.lightRyView.findViewHolderForAdapterPosition(position)!!.itemViewif (last != null) {last.requestFocus()return true}return false}private fun handleLightViewBottomBoundary(): Boolean {// 类似处理底部边界val lastPos: Int = viewBinding.lightRyView.getAdapter()!!.getItemCount() - 1val last: View =viewBinding.lightRyView.findViewHolderForAdapterPosition(lastPos)!!.itemViewif (last != null) {last.requestFocus()return true}return false}
如下:如果是底部最后一行了,那么就不让事件传递,这样就不会丢失焦点。 在上下左右焦点都是这么处理的,就是判断 或者 在边焦点时候做其他业务处理。
总结
- 遥控器功能开发,本身就是处理焦点的问题,这里简要描述了焦点基本知识、实际开发案例、注意事项。
- 简单的UI焦点处理事件很简单的,默认就支持,可能需要定制焦点选中UI
- 对于RecycleView 、banner 嵌套在Fragmetn / Dialog 或者 自嵌套 的复杂UI情形,焦点很容易没有规律,掌握一些基本的处理方案很重要。 遇到问题针对性解决即可。