Android TV Focus View的封装

  • 最近在搞和盒子有关的项目,写盒子系统的Launcher应用以及设置等等。
  • 因为是盒子,所以大部分的使用场景是遥控器,这就导致我们在开发过程中,对每一个可点击的View,都要变成可以Focus。
  • 在UI上,就多出来了许多了Focus逻辑。比如:放大、边框、阴影等等。
  • Android在TVFocus这块,有太多的实现方式:
    • 比如Background的selector,比如动画的Scale等等,但是面对设计的多样化,单一的实现方式,总是有不尽人意的地方。
    • 于是只好自己封装一套Focus的绘制方案了。

先看怎么用:

  • Lib提供插件式调用,并且已经发布到JitPack中,可以直接dependencies依赖,
  • 只需要按照以下步骤,即可方式调用:

配置dependencies:

  • 在项目根目录的build.gradle下配置:
           allprojects {
               repositories {
                   ...
                   maven { url 'https://jitpack.io'; }
               }
           }
  • 在你的app module中的build.gradle下配置:
dependencies {
               implementation 'com.github.RyanHuen:AndroidTVFocus:v1.0.0'
           }

调用FocusAPI:

需要Focus的View,请设置OnFocusChangeListener:

           TextView mDefaultConfig= root.findViewById(R.id.default_config);
           TextView mNoScale= root.findViewById(R.id.no_scale);
           TextView mScaleNoBorder= root.findViewById(R.id.scale_no_border);
           private LinearLayout mAdvanceFocus= root.findViewById(R.id.advance_focus);
           private ImageView mTargetDrawBorder= root.findViewById(R.id.target_draw_border);
           private TextView mChangeBorder = root.findViewById(R.id.change_border);

           //默认效果
           mDefaultConfig.setOnFocusChangeListener(new View.OnFocusChangeListener() {
               @Override
               public void onFocusChange(View v, boolean hasFocus) {
                   FocusHighlightHelper.focusHighlightView(v, hasFocus, new FocusHighlightOptions());
               }
           });

           //不放大
           mNoScale.setOnFocusChangeListener(new View.OnFocusChangeListener() {
               @Override
               public void onFocusChange(View v, boolean hasFocus) {
                   FocusHighlightHelper.focusHighlightView(mNoScale, hasFocus, new FocusHighlightOptions
                           .Builder()
                           .needsScale(false)
                           .build());
               }
           });

           //只放大,不增加边框
           mScaleNoBorder.setOnFocusChangeListener(new View.OnFocusChangeListener() {
               @Override
               public void onFocusChange(View v, boolean hasFocus) {
                   FocusHighlightHelper.focusHighlightView(mScaleNoBorder, hasFocus, new FocusHighlightOptions
                           .Builder()
                           .needsBorder(false)
                           .build());
               }
           });
           //修改Focus的边框样式
           mChangeBorder.setOnFocusChangeListener(new View.OnFocusChangeListener() {
               @Override
               public void onFocusChange(View v, boolean hasFocus) {
                   FocusHighlightHelper.focusHighlightView(mChangeBorder, hasFocus, new FocusHighlightOptions
                           .Builder()
                           .specifiedBackground(getResources().getDrawable(R.drawable.bg_light_focus), true)
                           .build());
               }
           });

           //进阶Focus效果,指定ViewGroup中的某个View绘制边框
           mAdvanceFocus.setOnFocusChangeListener(new View.OnFocusChangeListener() {
               @Override
               public void onFocusChange(View v, boolean hasFocus) {
                   FocusHighlightHelper.focusHighlightView(mAdvanceFocus, hasFocus, new FocusHighlightOptions
                           .Builder()
                           .specifiedViewWithBorder(mTargetDrawBorder)
                           .build());
               }
           });

详细设计

##方案大概思路:

  • 因为Focus的效果设计到绘制边框,并且给View进行放大。同时,边框又存在阴影效果。
  • 因此,如果使用Selector的方式实现的话,会比较麻烦。
  • 可以想象得到,View在设置了selector后,9patch图片的阴影padding也会被计算到View里去,这样就会导致View的实际效果被挤压。
  • 这在产品和设计肯定是过不去的。
  • 博主思前想后,最后只能在View的上面先设置一个透明的浮层,所有的Focus效果都绘制在这个浮层上。
  • 这样做的效果是所有的放大、边框、阴影、padding等等的效果,因为绘制在了一个独立的浮层上。因此不会影响原来的View。
  • 因此,我们得出结论:
    • 所有的Focus效果,画在透明的Shadow层上。

##Shadow浮层的实现:

  • 大家都知道,每一个程序运行起来,都是一个Window。
  • 这个window会对应一个DecorView,这是我们通过Activity的Context对象,就能获取到的。
  • 如果我们想要在整个界面的上层增加一个浮层用来绘制Focus效果,我们可以通过Activity的Context对象,获取到DecorView。
  • 在DecorView上,增加一个透明的View,这样就能保证,我们界面上每一个View获取到Focus的时候,都可以在Shadow层上进行相应的Focus绘制。
  • 具体代码实现如下:
       private static RHFocusCursorView getFocusCursorView(View view) {
               ViewGroup decorView;
               if (view.getContext() instanceof Activity) {
                   //如果是Activity的Context,则直接获取DecorView
                   decorView = (ViewGroup) ((Activity) view.getContext()).getWindow().getDecorView();
               } else {
                   //这里还有一种情况
                   //如果是Dialog类型的Context,是无法获取Window对象的,因此需要传入Dialog对象来获取Window对象
                   return getFocusCursorViewFromDialogWindow(sDialog);
               }
               RHFocusCursorView metroCursorView;
               metroCursorView = decorView.findViewById(R.id.ryan_focus_cursor_view);
               //获取到DecorView后,看是否能够找到我们专门设置的Shadow浮层
               if (null == metroCursorView) {
                   if (DEBUG) {
                       Log.e(TAG, "didn't find MetroCursorView,we will create one");
                   }
                   //没找到浮层,表示从来没有添加过浮层,因此我们创建一个,并且添加到DecorView下
                   //这样得到的Shadow浮层,永远都置于其他的布局之上
                   metroCursorView = new RHFocusCursorView(view.getContext());
                   metroCursorView.setId(R.id.ryan_focus_cursor_view);
                   ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams(
                           ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
                   //将创建的Shadow浮层,添加到DecorView上
                   decorView.addView(metroCursorView, layoutParams);
               }
               return metroCursorView;
           }

##Shadow浮层内部的绘制:

  • 创建了Shadow浮层之后,我们拥有了一个可以绘制Focus效果的View,这时候,我们就可以在Shadow浮层View的内部搞事情了。

###ShadowFocusView实现:

####创建ShadowFocusView:

  • shadowfocusview一个透明的match-parent宽高的View,因此直接继承View即可。

####FocusHighlightOptions:

  • 因为Focus的效果多样:
    • 如:放大、边框、指定Focus的View其中的某个子View绘制边框等等
    • 因此我们需要开放一个Focus效果的配置器给调用处,让Focus效果达到可配置性
    • FocusHighlightOptions配置器使用Builder的方式,主要开放以下接口:
//开放是否需要Scale放大的接口
           public Builder needsScale(boolean needsScale) {
               this.needsScale = needsScale;
               return this;
           }

           //开放是否需要Focus边框效果的接口
           public Builder needsBorder(boolean needsBorder) {
               this.needsBorder = needsBorder;
               return this;
           }

           //开放是否需要指定某个子View绘制边框的接口
           public Builder specifiedViewWithBorder(View view) {
               this.needsSpecialBorder = true;
               this.specifiedBorderView = view;
               return this;
           }

           //开放是否需要设置额外的边框Drawable的接口
           //同时指定额外的边框Drawable时,该Drawable作为View的前景还是背景
           public Builder specifiedBackground(Drawable specifiedBackground, boolean needsMoveToBottom) {
               this.needsSpecialBackground = true;
               this.needsMoveToBottom = needsMoveToBottom;
               this.specifiedBackground = specifiedBackground;
               return this;
           }

####开放Focus接口

  • 因为所有的Focus效果都是在ShadowFocusView上绘制,所以,我们需要调用处把当前Focus的View传进来:
  • 结合FocusHightlightOptions,为当前View的Focus效果的绘制做准备
public void setFocusView(View view, FocusHighlightOptions focusHighlightOptions) {
             //通过FocusHighlightOptions,确定此次Focus的View,是否需要以下属性:
             //外边框、放大、指定子View绘制边框、指定额外的边框图形
             if (focusHighlightOptions.needsSpecialBorder) {
                 mSpecifiedBorderView = focusHighlightOptions.specifiedBorderView;
             } else {
                 mBorderNeeded = focusHighlightOptions.needsBorder;
             }
             if (focusHighlightOptions.needsSpecialBackground) {
                 mDrawablePaint = focusHighlightOptions.specifiedBackground;
                 mMoveHighLightToBottom = focusHighlightOptions.needsMoveToBottom;
                 mBorderNeeded = true;
             }
             mScaleNeeded = focusHighlightOptions.needsScale;
             view.setAlpha(0f);
             if (mFocusView != view) {
                 mFocusView = view;
                 mScaleUp = 1.0f;
                 animScaleUp.start();
             }
             invalidate();
         }

初始化设计

  • 初始化过程中中,我们将画笔设置为透明,同时创建一个ValueAnimator,默认时长为500ms,用于作为Focus绘制的时间长度:
//Focus默认效果主要是放大,因此我们ofFloat一个从1.0f————1.1f的动画过程。
         ValueAnimator animScaleUp = ValueAnimator.ofFloat(1.0F, DEFAULT_VIEW_SCALE)
             .setDuration(getResources().getInteger(R.integer.scale_up_duration));
         //duration设置为500ms
         void init() {
             //设置默认的Focus绘制Drawable(即边框)
             mDrawablePaint = getResources().getDrawable(R.drawable.focus_highlight);
             mDrawableDefault = getResources().getDrawable(R.drawable.focus_highlight);
             //画笔设为透明
             mPaint.setColor(0xff000000);
             //定义ValueAnimator的更新监听和interpolator
             animScaleUp.addUpdateListener(this);
             animScaleUp.setInterpolator(new DecelerateInterpolator());
         }
  • mDrawablePaint : 绘制Focus时使用的drawable对象。
  • mDrawableDefault : 如果调用处指定了特殊的Focus边框,我们需要把默认的Focus边框存到Default对象中。

####为什么需要ValueAnimator?

  • 开放Focus接口的代码中,我们调用了以下API:
         if (mFocusView != view) {
             mFocusView = view;
             mScaleUp = 1.0f;
             animScaleUp.start();
         }
         invalidate();
  • ValueAnimator在需要绘制Focus效果的View被传进来的时候,被启动了。
  • 同时我们在初始化代码中,给ValueAnimator设置的UpdateListener,我们来看一下回调函数里,我们做了哪些操作:
           @Override
           public void onAnimationUpdate(ValueAnimator animation) {
               mScaleUp = (float) animation.getAnimatedValue();
               invalidate();
           }

  • 回调函数中,我们将从1.0f————1.1f过程中动态变化的Value值,交给成员mScaleUp,同时调用了invalidate()请求重绘
  • 此时,ShadowFocusView的onDraw()函数会被回调,我们就可以在onDraw()函数里,将Focus的View和Focus效果一起通过Canvas绘制出来了。

Focus的绘制:

  • onDraw()函数里,我们调用了内部的绘制函数drawOnShadowView()
  • 在这个函数里,我们让传入的View和ShadowFocusView共享同一个Canvas,这一步是为了将传入的View绘制到ShadowFocusView中来
  • 这样的话,传入的View的动态效果能够保持:如传入一个加载gif图的ImageView,共享Canvas可以让gif依旧正常播放
view.draw(canvas);//将ShadowFocusView的canvas传给调用者传进来的View
  • 根据成员mScaleUp的动态变化,对Canvas进行scale()放大,以实现Focus的放大效果:
canvas.scale(scale, scale);
  • 由于有些View是紧靠屏幕的边缘,如果对View的中心进行放大,放大后,View的一部分肯定会超出屏幕,从而被挡住。这肯定不是我们想要看到的:
  • 如下图:
  • 未设置偏移示意图
  • 因此,对于贴边的View,需要特殊处理。
  • 方案如下:
  • 我们提供了两个id,传入的FocusView,如果需要在放大时产生横向和纵向的偏移量,需要使用对应的id,设置tag,以便绘制时,ShadowFocusView能够识别
             <item name="shadow_focus_item_pivot_horizontal" type="id" />
             <item name="shadow_focus_item_pivot_vertical" type="id" />
  • 具体的tag参数有:
             public static final int VIEW_SCALE_PIVOT_X_LEFT = 0;//放大时,避免左区域被遮挡
             public static final int VIEW_SCALE_PIVOT_X_RIGHT = 1;//放大时,避免右区域被遮挡
             public static final int VIEW_SCALE_PIVOT_Y_TOP = 0;//放大时,避免顶部区域被遮挡
             public static final int VIEW_SCALE_PIVOT_Y_BOTTOM = 1;//放大时,避免底部区域被遮挡
  • 根据getTag()函数得到的参数,ShadowFocusView会将Canvas做适当的translate移动。让View在放大后,不会超出屏幕边缘。
  • 由于9patch图片存在padding,因此偏移后的图片距离屏幕边缘的距离较远(这可以通过设置一个左边没有阴影信息的9patch图片来解决)

 评论