不分大屏產品需要有遙控器功能,這里分享部分實戰經驗
文章目錄
- 前言
- 一、案例部分效果圖
- 二、項目基礎架構
- 三、焦點基礎知識
- 適配遙控器基礎-焦點問題
- 焦點管理
- 明確焦點狀態
- 布局實現
- 硬編碼實現
- 引入第三方自定義組件實現
- 焦點順序
- 作用
- 初始焦點 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情形,焦點很容易沒有規律,掌握一些基本的處理方案很重要。 遇到問題針對性解決即可。