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图片来解决)