Android软件适配遥控器需求-案例经验分享

不分大屏产品需要有遥控器功能,这里分享部分实战经验

文章目录


前言

十多年的Android软件开发中,基本上都是做方案上的软件产品。 对于 电视、投影、闺蜜机 上面的软件 都有遥控器控制的需求,就需要自己的Android App能够受遥控器控制。 这里举一个案例,分享一下开发中的部分经验。 也方便自己下次开发直接复用经验,高效开发。


一、案例部分效果图

当前分享案例中部分效果图如下


二、项目基础架构

为什么要简单列举一下架构图,其实不同的UI架构会遇到各种不一样的问题,这里针对性的从列举项目上展示一下架构,方便分析和理解部分阐明的问题

三、焦点基础知识

适配遥控器基础-焦点问题

焦点管理

软件App 适配遥控器,需要用遥控器的功能,实际上就是处理焦点问题。当UI获取焦点时候、用遥控器上下左右按键移动到某一个UI图标识货,UI图标必须差异化显示出来,表示选中状态,进而遥控器点击ok 等,实际上就是点击这个UI的操作。

明确焦点状态

确保UI元素有清晰的焦点视觉效果(放大、边框、阴影等)

如上描述,其实就是一个UI组件选中的效果。 这里我理解有三种表现形式

布局实现

比如我们开发中常见的,在获取焦点 state_focused = true ,时候给于不同的背景、颜色 等突出显示出来。

java 复制代码
<Button
    android: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不同渲染,达到焦点选中效果,无交点正常显示效果。

java 复制代码
 binding.selectOk.setOnFocusChangeListener { v, hasFocus ->
            val roundView: RoundTextView = v as RoundTextView
            if (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 RoundConstraintLayout
            if(hasFocus){
                roundView.setStrokeWidthColor(5f, Color.parseColor("#EEEE00"))
                focusPos=position
                Log.d(TAG," focusPos:${focusPos}")
                RightAppInfoLiveData.value= RightAppInfo(focusPos,mCenterAppDataList.size)
            }else{
                roundView.setStrokeWidthColor(5f, Color.parseColor("#00000000"))
                Log.d(TAG,"no focusPos:${focusPos}")
                focusPos=-1
                RightAppInfoLiveData.value= RightAppInfo(focusPos,mCenterAppDataList.size)
            }
        }
引入第三方自定义组件实现

只是作为一个UI组件使用,第三方组件和核心功能就是在获取焦点时候突出显示而已,和 布局表现及 硬编码实现方式并无区别。

焦点顺序

设置合理的焦点移动顺序(android:nextFocusUp/Down/Left/Right),为什么要有这个东西呢? 举例在架构图中,分三页面,无论那个页面都有很多UI组件,如何实现遥控器按 上、下、左、右按键时候,UI组件选中按照自己意愿活着业务定义来切换不同UI选中状态呢?

这个时候,焦点移动顺序就起到作用了,下面列举一下实际用法,其实就是在布局文件中设置的。

作用
  • 实现UI焦点移动顺序
  • 对边角的UI组件,指向自己,这样就可以规避焦点不见了的问题,规避反人类的体验。
    实际使用 简单 如下:

初始焦点 requestFocus

为什么会有这个方法, 为什么需要? 比如说 进行界面切换的时候,如架构图中从一个界面切换到另外一个界面、点击一个图标进入另外一个界面。 在新的界面焦点在哪里是不确定的或者说在新的界面,是没有焦点的。 那么最好初始化一个UI具备焦点。 这样遥控器按键时候直接上下左右进行切换,规避没有焦点时候或者焦点不确定时候需要多按好多次 才有UI获取焦点显示出来,体验和业务需要的。

比如,架构图中,第一页切换到第二页、第二页切换到第三页、第三页切换到第二页、第二页切换到第一页,如何实现焦点初始化呢?

在 页面onResume 方法中,让指定的UI初始化一次焦点,去获取焦点一次。

按键处理

我们为什么需要处理按键,遥控器的本身其实就是KeyEvent 事件,映射的其实就是物理按键的功能。 那么当keyevent 事件通过遥控器触发后,做什么业务逻辑,那就是上层需要处理的事情了。 这就是为什么我们需要按键处理了。

对于很多遥控器,基本功能通用;专用的KeyEvent 是通过定制实现(比如 点击遥控器一个按键就是要直接打开抖音、长按遥控器进行语音控制呀,这些就是定制的功能)

下面代码列举了部分源码,监听左右按键最核心的功能是 需要翻页的功能。 遥控器没有翻页的物理按键,通过当前焦点,是否是边角焦点结合按键监听方向,实现是否翻页、翻到哪一页的功能。

java 复制代码
  override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
        when (keyCode) {
            KeyEvent.KEYCODE_DPAD_UP -> {
                Log.d(TAG," KEYCODE_DPAD_UP")
                val rootview = window.decorView
                val focusView = rootview.findFocus()
                Log.i(TAG, "===当前获取焦点的View===${focusView}")
                //return true // 返回true表示事件已被处理
                val focused = currentFocus
                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_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.decorView
                val focusView = rootview.findFocus()
                val focused = currentFocus
                Log.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.decorView
                val 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.value
                                Log.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.value
                                    Log.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.decorView
                val 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.value
                            Log.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 事件响应地方也有相关代码的。

java 复制代码
     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 翻页处理

情形而:如下判断是否边角焦点,处理对应业务,比如底部不让事件传递,焦点定在那里,不然会失去焦点。

java 复制代码
   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() - 1
        val last: View =
            viewBinding.lightRyView.findViewHolderForAdapterPosition(position)!!.itemView
        if (last != null) {
            last.requestFocus()
            return true
        }
        return false
    }


 private fun handleLightViewLeftBoundary(position: Int): Boolean {
        // 类似处理左边边界

       // val lastPos: Int = viewBinding.lightRyView.getAdapter()!!.getItemCount() - 1
        val last: View =
            viewBinding.lightRyView.findViewHolderForAdapterPosition(position)!!.itemView
        if (last != null) {
            last.requestFocus()
            return true
        }
        return false
    }

  private fun handleLightViewBottomBoundary(): Boolean {
        // 类似处理底部边界
        val lastPos: Int = viewBinding.lightRyView.getAdapter()!!.getItemCount() - 1
        val last: View =
            viewBinding.lightRyView.findViewHolderForAdapterPosition(lastPos)!!.itemView
        if (last != null) {
            last.requestFocus()
            return true
        }
        return false
    }

如下:如果是底部最后一行了,那么就不让事件传递,这样就不会丢失焦点。 在上下左右焦点都是这么处理的,就是判断 或者 在边焦点时候做其他业务处理。

总结

  • 遥控器功能开发,本身就是处理焦点的问题,这里简要描述了焦点基本知识、实际开发案例、注意事项。
  • 简单的UI焦点处理事件很简单的,默认就支持,可能需要定制焦点选中UI
  • 对于RecycleView 、banner 嵌套在Fragmetn / Dialog 或者 自嵌套 的复杂UI情形,焦点很容易没有规律,掌握一些基本的处理方案很重要。 遇到问题针对性解决即可。