图一 LeanbackTvSample_1.png

背景

现在国内主流的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。

图二 LeanbackTvSample_2.png

实现

实现一 切换标题,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中只在第一个页面按左键和最后一个页面按右键时添加了抖动效果。

边界抖动.gif

实现六 焦点View上划过一道光

其实这个效果就是一张图片执行了水平方向上的移动动画。

我的实现方式是自定义了ConstraintLayout,当ConstraintLayout获取焦点时,将闪光图片执行一个属性动画。所以,我所有的能获取焦点的Item的根布局都是这个自定义的ConstraintLayout。具体代码可以看Demo中的ImgConstraintLayout这个类。

焦点View上划过一道光.gif

实现七 自定义标题

自定义标题样式.png

默认的标题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的基本使用时可以参考这个页面。

已安装应用.jpg

实现九 一张大图多张小图样式

有人问怎么实现一张大图多张小图样式,我就花时间写了一种实现方式。其实也就是模拟ListRowPresenter的思想写的,但没有进行封装,写的不优雅。如果大家有更好的实现方式,望指教。

新类型的主要实现类请参考:TypeSevenPresenter.java

一张大图多张小图.png

结束语

写代码的修行路,道阻且长。

如果我有写的不好的地方或者错误的地方,还请批评指正。

如果此文对您稍微有点用,希望您能在简书点个赞,并在Github点个星。谢谢!

Demo地址:https://github.com/iSuperRed/LeanbackTvSample.git

android tv 国内使用,Android TV:使用Leanback写国内UI风格的TV应用相关推荐

  1. Android电视切换回放,Android Studio V3.12环境下TV开发教程(五)建立电视回放应用...

    Android Studio V3.12环境下TV开发教程 文章源自:光谷佳武 https://blog.csdn.net/jiawuhan/article/details/80619382 浏览和播 ...

  2. android+tv+自动切换,Android TV 重写GridView,实现焦点放大效果

    关于缩放,使用了view.setScaleX/Y 方法,api11以上即可. 重写dispatchDraw(),绘制选中项的焦点效果.(注意带阴影的焦点图需要微调偏移量) 要将选中项绘制显示在顶层,所 ...

  3. android tv 云播放器,Android TV开发总结(六)构建一个TV app的直播节目实例

    近年来,Android TV的迅速发展,传统的有线电视受到较大的冲击,在TV上用户同样也可以看到各个有线电视的直播频道,相对于手机,这种直播节目,体验效果更佳,尤其是一样赛事节目,大屏幕看得才够痛快, ...

  4. android+tv+开发+icon,Android TV+HTML5的架构设计和应用开发经验.pdf

    Android TV + HTML5 : 架構設計與App開發經驗 高煥堂 亞太地區Android 技術大會主席 中國電子視像行業協會高級顧問 ★ 講題簡介 HTML5天生麗質,具有天賦的跨端.跨雲. ...

  5. android电视盒子开发,Android TV机顶盒开发之初级接触

    最近接触了点Andiroid 机顶盒开发,简单的写一下,希望我的经验可以给各位带来一点经验.图片我就不上传的,很麻烦的! 首先是Android的apk如何安装到机顶盒上?将TV连接机顶盒,然后找到设置 ...

  6. u盘里android文件夹作用,Android应用开发android tv box ---- 插入u盘直接播放指定文件夹中的视频...

    本文将带你了解Android应用开发android tv box ---- 插入u盘直接播放指定文件夹中的视频,希望本文对大家学Android有所帮助. android tv box ---- 插入u ...

  7. Aandroid TV 基于Leanback支持最新MD设计的TV开发框架

    原文地址:http://blog.csdn.net/sk719887916 作者:skay 基于6.0最新的API 支持TV的框架 Android 6.0已完美支持TV开发,之前的5.0后Recycl ...

  8. 国内首本Android开发图书之双剑

    国内首本Android开发图书之双剑Google Android SDK开发范例大全 (china-pub首发热卖中) [市场价]¥79.00 [PUB价]¥59.25 详情查看:http://www ...

  9. 国内主流Android安卓应用市场简介

    GOOGLE对安卓市场的建设比较开放,所以安卓应用市场有点小乱,不懂行的经常搞不清楚哪个是官方.哪些是第三方.本文小结下国内几个主流的Android市场: 1. Android Market 官方的大 ...

最新文章

  1. 网络知识科普 | 你未必了解的DNS
  2. CSS清浮动处理(Clear与BFC)
  3. Leetcode--213. 打家劫舍Ⅱ
  4. 93没有了_杭州1米93程序员征婚贴火了!年薪50万,孩子随妈姓,没有皇位要继承...
  5. 即时通讯学习笔记007---在windows下安装openfire_并且使用自定义的数据库这里用mysql
  6. 开电视显示网管服务器数据下发超时,关于网络管理中的常见问题解决
  7. 安装openstack(pike版本)nova节点,yum安装报错分析
  8. gitbook mac 版本的安装
  9. 什么软件能打开Android,哪位晓得apk文件用什么软件打开
  10. 怎么用计算机拨号手机,教你如何用电脑连接手机自动打电话
  11. Python高级配色 RGB
  12. python实训报告5000字_实训总结5000字
  13. Update|亚洲精品菜订餐平台「Chowbus」获400万美金新融资,由Greycroft和FJ labs领投...
  14. hdu 1109 Run Away
  15. 什么是html的语义化?
  16. 浏览器主页被2345劫持捆绑解决方案---极其简单!
  17. layui数据表格的使用
  18. MT6580电池电压ADC完全不对,最高达到4.7V
  19. [转]触乐独家:揭秘愈演愈烈的苹果“做号退款”生态圈
  20. JavaSE-Adventure(III): Generics 泛型程序设计

热门文章

  1. 若尔当型(The Jordan form)
  2. 信号逻辑电平标准详解
  3. MATLAB GUI设计手写输入板
  4. 什么技术,让阿里拿下国家技术发明奖?
  5. 性能优化08_电量优化:监控电量状态
  6. 通过360站长平台,免费让360显示官网
  7. 联想服务器看硬盘,联想服务器SSD硬盘
  8. rcnn 回归_基础目标检测算法介绍:CNN、RCNN、Fast RCNN和Faster RCNN
  9. linux设备树使用手册word免费版
  10. 程序员接私活怎样防止做完了不给钱?