Android TV 焦點移動飛框的實現
目前國內傳統廠商和網際網路廠商所開發的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 屬性動畫類來實現自適應寬高和位置移動的動畫效果。
獲得焦點View突出顯示實現
在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的寬高。
完整程式碼實現
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:
<?xml version="1.0" encoding="utf-8"?>
<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>