Demo地址:https://github.com/iSuperRed/LeanbackTvSample.git
背景
现在国内主流的TV端视频播放软件、TV端桌面的UI风格都差不多了。这个“差不多”不仅是说版式排布“差不多”,也是在说交互逻辑的“差不多”。
从版式排布上来说,主页(如图一所示)的最上面会有一排比较重要的功能按钮,比如搜索、历史、登录、引导开通VIP、广告、网络状态、时间、Logo等等,这一排按钮下面是视频分类的标题,标题下面是流式布局的视频内容页,通过切换标题能够切换视频内容页,在视频内容页按遥控器下键能够加载多页内容,当前视频内容页没有更多内容时,会有文字提示“到底部了”或者会有个按钮,点击该按钮后会回到顶部;而焦点态的标记是通过放大、加边框和加阴影的方式来实现的。
从交互逻辑上来说,进到主页后,会有一个默认焦点,很多App的默认焦点都是标题上的“精选”页,然后通过遥控器的上、下、左、右、返回、OK、Home等按键进行人机交互;如果你想按返回键退出当前App,焦点会先回到当前页的标题,如果当前标题不是进入主页的默认标题,再按返回键会先回到默认标题,回到默认标题后再按返回键会给个对话框问一下“是否要退出App”,确定退出后才会真的退出;很多App都会加边界抖动,当按遥控器方向键时,查找该方向的下一个焦点View为null时,会给当前焦点View一个抖动动画来提示用户当前已经到边界了。
上面这些就是当前国内视频播放软件的一些基本特点了。
分析
下面我来说道说道我是怎么通过Leanback实现这些功能的。
如图二所示,我要实现一个这样的页面。标题我使用的是HorizontalGridView(一个水平方向的RecyclerView);标题下面的内容页是ViewPager嵌套Fragment,ViewPager实现切页,每一个页都是一个Fragment;Fragment的布局是一个VerticalGridView(一个是垂直方向上的RecyclerView),VerticalGridView的每一个item又是一个HorizontalGridView,相当于垂直方向上的RecyclerView嵌套了水平方向的RecyclerView。
实现
实现一 切换标题,ViewPager联动
关于标题的实现可以看这篇 聊一聊 Leanback 中的 HorizontalGridView,或者看本篇Demo的源码也可以。标题的一个重要的作用是切换页面,所以OnChildViewHolderSelectedListener这个监听很重要,HorizontalGridView添加这个监听后,在切换标题Item时,会回调这个监听的onChildViewHolderSelected方法,而我就是在这个方法里调用了mViewPager.setCurrentItem
来实现标题和ViewPager的联动的。
顺便提一下,OnChildViewHolderSelectedListener这个接口有两个方法,分别为onChildViewHolderSelected和onChildViewHolderSelectedAndPositioned。那有什么区别呢?注释里说,这个监听可能会改变子View的大小和位置,所以如果想要获取子View的布局位置的话,要重写onChildViewHolderSelectedAndPositioned这个方法,不过我这里暂时没用到它。
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切页标题不刷新,调用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切页标题不刷新,调用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;
}
}
实现二 切换ViewPager,标题联动
那如果想要切换ViewPager,让标题联动该如何实现呢?
ViewPager有个切页监听addOnPageChangeListener,添加这个监听后,在切页时,会回调onPageSelected这个方法,我就是在这个方法里调用了mHorizontalGridView.setSelectedPosition(position);
进行标题的切换的,调用mHorizontalGridView.setSelectedPosition(position);
这个方法后,标题的这个监听onChildViewHolderSelectedListener就会执行回调。
private void initViewPager(List 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);
}
});
}
实现三 流式布局实现
那每一个内容页的流式布局是如何实现的呢?
每一个内容页都是一个Fragment,而流式布局是Fragment里的VerticalGridView通过添加一个个ListRowPresenter实现的。
流式布局的每一行都是一个ListRowPresenter,也就是标题TextView + 内容HorizontalGridView的样式。ListRowPresenter封装了一个RowContainerView,RowContainerView是一个继承自LinearLayout(垂直方向)的自定义View。如下所示,这个垂直方向的LinearLayout调用了addHeaderView和addRowView,HeaderView就是标题TextView,RowView就是内容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;
}
}
那怎么把一个个ListRowPresenter添加到VerticalGridView中呢?
第一步 初始化
mVerticalGridView = mRootView.findViewById(R.id.hg_content);
//设置垂直方向上的间距
mVerticalGridView.setVerticalSpacing((int) getResources().getDimension(R.dimen.px48));
//PresenterSelector使用ArrayMap存储对象和Presenter,使mAdapter添加的对象能够找到与之对应的布局,这使得数据层和表现层分离
ContentPresenterSelector presenterSelector = new ContentPresenterSelector();
mAdapter = new ArrayObjectAdapter(presenterSelector);
//ItemBridgeAdapter是Presenter和RecyclerView.Adapter之间沟通的桥梁
ItemBridgeAdapter itemBridgeAdapter = new ItemBridgeAdapter(mAdapter);
mVerticalGridView.setAdapter(itemBridgeAdapter);
第二步 创建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
下面以添加一个ListRowPresenter的代码举例,添加多个ListRowPresenter就是多次执行下面这些代码而已。
//TypeOneContentPresenter是水平的HorizontalGridView的Item的布局,在里面进行数据绑定,
//有onCreateViewHolder、onBindViewHolder和onUnbindViewHolder三个方法,和RecyclerView的Adapter的三个方法的作用一样
//arrayObjectAdapterOne是水平的HorizontalGridView的数据
ArrayObjectAdapter arrayObjectAdapterOne = new ArrayObjectAdapter(new TypeOneContentPresenter());
List listOne = dataBean.getWidgets();
if (listOne == null) {
return;
}
arrayObjectAdapterOne.addAll(0, listOne);
//HeaderItem是标题的对象,headerItem为null的话,不显示标题
HeaderItem headerItem = null;
if (dataBean.getShowTitle()) {
//dataBean.getTitle()就是标题显示的字符串
headerItem = new HeaderItem(dataBean.getTitle());
}
ListRow listRowOne = new ListRow(headerItem, arrayObjectAdapterOne);
//mAdapter是VerticalGridView的ArrayObjectAdapter对象
mAdapter.add(listRowOne);
看完还觉得不清楚的话,可以看看Demo里的ContentFragment这个类。
实现四 ViewPager禁止切页
ViewPager继承自ViewGroup,所以根据事件分发机制我们知道,重写ViewPager的dispatchKeyEvent就能够拦截ViewPager的Key事件,那我们看看ViewPager的dispatchKeyEvent是怎么实现的。
看ViewPager的源码,ViewPager的dispatchKeyEvent返回了super.dispatchKeyEvent(event) || executeKeyEvent(event)
,那我们看看super.dispatchKeyEvent(event)
和executeKeyEvent(event)
都做了什么操作。看过executeKeyEvent(event)
后,知道了按遥控器的左右键时,pageLeft()
和pageRight()
执行setCurrentItem
进行切页,知道这个就好办了。我们自定义ViewPager,重写pageLeft()
和pageRight()
这两个方法,让他们不执行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;
}
}
实现五 边界抖动
边界抖动的实现方式是:当按某个方向键的时候,发现该方向上找到的的下一个View为null,那这时候给当前焦点View执行抖动动画就行了。所以只要重写dispatchKeyEvent就行了。具体代码可以看Demo中的TabHorizontalGridView、TabVerticalGridView和TabViewPager这三个类。目前Demo中只在第一个页面按左键和最后一个页面按右键时添加了抖动效果。
实现六 焦点View上划过一道光
其实这个效果就是一张图片执行了水平方向上的移动动画。
我的实现方式是自定义了ConstraintLayout,当ConstraintLayout获取焦点时,将闪光图片执行一个属性动画。所以,我所有的能获取焦点的Item的根布局都是这个自定义的ConstraintLayout。具体代码可以看Demo中的ImgConstraintLayout这个类。
实现七 自定义标题
默认的标题RowHeaderPresenter只有文本样式,如果想要在文本前加上图片或者变成其它的样式,需要自定义RowHeaderPresenter。下面说一下自定义的步骤。
第一步,新建一个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);
}
}
}
第二步,调用setHeaderPresenter设置自定义标题样式。
TypeFiveListRowPresenter listRowPresenterFive = new TypeFiveListRowPresenter();
listRowPresenterFive.setShadowEnabled(false);
listRowPresenterFive.setSelectEffectEnabled(false);
listRowPresenterFive.setKeepChildForeground(false);
listRowPresenterFive.setHeaderPresenter(new ImageRowHeaderPresenter());
addClassPresenter(ListRow.class, listRowPresenterFive, TypeFiveContentPresenter.class);
实现八 已安装应用
已安装应用列表展示已经安装的所有应用并能点击打开应用。获取焦点时,图片上添加了焦点框,应用名过长时,跑马灯滚动展示。学习VerticalGridView的基本使用时可以参考这个页面。
实现九 一张大图多张小图样式
有人问怎么实现一张大图多张小图样式,我就花时间写了一种实现方式。其实也就是模拟ListRowPresenter的思想写的,但没有进行封装,写的不优雅。如果大家有更好的实现方式,望指教。
新类型的主要实现类请参考:TypeSevenPresenter.java
结束语
写代码的修行路,道阻且长。
如果我有写的不好的地方或者错误的地方,还请批评指正。
如果此文对您稍微有点用,希望您能在点个赞,并在Github点个星。谢谢!
Demo地址:https://github.com/iSuperRed/LeanbackTvSample.git