使用Leanback写国内UI风格的TV应用

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

图一 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
实现七 自定义标题
使用Leanback写国内UI风格的TV应用_第1张图片
自定义标题样式.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的基本使用时可以参考这个页面。

使用Leanback写国内UI风格的TV应用_第2张图片
已安装应用.jpg
实现九 一张大图多张小图样式

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

一张大图多张小图.png

结束语

  写代码的修行路,道阻且长。
  如果我有写的不好的地方或者错误的地方,还请批评指正。
  如果此文对您稍微有点用,希望您能在点个赞,并在Github点个星。谢谢!
  Demo地址:https://github.com/iSuperRed/LeanbackTvSample.git

你可能感兴趣的:(使用Leanback写国内UI风格的TV应用)