Demo地址:https://github.com/iSuperRed/LeanbackTvSample.git
背景
??現(xiàn)在國內(nèi)主流的TV端視頻播放軟件碉咆、TV端桌面的UI風(fēng)格都差不多了阀趴。這個(gè)“差不多”不僅是說版式排布“差不多”染服,也是在說交互邏輯的“差不多”窑眯。
??從版式排布上來說氏淑,主頁(如圖一所示)的最上面會(huì)有一排比較重要的功能按鈕腕巡,比如搜索、歷史嫩挤、登錄害幅、引導(dǎo)開通VIP消恍、廣告岂昭、網(wǎng)絡(luò)狀態(tài)、時(shí)間狠怨、Logo等等约啊,這一排按鈕下面是視頻分類的標(biāo)題,標(biāo)題下面是流式布局的視頻內(nèi)容頁佣赖,通過切換標(biāo)題能夠切換視頻內(nèi)容頁恰矩,在視頻內(nèi)容頁按遙控器下鍵能夠加載多頁內(nèi)容,當(dāng)前視頻內(nèi)容頁沒有更多內(nèi)容時(shí)憎蛤,會(huì)有文字提示“到底部了”或者會(huì)有個(gè)按鈕外傅,點(diǎn)擊該按鈕后會(huì)回到頂部纪吮;而焦點(diǎn)態(tài)的標(biāo)記是通過放大、加邊框和加陰影的方式來實(shí)現(xiàn)的萎胰。
??從交互邏輯上來說碾盟,進(jìn)到主頁后,會(huì)有一個(gè)默認(rèn)焦點(diǎn)技竟,很多App的默認(rèn)焦點(diǎn)都是標(biāo)題上的“精選”頁冰肴,然后通過遙控器的上、下榔组、左熙尉、右、返回搓扯、OK检痰、Home等按鍵進(jìn)行人機(jī)交互;如果你想按返回鍵退出當(dāng)前App擅编,焦點(diǎn)會(huì)先回到當(dāng)前頁的標(biāo)題攀细,如果當(dāng)前標(biāo)題不是進(jìn)入主頁的默認(rèn)標(biāo)題,再按返回鍵會(huì)先回到默認(rèn)標(biāo)題爱态,回到默認(rèn)標(biāo)題后再按返回鍵會(huì)給個(gè)對話框問一下“是否要退出App”谭贪,確定退出后才會(huì)真的退出;很多App都會(huì)加邊界抖動(dòng)锦担,當(dāng)按遙控器方向鍵時(shí)俭识,查找該方向的下一個(gè)焦點(diǎn)View為null時(shí),會(huì)給當(dāng)前焦點(diǎn)View一個(gè)抖動(dòng)動(dòng)畫來提示用戶當(dāng)前已經(jīng)到邊界了洞渔。
??上面這些就是當(dāng)前國內(nèi)視頻播放軟件的一些基本特點(diǎn)了套媚。
分析
??下面我來說道說道我是怎么通過Leanback實(shí)現(xiàn)這些功能的。
??如圖二所示磁椒,我要實(shí)現(xiàn)一個(gè)這樣的頁面堤瘤。標(biāo)題我使用的是HorizontalGridView(一個(gè)水平方向的RecyclerView);標(biāo)題下面的內(nèi)容頁是ViewPager嵌套Fragment浆熔,ViewPager實(shí)現(xiàn)切頁本辐,每一個(gè)頁都是一個(gè)Fragment;Fragment的布局是一個(gè)VerticalGridView(一個(gè)是垂直方向上的RecyclerView)医增,VerticalGridView的每一個(gè)item又是一個(gè)HorizontalGridView慎皱,相當(dāng)于垂直方向上的RecyclerView嵌套了水平方向的RecyclerView。
實(shí)現(xiàn)
實(shí)現(xiàn)一 切換標(biāo)題叶骨,ViewPager聯(lián)動(dòng)
??關(guān)于標(biāo)題的實(shí)現(xiàn)可以看這篇 聊一聊 Leanback 中的 HorizontalGridView茫多,或者看本篇Demo的源碼也可以。標(biāo)題的一個(gè)重要的作用是切換頁面忽刽,所以O(shè)nChildViewHolderSelectedListener這個(gè)監(jiān)聽很重要天揖,HorizontalGridView添加這個(gè)監(jiān)聽后夺欲,在切換標(biāo)題Item時(shí),會(huì)回調(diào)這個(gè)監(jiān)聽的onChildViewHolderSelected方法今膊,而我就是在這個(gè)方法里調(diào)用了mViewPager.setCurrentItem
來實(shí)現(xiàn)標(biāo)題和ViewPager的聯(lián)動(dòng)的洁闰。
??順便提一下,OnChildViewHolderSelectedListener這個(gè)接口有兩個(gè)方法万细,分別為onChildViewHolderSelected和onChildViewHolderSelectedAndPositioned扑眉。那有什么區(qū)別呢?注釋里說赖钞,這個(gè)監(jiān)聽可能會(huì)改變子View的大小和位置腰素,所以如果想要獲取子View的布局位置的話,要重寫onChildViewHolderSelectedAndPositioned這個(gè)方法雪营,不過我這里暫時(shí)沒用到它弓千。
private final OnChildViewHolderSelectedListener onChildViewHolderSelectedListener
= new OnChildViewHolderSelectedListener() {
@Override
public void onChildViewHolderSelected(RecyclerView parent, RecyclerView.ViewHolder child,
int position, int subposition) {
super.onChildViewHolderSelected(parent, child, position, subposition);
if (mOldTitle != null) {
Paint paint = mOldTitle.getPaint();
if (paint != null) {
paint.setFakeBoldText(false);
//viewpager切頁標(biāo)題不刷新,調(diào)用invalidate刷新
mOldTitle.invalidate();
}
}
if (child != null) {
TextView view = child.itemView.findViewById(R.id.tv_main_title);
Paint paint = view.getPaint();
if (paint != null) {
paint.setFakeBoldText(true);
//viewpager切頁標(biāo)題不刷新献起,調(diào)用invalidate刷新
view.invalidate();
}
mOldTitle = view;
}
Log.e(TAG, "onChildViewHolderSelected mViewPager != null: " + (mViewPager != null)
+ " position:" + position);
setCurrentItemPosition(position);
}
};
private void setCurrentItemPosition(int position) {
if (mViewPager != null && position != mCurrentPageIndex) {
mViewPager.setCurrentItem(position);
mCurrentPageIndex = position;
}
}
實(shí)現(xiàn)二 切換ViewPager洋访,標(biāo)題聯(lián)動(dòng)
??那如果想要切換ViewPager,讓標(biāo)題聯(lián)動(dòng)該如何實(shí)現(xiàn)呢谴餐?
??ViewPager有個(gè)切頁監(jiān)聽addOnPageChangeListener姻政,添加這個(gè)監(jiān)聽后,在切頁時(shí)岂嗓,會(huì)回調(diào)onPageSelected這個(gè)方法汁展,我就是在這個(gè)方法里調(diào)用了mHorizontalGridView.setSelectedPosition(position);
進(jìn)行標(biāo)題的切換的,調(diào)用mHorizontalGridView.setSelectedPosition(position);
這個(gè)方法后厌殉,標(biāo)題的這個(gè)監(jiān)聽onChildViewHolderSelectedListener就會(huì)執(zhí)行回調(diào)食绿。
private void initViewPager(List<Title.DataBean> dataBeans) {
mViewPagerAdapter = new ContentViewPagerAdapter(getSupportFragmentManager());
mViewPagerAdapter.setData(dataBeans);
mViewPager.setAdapter(mViewPagerAdapter);
mViewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
}
@Override
public void onPageSelected(int position) {
Log.e(TAG, "onPageSelected position: " + position);
if (position != mCurrentPageIndex) {
mCurrentPageIndex = position;
mHorizontalGridView.setSelectedPosition(position);
}
}
@Override
public void onPageScrollStateChanged(int state) {
Log.e(TAG, "onPageScrollStateChanged state: " + state);
}
});
}
實(shí)現(xiàn)三 流式布局實(shí)現(xiàn)
??那每一個(gè)內(nèi)容頁的流式布局是如何實(shí)現(xiàn)的呢?
??每一個(gè)內(nèi)容頁都是一個(gè)Fragment公罕,而流式布局是Fragment里的VerticalGridView通過添加一個(gè)個(gè)ListRowPresenter實(shí)現(xiàn)的器紧。
??流式布局的每一行都是一個(gè)ListRowPresenter,也就是標(biāo)題TextView + 內(nèi)容HorizontalGridView的樣式楼眷。ListRowPresenter封裝了一個(gè)RowContainerView铲汪,RowContainerView是一個(gè)繼承自LinearLayout(垂直方向)的自定義View。如下所示摩桶,這個(gè)垂直方向的LinearLayout調(diào)用了addHeaderView和addRowView桥状,HeaderView就是標(biāo)題TextView帽揪,RowView就是內(nèi)容HorizontalGridView硝清。
RowPresenter.java
static class ContainerViewHolder extends Presenter.ViewHolder {
/**
* wrapped row view holder
*/
final ViewHolder mRowViewHolder;
public ContainerViewHolder(RowContainerView containerView, ViewHolder rowViewHolder) {
super(containerView);
containerView.addRowView(rowViewHolder.view);
if (rowViewHolder.mHeaderViewHolder != null) {
containerView.addHeaderView(rowViewHolder.mHeaderViewHolder.view);
}
mRowViewHolder = rowViewHolder;
mRowViewHolder.mContainerViewHolder = this;
}
}
??那怎么把一個(gè)個(gè)ListRowPresenter添加到VerticalGridView中呢?
??第一步 初始化
mVerticalGridView = mRootView.findViewById(R.id.hg_content);
//設(shè)置垂直方向上的間距
mVerticalGridView.setVerticalSpacing((int) getResources().getDimension(R.dimen.px48));
//PresenterSelector使用ArrayMap存儲(chǔ)對象和Presenter转晰,使mAdapter添加的對象能夠找到與之對應(yīng)的布局芦拿,這使得數(shù)據(jù)層和表現(xiàn)層分離
ContentPresenterSelector presenterSelector = new ContentPresenterSelector();
mAdapter = new ArrayObjectAdapter(presenterSelector);
//ItemBridgeAdapter是Presenter和RecyclerView.Adapter之間溝通的橋梁
ItemBridgeAdapter itemBridgeAdapter = new ItemBridgeAdapter(mAdapter);
mVerticalGridView.setAdapter(itemBridgeAdapter);
??第二步 創(chuàng)建HorizontalGridView的item的布局
public class TypeOneContentPresenter extends Presenter {
private Context mContext;
private static final String TAG = "TypeOneContentPresenter";
@Override
public Presenter.ViewHolder onCreateViewHolder(ViewGroup parent) {
if (mContext == null) {
mContext = parent.getContext();
}
View view = LayoutInflater.from(mContext).inflate(R.layout.item_type_one_layout, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(Presenter.ViewHolder viewHolder, Object item) {
if (item instanceof Content.DataBean.WidgetsBean) {
ViewHolder vh = (ViewHolder) viewHolder;
Glide.with(mContext)
.load(((Content.DataBean.WidgetsBean) item).getUrl())
.apply(new RequestOptions()
.centerCrop()
.override((int) mContext.getResources().getDimension(R.dimen.px400),
(int) mContext.getResources().getDimension(R.dimen.px222))
.placeholder(R.drawable.shape_default))
.into(vh.mIvTypeTwoPoster);
}
}
@Override
public void onUnbindViewHolder(Presenter.ViewHolder viewHolder) {
}
public static class ViewHolder extends Presenter.ViewHolder {
private final ImageView mIvTypeTwoPoster;
public ViewHolder(View view) {
super(view);
mIvTypeTwoPoster = view.findViewById(R.id.iv_type_two_poster);
}
}
}
??第三步 將ListRowPresenter添加到VerticalGridView
??下面以添加一個(gè)ListRowPresenter的代碼舉例士飒,添加多個(gè)ListRowPresenter就是多次執(zhí)行下面這些代碼而已。
//TypeOneContentPresenter是水平的HorizontalGridView的Item的布局蔗崎,在里面進(jìn)行數(shù)據(jù)綁定酵幕,
//有onCreateViewHolder、onBindViewHolder和onUnbindViewHolder三個(gè)方法缓苛,和RecyclerView的Adapter的三個(gè)方法的作用一樣
//arrayObjectAdapterOne是水平的HorizontalGridView的數(shù)據(jù)
ArrayObjectAdapter arrayObjectAdapterOne = new ArrayObjectAdapter(new TypeOneContentPresenter());
List<Content.DataBean.WidgetsBean> listOne = dataBean.getWidgets();
if (listOne == null) {
return;
}
arrayObjectAdapterOne.addAll(0, listOne);
//HeaderItem是標(biāo)題的對象芳撒,headerItem為null的話,不顯示標(biāo)題
HeaderItem headerItem = null;
if (dataBean.getShowTitle()) {
//dataBean.getTitle()就是標(biāo)題顯示的字符串
headerItem = new HeaderItem(dataBean.getTitle());
}
ListRow listRowOne = new ListRow(headerItem, arrayObjectAdapterOne);
//mAdapter是VerticalGridView的ArrayObjectAdapter對象
mAdapter.add(listRowOne);
??看完還覺得不清楚的話未桥,可以看看Demo里的ContentFragment這個(gè)類笔刹。
實(shí)現(xiàn)四 ViewPager禁止切頁
??ViewPager繼承自ViewGroup,所以根據(jù)事件分發(fā)機(jī)制我們知道冬耿,重寫ViewPager的dispatchKeyEvent就能夠攔截ViewPager的Key事件舌菜,那我們看看ViewPager的dispatchKeyEvent是怎么實(shí)現(xiàn)的。
??看ViewPager的源碼亦镶,ViewPager的dispatchKeyEvent返回了super.dispatchKeyEvent(event) || executeKeyEvent(event)
日月,那我們看看super.dispatchKeyEvent(event)
和executeKeyEvent(event)
都做了什么操作$凸牵看過executeKeyEvent(event)
后爱咬,知道了按遙控器的左右鍵時(shí),pageLeft()
和pageRight()
執(zhí)行setCurrentItem
進(jìn)行切頁绊起,知道這個(gè)就好辦了台颠。我們自定義ViewPager,重寫pageLeft()
和pageRight()
這兩個(gè)方法勒庄,讓他們不執(zhí)行setCurrentItem
而是直接返回True就可以了串前。(注:我的Demo里是可以切頁的,Demo沒有攔截切頁)
??ViewPager禁止切頁修改方式如下所示实蔽。
public class TabViewPager extends ViewPager {
public static final String TAG = "TabViewPager";
private final Rect mTempRect = new Rect();
public TabViewPager(@NonNull Context context) {
super(context);
}
public TabViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
return super.dispatchKeyEvent(event) || executeKeyEvent(event);
}
public boolean executeKeyEvent(@NonNull KeyEvent event) {
boolean handled = false;
if (event.getAction() == KeyEvent.ACTION_DOWN) {
switch (event.getKeyCode()) {
case KeyEvent.KEYCODE_DPAD_LEFT:
handled = arrowScroll(FOCUS_LEFT);
break;
case KeyEvent.KEYCODE_DPAD_RIGHT:
handled = arrowScroll(FOCUS_RIGHT);
break;
}
}
return handled;
}
public boolean arrowScroll(int direction) {
View currentFocused = findFocus();
if (currentFocused == this) {
currentFocused = null;
} else if (currentFocused != null) {
boolean isChild = false;
for (ViewParent parent = currentFocused.getParent(); parent instanceof ViewGroup;
parent = parent.getParent()) {
if (parent == this) {
isChild = true;
break;
}
}
if (!isChild) {
// This would cause the focus search down below to fail in fun ways.
final StringBuilder sb = new StringBuilder();
sb.append(currentFocused.getClass().getSimpleName());
for (ViewParent parent = currentFocused.getParent(); parent instanceof ViewGroup;
parent = parent.getParent()) {
sb.append(" => ").append(parent.getClass().getSimpleName());
}
Log.e(TAG, "arrowScroll tried to find focus based on non-child "
+ "current focused view " + sb.toString());
currentFocused = null;
}
}
boolean handled = false;
View nextFocused = FocusFinder.getInstance().findNextFocus(this, currentFocused,
direction);
if (nextFocused != null && nextFocused != currentFocused) {
if (direction == View.FOCUS_LEFT) {
// If there is nothing to the left, or this is causing us to
// jump to the right, then what we really want to do is page left.
final int nextLeft = getChildRectInPagerCoordinates(mTempRect, nextFocused).left;
final int currLeft = getChildRectInPagerCoordinates(mTempRect, currentFocused).left;
if (currentFocused != null && nextLeft >= currLeft) {
handled = pageLeft();
} else {
handled = nextFocused.requestFocus();
}
} else if (direction == View.FOCUS_RIGHT) {
// If there is nothing to the right, or this is causing us to
// jump to the left, then what we really want to do is page right.
final int nextLeft = getChildRectInPagerCoordinates(mTempRect, nextFocused).left;
final int currLeft = getChildRectInPagerCoordinates(mTempRect, currentFocused).left;
if (currentFocused != null && nextLeft <= currLeft) {
handled = pageRight();
} else {
handled = nextFocused.requestFocus();
}
}
} else if (direction == FOCUS_LEFT || direction == FOCUS_BACKWARD) {
// Trying to move left and nothing there; try to page.
handled = pageLeft();
} else if (direction == FOCUS_RIGHT || direction == FOCUS_FORWARD) {
// Trying to move right and nothing there; try to page.
handled = pageRight();
}
if (handled) {
playSoundEffect(SoundEffectConstants.getContantForFocusDirection(direction));
}
return handled;
}
private Rect getChildRectInPagerCoordinates(Rect outRect, View child) {
if (outRect == null) {
outRect = new Rect();
}
if (child == null) {
outRect.set(0, 0, 0, 0);
return outRect;
}
outRect.left = child.getLeft();
outRect.right = child.getRight();
outRect.top = child.getTop();
outRect.bottom = child.getBottom();
ViewParent parent = child.getParent();
while (parent instanceof ViewGroup && parent != this) {
final ViewGroup group = (ViewGroup) parent;
outRect.left += group.getLeft();
outRect.right += group.getRight();
outRect.top += group.getTop();
outRect.bottom += group.getBottom();
parent = group.getParent();
}
return outRect;
}
boolean pageLeft() {
return true;
}
boolean pageRight() {
return true;
}
}
實(shí)現(xiàn)五 邊界抖動(dòng)
??邊界抖動(dòng)的實(shí)現(xiàn)方式是:當(dāng)按某個(gè)方向鍵的時(shí)候荡碾,發(fā)現(xiàn)該方向上找到的的下一個(gè)View為null,那這時(shí)候給當(dāng)前焦點(diǎn)View執(zhí)行抖動(dòng)動(dòng)畫就行了局装。所以只要重寫dispatchKeyEvent就行了坛吁。具體代碼可以看Demo中的TabHorizontalGridView、TabVerticalGridView和TabViewPager這三個(gè)類铐尚。目前Demo中只在第一個(gè)頁面按左鍵和最后一個(gè)頁面按右鍵時(shí)添加了抖動(dòng)效果拨脉。
實(shí)現(xiàn)六 焦點(diǎn)View上劃過一道光
??其實(shí)這個(gè)效果就是一張圖片執(zhí)行了水平方向上的移動(dòng)動(dòng)畫。
??我的實(shí)現(xiàn)方式是自定義了ConstraintLayout宣增,當(dāng)ConstraintLayout獲取焦點(diǎn)時(shí)玫膀,將閃光圖片執(zhí)行一個(gè)屬性動(dòng)畫。所以爹脾,我所有的能獲取焦點(diǎn)的Item的根布局都是這個(gè)自定義的ConstraintLayout帖旨。具體代碼可以看Demo中的ImgConstraintLayout這個(gè)類箕昭。
實(shí)現(xiàn)七 自定義標(biāo)題
??默認(rèn)的標(biāo)題RowHeaderPresenter只有文本樣式,如果想要在文本前加上圖片或者變成其它的樣式解阅,需要自定義RowHeaderPresenter落竹。下面說一下自定義的步驟。
??第一步货抄,新建一個(gè)ImageRowHeaderPresenter類述召,繼承自RowHeaderPresenter。
public class ImageRowHeaderPresenter extends RowHeaderPresenter {
private final int mLayoutResourceId;
private final boolean mAnimateSelect;
public ImageRowHeaderPresenter() {
this(R.layout.lb_img_row_header);
}
/**
* @hide
*/
@RestrictTo(LIBRARY_GROUP_PREFIX)
public ImageRowHeaderPresenter(int layoutResourceId) {
this(layoutResourceId, true);
}
/**
* @hide
*/
@RestrictTo(LIBRARY_GROUP_PREFIX)
public ImageRowHeaderPresenter(int layoutResourceId, boolean animateSelect) {
mLayoutResourceId = layoutResourceId;
mAnimateSelect = animateSelect;
}
@Override
public Presenter.ViewHolder onCreateViewHolder(ViewGroup parent) {
View root = LayoutInflater.from(parent.getContext())
.inflate(mLayoutResourceId, parent, false);
HeadViewHolder viewHolder = new HeadViewHolder(root);
if (mAnimateSelect) {
setSelectLevel(viewHolder, 0);
}
return viewHolder;
}
@Override
public void onBindViewHolder(Presenter.ViewHolder viewHolder, Object item) {
HeaderItem headerItem = item == null ? null : ((Row) item).getHeaderItem();
if (headerItem == null) {
if ( viewHolder.view.findViewById(R.id.row_header) != null) {
((TextView)viewHolder.view.findViewById(R.id.row_header)).setText(null);
}
viewHolder.view.setContentDescription(null);
viewHolder.view.setVisibility(View.GONE);
} else {
if (viewHolder.view.findViewById(R.id.row_header) != null) {
((TextView)viewHolder.view.findViewById(R.id.row_header)).setText(headerItem.getName());
}
viewHolder.view.setContentDescription(headerItem.getContentDescription());
viewHolder.view.setVisibility(View.VISIBLE);
}
}
public static class HeadViewHolder extends ViewHolder {
public HeadViewHolder(View view) {
super(view);
}
}
}
??第二步蟹地,調(diào)用setHeaderPresenter設(shè)置自定義標(biāo)題樣式桨武。
TypeFiveListRowPresenter listRowPresenterFive = new TypeFiveListRowPresenter();
listRowPresenterFive.setShadowEnabled(false);
listRowPresenterFive.setSelectEffectEnabled(false);
listRowPresenterFive.setKeepChildForeground(false);
listRowPresenterFive.setHeaderPresenter(new ImageRowHeaderPresenter());
addClassPresenter(ListRow.class, listRowPresenterFive, TypeFiveContentPresenter.class);
實(shí)現(xiàn)八 已安裝應(yīng)用
?? 已安裝應(yīng)用列表展示已經(jīng)安裝的所有應(yīng)用并能點(diǎn)擊打開應(yīng)用。獲取焦點(diǎn)時(shí)锈津,圖片上添加了焦點(diǎn)框呀酸,應(yīng)用名過長時(shí),跑馬燈滾動(dòng)展示琼梆。學(xué)習(xí)VerticalGridView的基本使用時(shí)可以參考這個(gè)頁面性誉。
實(shí)現(xiàn)九 一張大圖多張小圖樣式
?? 有人問怎么實(shí)現(xiàn)一張大圖多張小圖樣式,我就花時(shí)間寫了一種實(shí)現(xiàn)方式茎杂。其實(shí)也就是模擬ListRowPresenter的思想寫的错览,但沒有進(jìn)行封裝,寫的不優(yōu)雅煌往。如果大家有更好的實(shí)現(xiàn)方式倾哺,望指教。
?? 新類型的主要實(shí)現(xiàn)類請參考:TypeSevenPresenter.java
結(jié)束語
??寫代碼的修行路刽脖,道阻且長羞海。
??如果我有寫的不好的地方或者錯(cuò)誤的地方,還請批評指正曲管。
??如果此文對您稍微有點(diǎn)用却邓,希望您能在簡書點(diǎn)個(gè)贊,并在Github點(diǎn)個(gè)星院水。謝謝腊徙!
??Demo地址:https://github.com/iSuperRed/LeanbackTvSample.git