目前国内传统厂商和互联网厂商所开发的Android智能电视的UI都很类似,其中最常见的就是获得焦点的选中项飞框动画效果的实现了,看上去动画效果很炫酷,能够正确的导航使用者当前所选择的条目。Android电视和Android手机有很大的区别,Android手机带有触摸屏,一般不用特别指示用户所选中的项;而Android电视则不同,不带有触摸屏,一切操作都需要通过遥控或者手机(带红外线)来实现远程操控,所以智能电视UI需要高亮用户所选中的项来达到导航的效果。
焦点项飞框的动画效果就是飞框会自动移动到下一个选中项,并且会根据下一个选中项的大小进行伸缩变化来包裹高亮下一个选中项,本文代码实现后的效果图如下所示:
使用焦点移动飞框来导航用户的操控行为,用户就能更明确直接的到达所想要打开的条目,享受智能电视带来的极致畅快的体验。
原生Android Tv并不带有焦点移动飞框的控件,而只是选中项突出显示的效果而已,如下图Videos图标是选中状态,突出显示,也就是Z轴抬高了,而焦点移动飞框在国内厂商定制的ROM UI界面却很常见,所以要实现该效果,必须使用自定义View。
分析本文代码实现后的效果图,可以有两种实现方法,一种是自定义View,并在onDraw方法里面判断处理各种位置和数值变化的逻辑,然后使用path等绘制路径类进行绘制,复杂度较高,代码实现复杂;另一种是使用属性动画,获取下一个选中项和当前选中项的位置和宽高等信息,然后使用属性动画和这些信息来动态实现移动飞框View的移动和宽高等动画效果。
属性动画是在Android4.0之后引入的动画,与之前的补间动画和帧动画不同的是,使用属性动画能真正的改变View的属性值,利用这个特性就能很方便的实现Android智能电视UI上面的焦点移动飞框的效果了。本文中使用了ObjectAnimator和ValueAnimator 属性动画类来实现自适应宽高和位置移动的动画效果。
在ViewGroup容器里面,获得焦点的View会放大并突出显示,当焦点移动到下一个选中项View时,当前View会缩小并还原为初始状态,而下一个选中项View就会放大并突出显示,以此类推。实现方法很简单,首先需要监听ViewGroup容器里面的ViewTreeObserver视图树监听者来监听View视图焦点的变化,可以调用addOnGlobalFocusChangeListener来监听回调接口,如下所示:
rootView.getViewTreeObserver().addOnGlobalFocusChangeListener(new ViewTreeObserver.OnGlobalFocusChangeListener() {
@Override
public void onGlobalFocusChanged(View oldFocus, View newFocus) {
if(newFocus!=null){
ObjectAnimator scaleX=ObjectAnimator.ofFloat(newFocus,"scaleX",1.0f,1.20f);
ObjectAnimator scaleY=ObjectAnimator.ofFloat(newFocus,"scaleY",1.0f,1.20f);
ObjectAnimator translationZ=ObjectAnimator.ofFloat(newFocus,"translationZ",0f,1.0f);
AnimatorSet animatorSet=new AnimatorSet();
animatorSet.play(scaleX).with(scaleY).with(translationZ);
animatorSet.setDuration(200);
animatorSet.start();
}
if(oldFocus!=null){
ObjectAnimator scaleX=ObjectAnimator.ofFloat(oldFocus,"scaleX",1.20f,1.0f);
ObjectAnimator scaleY=ObjectAnimator.ofFloat(oldFocus,"scaleY",1.20f,1.0f);
ObjectAnimator translationZ=ObjectAnimator.ofFloat(oldFocus,"translationZ",1.0f,0f);
AnimatorSet animatorSet=new AnimatorSet();
animatorSet.setDuration(200);
animatorSet.play(scaleX).with(scaleY).with(translationZ);
animatorSet.start();
}
flyBorderView.attachToView(newFocus,1.20f);
}
});
这里使用ObjectAnimator和AnimatorSet 动画集合来放大缩小焦点View以及改变View的Z轴高度达到突出显示的效果,当多个View相邻并排在一起的时候,改变Z轴的高度可以突出焦点View,并可以层叠在其他View上面。也可以使用View.animate()方法使用属性动画:
rootView.getViewTreeObserver().addOnGlobalFocusChangeListener(new ViewTreeObserver.OnGlobalFocusChangeListener() {
@Override
public void onGlobalFocusChanged(View oldFocus, View newFocus) {
if(newFocus!=null){ newFocus.animate().scaleX(1.20f).scaleY(1.20f).translationZ(1.1f).setDuration(200).start();
}
if(oldFocus!=null){ oldFocus.animate().scaleX(1.0f).scaleY(1.0f).translationZ(1.0f).setDuration(200).start();
}
flyBorderView.attachToView(newFocus,1.20f);
}
});
首先需要自定义一个View,并继承View。
public class FlyBorderView extends View
然后在上面的视图树监听焦点View变化的监听器中调用改变FlyBorderView的方法:
/**
*
* @param newFocus 下一个选中项视图
* @param scale 选中项视图的伸缩大小
*/
public void attachToView(View newFocus, float scale) {
final int widthInc = (int) ((newFocus.getWidth() * scale + 2 * borderWidth - getWidth()));//当前选中项与下一个选中项的宽度偏移量
final int heightInc = (int) ((newFocus.getHeight() * scale + 2 * borderWidth - getHeight()));//当前选中项与下一个选中项的高度偏移量
float translateX = newFocus.getLeft() - borderWidth
- (newFocus.getWidth() * scale - newFocus.getWidth()) / 2;//飞框到达下一个选中项的X轴偏移量
float translateY = newFocus.getTop() - borderWidth
- (newFocus.getHeight() * scale - newFocus.getHeight()) / 2;//飞框到达下一个选中项的Y轴偏移量
startTotalAnim(widthInc,heightInc,translateX,translateY);//调用飞框 自适应和移动 动画效果
}
ViewTreeObserver.OnGlobalFocusChangeListener()的onGlobalFocusChanged方法的第一个参数oldFocus是上一个获得焦点的View,而第二个参数newFocus是目前获得焦点的View。飞框的移动和伸缩只与目前获得焦点的View相关,所以只需要在attachToView中传入newFocus,attachToView的第二个参数是获得焦点View伸缩变化值。widthInc 和heightInc 是获得焦点View放大或者缩小后与目前飞框的大小的偏移量,以使飞框能够自适应伸缩变化。translateX 和translateY 是飞框移动的偏移量,newFocus.getLeft()和getTop()是获得newFocus左上角在父View窗口内的绝对值坐标。
计算出飞框需要变化的宽高和位移偏移量之后,调用startTotalAnim方法来开始飞框的动画效果:
/**
* 飞框 自适应和移动 动画效果
* @param widthInc 宽度偏移量
* @param heightInc 高度偏移量
* @param translateX X轴偏移量
* @param translateY Y轴偏移量
*/
private void startTotalAnim(final int widthInc, final int heightInc, float translateX, float translateY){
final int width = getWidth();//当前飞框的宽度
final int height = getHeight();//当前飞框的高度
ValueAnimator widthAndHeightChangeAnimator = ValueAnimator.ofFloat(0, 1).setDuration(duration);//数值变化动画器,能获取平均变化的值
widthAndHeightChangeAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
setFlyBorderLayoutParams((int) (width + widthInc * Float.parseFloat(valueAnimator.getAnimatedValue().toString())),
(int) (height + heightInc * Float.parseFloat(valueAnimator.getAnimatedValue().toString())));//设置当前飞框的宽度和高度的自适应变化
}
});
ObjectAnimator translationX = ObjectAnimator.ofFloat(this, "translationX", translateX);//X轴移动的属性动画
ObjectAnimator translationY = ObjectAnimator.ofFloat(this, "translationY", translateY);//y轴移动的属性动画
AnimatorSet set = new AnimatorSet();//动画集合
set.play(widthAndHeightChangeAnimator).with(translationX).with(translationY);//动画一起实现
set.setDuration(duration);
set.setInterpolator(new LinearInterpolator());//设置动画插值器
set.start();//开始动画
}
private void setFlyBorderLayoutParams(int width, int height){//设置焦点移动飞框的宽度和高度
ViewGroup.LayoutParams params=getLayoutParams();
params.width=width;
params.height=height;
setLayoutParams(params);
}
要想实现飞框的宽和高的渐变效果,需要使用ValueAnimator的AnimatorUpdateListener监听器方法来获取动画变化过程中数值的变化,我们可以设置从0到1之间的渐变值,然后(int) (width + widthInc * Float.parseFloat(valueAnimator.getAnimatedValue().toString()))即当前飞框的宽度加上飞框需要变化的宽度偏移量X百分比变化数,就可以计算出飞框实际的宽度值,高度原理一样。计算出高度和宽度之后,调用setLayoutParams方法设置飞框当前的高度和宽度属性值来改变View的宽高。
Demo地址:https://github.com/QQ402164452/FlyBorderViewDemo
public class FlyBorderView extends View {
private int borderWidth;//焦点移动飞框的边框
private int duration=200;//动画持续时间
public FlyBorderView(Context context) {
this(context, null);
}
public FlyBorderView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public FlyBorderView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
borderWidth = getContext().getResources().getDimensionPixelSize(R.dimen.FlyBorderWidth);
}
/**
*
* @param newFocus 下一个选中项视图
* @param scale 选中项视图的伸缩大小
*/
public void attachToView(View newFocus, float scale) {
final int widthInc = (int) ((newFocus.getWidth() * scale + 2 * borderWidth - getWidth()));//当前选中项与下一个选中项的宽度偏移量
final int heightInc = (int) ((newFocus.getHeight() * scale + 2 * borderWidth - getHeight()));//当前选中项与下一个选中项的高度偏移量
float translateX = newFocus.getLeft() - borderWidth
- (newFocus.getWidth() * scale - newFocus.getWidth()) / 2;//飞框到达下一个选中项的X轴偏移量
float translateY = newFocus.getTop() - borderWidth
- (newFocus.getHeight() * scale - newFocus.getHeight()) / 2;//飞框到达下一个选中项的Y轴偏移量
startTotalAnim(widthInc,heightInc,translateX,translateY);//调用飞框 自适应和移动 动画效果
}
/**
* 飞框 自适应和移动 动画效果
* @param widthInc 宽度偏移量
* @param heightInc 高度偏移量
* @param translateX X轴偏移量
* @param translateY Y轴偏移量
*/
private void startTotalAnim(final int widthInc, final int heightInc, float translateX, float translateY){
final int width = getWidth();//当前飞框的宽度
final int height = getHeight();//当前飞框的高度
ValueAnimator widthAndHeightChangeAnimator = ValueAnimator.ofFloat(0, 1).setDuration(duration);//数值变化动画器,能获取平均变化的值
widthAndHeightChangeAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
setFlyBorderLayoutParams((int) (width + widthInc * Float.parseFloat(valueAnimator.getAnimatedValue().toString())),
(int) (height + heightInc * Float.parseFloat(valueAnimator.getAnimatedValue().toString())));//设置当前飞框的宽度和高度的自适应变化
}
});
ObjectAnimator translationX = ObjectAnimator.ofFloat(this, "translationX", translateX);//X轴移动的属性动画
ObjectAnimator translationY = ObjectAnimator.ofFloat(this, "translationY", translateY);//y轴移动的属性动画
AnimatorSet set = new AnimatorSet();//动画集合
set.play(widthAndHeightChangeAnimator).with(translationX).with(translationY);//动画一起实现
set.setDuration(duration);
set.setInterpolator(new LinearInterpolator());//设置动画插值器
set.start();//开始动画
}
public int getDuration() {
return duration;
}
public void setDuration(int duration) {
this.duration = duration;
}
private void setFlyBorderLayoutParams(int width, int height){//设置焦点移动飞框的宽度和高度
ViewGroup.LayoutParams params=getLayoutParams();
params.width=width;
params.height=height;
setLayoutParams(params);
}
}
MainActivity实现:
public class MainActivity extends BaseActivity{
private RelativeLayout rootView;
private FlyBorderView flyBorderView;
@Override
protected void setView() {
setContentView(R.layout.activity_main);
rootView= (RelativeLayout) findViewById(R.id.RootView);
flyBorderView= (FlyBorderView) findViewById(R.id.FlyBorderView);
}
@Override
protected void setData() {
}
@Override
protected void setListener() {
rootView.getViewTreeObserver().addOnGlobalFocusChangeListener(new ViewTreeObserver.OnGlobalFocusChangeListener() {
@Override
public void onGlobalFocusChanged(View oldFocus, View newFocus) {
if(newFocus!=null){
ObjectAnimator scaleX=ObjectAnimator.ofFloat(newFocus,"scaleX",1.0f,1.20f);
ObjectAnimator scaleY=ObjectAnimator.ofFloat(newFocus,"scaleY",1.0f,1.20f);
ObjectAnimator translationZ=ObjectAnimator.ofFloat(newFocus,"translationZ",0f,1.0f);
AnimatorSet animatorSet=new AnimatorSet();
animatorSet.play(scaleX).with(scaleY).with(translationZ);
animatorSet.setDuration(200);
animatorSet.start();
}
if(oldFocus!=null){
ObjectAnimator scaleX=ObjectAnimator.ofFloat(oldFocus,"scaleX",1.20f,1.0f);
ObjectAnimator scaleY=ObjectAnimator.ofFloat(oldFocus,"scaleY",1.20f,1.0f);
ObjectAnimator translationZ=ObjectAnimator.ofFloat(oldFocus,"translationZ",1.0f,0f);
AnimatorSet animatorSet=new AnimatorSet();
animatorSet.setDuration(200);
animatorSet.play(scaleX).with(scaleY).with(translationZ);
animatorSet.start();
}
flyBorderView.attachToView(newFocus,1.20f);
}
});
}
}
布局Layout:
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/RootView"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
android:clipChildren="false"
android:clipToPadding="false">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false"
android:clipToPadding="false"
android:layout_alignParentTop="true"
android:layout_alignParentStart="true">
<ImageView
android:id="@+id/pro5"
android:focusable="true"
android:layout_width="160dp"
android:layout_height="160dp"
android:layout_margin="20dp"
android:src="@drawable/pro5"/>
<ImageView
android:id="@+id/pro3"
android:layout_width="150dp"
android:layout_height="200dp"
android:focusable="true"
android:src="@drawable/pro3"
android:layout_toRightOf="@id/pro5"
android:layout_marginLeft="50dp"
android:layout_marginTop="50dp"/>
<ImageView
android:id="@+id/pro"
android:layout_width="180dp"
android:layout_height="180dp"
android:focusable="true"
android:src="@drawable/pro"
android:layout_toRightOf="@id/pro3"
android:layout_marginLeft="40dp"
android:layout_marginTop="120dp"/>
<ImageView
android:layout_width="190dp"
android:layout_height="350dp"
android:focusable="true"
android:src="@drawable/pro4"
android:layout_toRightOf="@id/pro"
android:layout_margin="70dp" />
<ImageView
android:id="@+id/pro2"
android:layout_width="250dp"
android:layout_height="140dp"
android:focusable="true"
android:layout_below="@id/pro5"
android:src="@drawable/pro2"
android:layout_marginTop="80dp"
android:layout_marginLeft="40dp"/>
RelativeLayout>
<custom_view.FlyBorderView
android:id="@+id/FlyBorderView"
android:layout_width="1dp"
android:layout_height="1dp"
android:background="@drawable/list_focus"/>
RelativeLayout>