前言

最近在研讨Android TV开发的leanback,计划学习运用它的HorizontalGridView,并在此根底上完成一个常见TV运用的分类标题栏。类似的完成有云视听极光app,如下图:

使用HorizontalGridView实现一个TV应用的分类标题栏

Leanback宗族

leanback依赖:

implementation "androidx.leanback:leanback:1.0.0"

参阅:Leanback

leanback为我们供给了HorizontalGridViewVerticalGridView用于水平缓笔直方向的网格布局,两者均集成自RecyclerView。这儿有几个宗族成员需求了解一下:

  • BaseGridView:承继RecyclerView,处理与焦点相关的逻辑
    • HorizontalGridView承继自BaseGridView,供给水平布局。
    • VerticalGridView承继自BaseGridView,供给笔直布局。
  • ArrayObjectAdapter:能够理解为一个数据适配器,用于对数据源的管理。
  • Presenter:供给视图的创立和数据绑定,类似RecyclerView.Adapter的能力。
  • PresenterSelector:依据不同的数据类型,能够回来不同的Presenter,到达多布局的意图。
  • ItemBridgeAdapter承继自RecyclerView.Adapter,真正的Adapter。

Presenter、PresenterSelector、ItemBridgeAdapter

Presenter的功用类似于RecyclerView中多布局中某一个布局的视图数据适配。供给的办法与RecyclerView.Adapter类似。

// 截取自tv-samples的LeanbackShowCase
public class ImageCardViewPresenter extends AbstractCardPresenter<ImageCardView> {
    。。。
    @Override
    protected ImageCardView onCreateView() {
        ImageCardView imageCardView = new ImageCardView(getContext());
        return imageCardView;
    }
    @Override
    public void onBindViewHolder(Card card, final ImageCardView cardView) {
        cardView.setTag(card);
        cardView.setTitleText(card.getTitle());
        cardView.setContentText(card.getDescription());
        if (card.getLocalImageResourceName() != null) {
            int resourceId = getContext().getResources()
                    .getIdentifier(card.getLocalImageResourceName(),
                            "drawable", getContext().getPackageName());
            Glide.with(getContext())
                    .asBitmap()
                    .load(resourceId)
                    .into(cardView.getMainImageView());
        }
    }
}

PresenterSelector的功用是依据数据类型回来对应布局的Presenter。

// 截取自tv-samples的LeanbackShowCase
public class CardPresenterSelector extends PresenterSelector {
    private final Context mContext;
    private final HashMap<Card.Type, Presenter> presenters = new HashMap<Card.Type, Presenter>();
    public CardPresenterSelector(Context context) {
        mContext = context;
    }
    @Override
    public Presenter getPresenter(Object item) {
        if (!(item instanceof Card)) throw new RuntimeException(
                String.format("The PresenterSelector only supports data items of type '%s'",
                        Card.class.getName()));
        Card card = (Card) item;
        Presenter presenter = presenters.get(card.getType());
        if (presenter == null) {
            switch (card.getType()) {
                。。。
            }    
        }
        presenters.put(card.getType(), presenter);
        return presenter;
    }
}

ItemBridgeAdaptergetItemViewType时,能够经过PresenterSelector获取不同的Presenter,在后续的onCreateViewHolder会经过viewType获取到对应的Presenter进行视图创立。属于是对普通多布局写法的扩展。

@Override
public int getItemViewType(int position) {
    PresenterSelector presenterSelector = mPresenterSelector != null
            ? mPresenterSelector : mAdapter.getPresenterSelector();
    Object item = mAdapter.get(position);
    Presenter presenter = presenterSelector.getPresenter(item);
    int type = mPresenters.indexOf(presenter);
    if (type < 0) {
        mPresenters.add(presenter);
        type = mPresenters.indexOf(presenter);
        if (DEBUG) Log.v(TAG, "getItemViewType added presenter " + presenter + " type " + type);
        onAddPresenter(presenter, type);
        if (mAdapterListener != null) {
            mAdapterListener.onAddPresenter(presenter, type);
        }
    }
    return type;
}
@Override
public final RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    if (DEBUG) Log.v(TAG, "onCreateViewHolder viewType " + viewType);
    Presenter presenter = mPresenters.get(viewType);
    Presenter.ViewHolder presenterVh;
    View view;
    if (mWrapper != null) {
        view = mWrapper.createWrapper(parent);
        presenterVh = presenter.onCreateViewHolder(parent);
        mWrapper.wrap(view, presenterVh.view);
    } else {
        presenterVh = presenter.onCreateViewHolder(parent);
        view = presenterVh.view;
    }
    。。。
    return viewHolder;
}

ps:假如涉及到行列嵌套的场景,能够运用ListRowListRowPresenter到达HorizontalGridView和VerticalGridView嵌套的作用。详情可参阅下面的文章或项目。

关于Leanback的根底内容可参阅:

android/tv-samples

从 Android 开发到读懂源码 第05期:Leanback 结构源码简析

聊一聊 Leanback 中的 HorizontalGridView

TV运用的分类标题栏完成

简略的需求描绘:

  • item获取到焦点后显现为赤色背景。
  • 焦点脱离标题栏后,item仍显现为选中状况,样式为底部显现下划线。
  • 焦点从别处回到标题栏,默许选中回之前的item。
  • 焦点在标题栏移动,一直居中
  • 手动获取焦点,选中item。

自定义一个item

简略完成一个自定义的TextView,作为标题栏的item。

class TitleItem @JvmOverloads constructor(
    context: Context,
    attr: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AppCompatTextView(context, attr, defStyleAttr) {
    private val backgroundRect: RectF = RectF()
    private val bottomLineRect: RectF = RectF()
    private val backgroundPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
    init {
        isFocusable = true
        isFocusableInTouchMode = true
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            defaultFocusHighlightEnabled = false
        }
    }
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        backgroundRect.top = width * 0.05f
        backgroundRect.left = width * 0.05f
        backgroundRect.right = w * 1f - width * 0.05f
        backgroundRect.bottom = h * 1f - width * 0.05f
        bottomLineRect.top = h * 1f - h * 0.2f - width * 0.05f
        bottomLineRect.left = w * 0.3f
        bottomLineRect.right = w * 0.7f
        bottomLineRect.bottom = h * 1f - h * 0.1f - width * 0.05f
    }
    override fun onDraw(canvas: Canvas?) {
        canvas ?: return
        if (isFocused) {  // 获取焦点时赤色背景
            canvas.drawRoundRect(backgroundRect, width / 4f, width / 4f, backgroundPaint.apply {
                this.color = Color.RED
            })
        } else if (isSelected) {  // 选中状况为赤色下划线
            canvas.drawRoundRect(bottomLineRect, width / 2f, width / 2f, backgroundPaint.apply {
                this.color = Color.RED
            })
        }
        super.onDraw(canvas)
    }
}

为适配“焦点脱离标题栏后,item仍显现为选中状况”的需求,这儿区分运用isFocusedisSelected的绘制部分,在焦点脱离标题栏后,会将当时item的isSelected设置为true。这个后面会讲到。

关联

val rowAdapter = ArrayObjectAdapter(TitlePresenter())
titles.forEach {
    rowAdapter.add(it)
}
val gridView = findViewById<HorizontalGridView>(R.id.gridView)
gridView.adapter = ItemBridgeAdapter(rowAdapter)
  • TitlePresenter作为上述TitleItem的视图创立和数据绑定
  • ArrayObjectAdapter增加对应的title调集
  • 经过ItemBridgeAdapterHorizontalGridView设置视图和数据适配

失掉焦点后,持续坚持item的选中作用

失掉焦点的场景在TV比较常见的是焦点从标题栏移动到下方的内容区域,此刻需求坚持item的选中作用。以下这儿笔者选用的计划

// TitlePresenter
override fun onBindViewHolder(viewHolder: Presenter.ViewHolder?, item: Any?) {
    viewHolder ?: return
    item ?: return
    if (viewHolder is ViewHolder && item is String) {
        viewHolder.tvTitle.text = item
        viewHolder.tvTitle.onFocusChangeListener = onFocusChangeListener
        viewHolder.tvTitle.tag = item
    }
}

onBindViewHolder时,设置item的tag,以此来区分是哪个标题。

// TitlePresenter
/** 用于记载获取焦点和失掉焦点的item **/
private val map = mutableMapOf<Boolean, String>()
private var lastSelectedView: View? = null
private val onFocusChangeListener = OnFocusChangeListener { view, hasFocus ->
    map[hasFocus] = view.tag.toString()
    if (map[true] == map[false]) {
        //  取得焦点和失掉焦点的是同一个item,会有以下两种状况:
        //  RecyclerView失掉焦点
        //  RecyclerView从头取得焦点
        //  让此item坚持选中状况
        view.isSelected = true
        lastSelectedView = view
    } else {
        lastSelectedView?.isSelected = false
        lastSelectedView = null
    }
}

这儿简略理解是:

  • 假如OnFocusChangeListener两次回调的item相同,则阐明标题栏在失掉焦点或者从头取得焦点,此刻就要将item的isSelected设置为true,并记载最终一次选中的item
  • 假如两次回调的item不相同,阐明焦点在标题栏内部的item间移动,只需求将上一次选中的item的isSelected设置为false即可。

这样就能够到达“失掉焦点后,持续坚持item的选中作用”的意图,这种做法是经过外部监听的方式完成的,假如有更好的办法也能够一同评论。

参阅:

Android TV–RecyclerView中item焦点实战

焦点一直居中

HorizontalGridView能够经过设置setFocusScrollStrategy来批改焦点的翻滚战略。默许就是FOCUS_SCROLL_ALIGNED,即焦点居中。

  • FOCUS_SCROLL_ALIGNED:焦点居中
  • FOCUS_SCROLL_ITEM:焦点在末尾,即RecyclerView原有的翻滚作用
  • FOCUS_SCROLL_PAGE:翻页,每次翻滚会翻滚到完好新的一页

假如是自定义RecyclerView完成的话,也可经过该办法来批改焦点居中问题:

// 自定义RecyclerView时,可在每次焦点更新时批改翻滚的方位
private fun makeViewCenter(view: View) {
    val parentLeft = this.paddingLeft
    val parentTop = this.paddingTop
    val parentRight = this.width - this.paddingRight
    val childLeft = view.left - view.scrollX
    val childTop = view.top - view.scrollY
    val dx = childLeft - parentLeft - (parentRight - view.width) / 2
    val dy = childTop - parentTop - (parentTop - view.height) / 2
    smoothScrollBy(dx, dy)
}

参阅:

android TV常见需求,焦点item坚持居中

从头取得焦点后,选中前次的item

HorizontalGridView的父类BaseGridView默许处理了这个焦点问题。假如是想自定义一个有焦点回忆功用的布局,能够考虑在焦点查找时经过addFocusables之前选中的item直接加入到可获取焦点的调集里,这样就能够确保前次选中的item能够持续获取焦点了。

下面以自定义RecyclerView时,在addFocusables中将当时选中的方位对应的view从头增加到可获取焦点的调集。 这儿只简略展现,不展开叙述。

override fun addFocusables(views: ArrayList<View>?, direction: Int, focusableMode: Int) {
    val view: View? = layoutManager?.findViewByPosition(currentSelectedPosition)
    if (hasFocus() || currentSelectedPosition < 0 || view == null) {
        super.addFocusables(views, direction, focusableMode)
    } else if (view.isFocusable && view.visibility == View.VISIBLE) {
        // 上一次选中的view,直接增加到调集,不再进行递归
        views?.add(view)
    } else {
        super.addFocusables(views, direction, focusableMode)
    }
}

手动获取焦点,选中item

日常开发会有需求,需求在初始化时默许选中第几个item,默许展现哪个分类的内容。此刻能够合作HorizontalGridView#setSelectedPosition办法,内部是交给了自定义LayoutManager完成的,详细就是经过需求选中的方位,翻滚到对应方位更新视图和内部数据。这儿不过多深究。

public void setSelectedPosition(int position) {
    mLayoutManager.setSelection(position, 0);
}

当然,调用这个只是将RecyclerView翻滚到某个方位,不能让指定方位的item获取焦点。此刻

  • 需求确保的是item的focusablefocusableInTouchMode属性为true,确保item能够获取到焦点
    isFocusable = true
    isFocusableInTouchMode = true
    or
    android:focusable="true"
    android:focusableInTouchMode="true"
    
  • 然后调用HorizontalGridView#requestFocus
    gridView.requestFocus()
    

这样就能确保指定的item获取到焦点了。

源码浅析

至于为什么在HorizontalGridView调用requestFocus后,item能获取到焦点呢?这儿作一个浅析:

  • 当在onCreate时调用requestFocus后,HorizontalGridView获取到焦点。等到onLayout时,RecyclerView会对应触发LayoutManager#onLayoutChildren办法。我们来看看自定义的GridLayoutManager:

    // GridLayoutManager.java
    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
       。。。
        // check if we need align to mFocusPosition, this is usually true unless in smoothScrolling
        // 1
        final boolean scrollToFocus = !isSmoothScrolling()
                && mFocusScrollStrategy == BaseGridView.FOCUS_SCROLL_ALIGNED;
        。。。
        do {
            updateScrollLimits();
            oldFirstVisible = mGrid.getFirstVisibleIndex();
            oldLastVisible = mGrid.getLastVisibleIndex();
            // 2
            focusToViewInLayout(hadFocus, scrollToFocus, -deltaPrimary, -deltaSecondary);
            appendVisibleItems();
            prependVisibleItems();
            // b/67370222: do not removeInvisibleViewsAtFront/End() in the loop, otherwise
            // loop may bounce between scroll forward and scroll backward forever. Example:
            // Assuming there are 19 items, child#18 and child#19 are both in RV, we are
            // trying to focus to child#18 and there are 200px remaining scroll distance.
            //   1  focusToViewInLayout() tries scroll forward 50 px to align focused child#18 on
            //      right edge, but there to compensate remaining scroll 200px, also scroll
            //      backward 200px, 150px pushes last child#19 out side of right edge.
            //   2  removeInvisibleViewsAtEnd() remove last child#19, updateScrollLimits()
            //      invalidates scroll max
            //   3  In next iteration, when scroll max/min is unknown, focusToViewInLayout() will
            //      align focused child#18 at center of screen.
            //   4  Because #18 is aligned at center, appendVisibleItems() will fill child#19 to
            //      the right.
            //   5  (back to 1 and loop forever)
        } while (mGrid.getFirstVisibleIndex() != oldFirstVisible
                || mGrid.getLastVisibleIndex() != oldLastVisible);
    }
    
    • 注释1:scrollToFocus,这儿需求确认焦点翻滚战略是FOCUS_SCROLL_ALIGNED,即焦点一直居中对齐
    • 注释2:focusToViewInLayout会使默许item获取焦点或进行焦点对齐
    // GridLayoutManager.java
    // called by onLayoutChildren, either focus to FocusPosition or declare focusViewAvailable
    // and scroll to the view if framework focus on it.
    private void focusToViewInLayout(boolean hadFocus, boolean alignToView, int extraDelta,
            int extraDeltaSecondary) {
        // 1
        View focusView = findViewByPosition(mFocusPosition);
        if (focusView != null && alignToView) {  // 假如是FOCUS_SCROLL_ALIGNED战略下,需求进行焦点对齐。
            scrollToView(focusView, false, extraDelta, extraDeltaSecondary);
        }
        if (focusView != null && hadFocus && !focusView.hasFocus()) {
            focusView.requestFocus();
        } else if (!hadFocus && !mBaseGridView.hasFocus()) {
            if (focusView != null && focusView.hasFocusable()) {
                mBaseGridView.focusableViewAvailable(focusView);
            } else {
                for (int i = 0, count = getChildCount(); i < count; i++) {
                    focusView = getChildAt(i);
                    if (focusView != null && focusView.hasFocusable()) {
                        mBaseGridView.focusableViewAvailable(focusView);
                        break;
                    }
                }
            }
            // focusViewAvailable() might focus to the view, scroll to it if that is the case.
            if (alignToView && focusView != null && focusView.hasFocus()) {
                scrollToView(focusView, false, extraDelta, extraDeltaSecondary);
            }
        }
    }
    

    优先重视注释1,mFocusPosition记载当时获取焦点的方位,在前面的onLayoutChildren中有这样的操作:

    // GridLayoutManager.java
    private boolean layoutInit() {
        final int newItemCount = mState.getItemCount();
        if (newItemCount == 0) {
            mFocusPosition = NO_POSITION;
            mSubFocusPosition = 0;
        } else if (mFocusPosition >= newItemCount) {
            mFocusPosition = newItemCount - 1;
            mSubFocusPosition = 0;
        } else if (mFocusPosition == NO_POSITION && newItemCount > 0) {  // 假如mFocusPosition是初始值,item数量大于0,会主动将mFocusPosition设置为0
            // if focus position is never set before,  initialize it to 0
            mFocusPosition = 0;
            mSubFocusPosition = 0;
        }
        。。。
    

    即当item数量大于0后,mFocusPosition默许为0,当然这个也能够经过前面讲到的setSelectedPosition批改的。

    回到focusToViewInLayout中,经过mFocusPosition能够获取到当时需求获取焦点的viewalignToView即为前面说到的scrollToFocus,即需求进行焦点对齐。此刻就会调用到scrollToView办法。 ps:该办法在子View主动获取焦点,选中方位改变等逻辑被广泛运用。

    // GridLayoutManager.java
    private void scrollToView(View view, View childView, boolean smooth, int extraDelta,
            int extraDeltaSecondary) {
        if ((mFlag & PF_SLIDING) != 0) {
            return;
        }
        // 改变方位记载,并响应监听
        int newFocusPosition = getAdapterPositionByView(view);
        int newSubFocusPosition = getSubPositionByView(view, childView);
        if (newFocusPosition != mFocusPosition || newSubFocusPosition != mSubFocusPosition) {
            mFocusPosition = newFocusPosition;
            mSubFocusPosition = newSubFocusPosition;
            mFocusPositionOffset = 0;
            if ((mFlag & PF_STAGE_MASK) != PF_STAGE_LAYOUT) {
                dispatchChildSelected();
            }
            if (mBaseGridView.isChildrenDrawingOrderEnabledInternal()) {
                mBaseGridView.invalidate();
            }
        }
        if (view == null) {
            return;
        }
        // MARK: - 若当时view没有焦点,且GridView有焦点,那么子view需求获取焦点
        // 1
        if (!view.hasFocus() && mBaseGridView.hasFocus()) {
            // transfer focus to the child if it does not have focus yet (e.g. triggered
            // by setSelection())
            view.requestFocus();
        }
        if ((mFlag & PF_SCROLL_ENABLED) == 0 && smooth) {
            return;
        }
        if (getScrollPosition(view, childView, sTwoInts)
                || extraDelta != 0 || extraDeltaSecondary != 0) {
            scrollGrid(sTwoInts[0] + extraDelta, sTwoInts[1] + extraDeltaSecondary, smooth);
        }
    }
    

    重视注释1,view即为需求获取焦点的item,当它没有焦点而GridView有焦点时,需求将焦点转移到item上。而GridView之所以获取到焦点是因为我们在刚开始时调用了它的requestFocus

  • 还有一种状况是,在其他状况下手动调用requestFocus后,依据ViewGroup的战略是需求分发给子View获取的,这是ViewGroup#requestFocus

    // ViewGroup.java
    public boolean requestFocus(int direction, Rect previouslyFocusedRect) {
        if (DBG) {
            System.out.println(this + " ViewGroup.requestFocus direction="
                    + direction);
        }
        int descendantFocusability = getDescendantFocusability();
        boolean result;
        switch (descendantFocusability) {
            case FOCUS_BLOCK_DESCENDANTS: // 禁止子View获取焦点
                result = super.requestFocus(direction, previouslyFocusedRect);
                break;
            case FOCUS_BEFORE_DESCENDANTS: { // 优先子View获取焦点
                final boolean took = super.requestFocus(direction, previouslyFocusedRect);
                result = took ? took : onRequestFocusInDescendants(direction,
                        previouslyFocusedRect);
                break;
            }
            case FOCUS_AFTER_DESCENDANTS: { // 子View优先获取焦点
                final boolean took = onRequestFocusInDescendants(direction, previouslyFocusedRect);
                result = took ? took : super.requestFocus(direction, previouslyFocusedRect);
                break;
            }
            default:
                throw new IllegalStateException(
                        "descendant focusability must be one of FOCUS_BEFORE_DESCENDANTS,"
                            + " FOCUS_AFTER_DESCENDANTS, FOCUS_BLOCK_DESCENDANTS but is "
                                + descendantFocusability);
        }
        if (result && !isLayoutValid() && ((mPrivateFlags & PFLAG_WANTS_FOCUS) == 0)) {
            mPrivateFlags |= PFLAG_WANTS_FOCUS;
        }
        return result;
    }
    

    所以会触发BaseGridView#onRequestFocusInDescendants办法

    // BaseGridView.java
    @Override
    public boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) {
        return mLayoutManager.gridOnRequestFocusInDescendants(this, direction,
                previouslyFocusedRect);
    }
    // GridLayoutManager.java
    boolean gridOnRequestFocusInDescendants(RecyclerView recyclerView, int direction,
            Rect previouslyFocusedRect) {
        switch (mFocusScrollStrategy) {
            case BaseGridView.FOCUS_SCROLL_ALIGNED:
            default:
                // 1
                return gridOnRequestFocusInDescendantsAligned(recyclerView,
                        direction, previouslyFocusedRect);
            case BaseGridView.FOCUS_SCROLL_PAGE:
            case BaseGridView.FOCUS_SCROLL_ITEM:
                return gridOnRequestFocusInDescendantsUnaligned(recyclerView,
                        direction, previouslyFocusedRect);
        }
    }
    private boolean gridOnRequestFocusInDescendantsAligned(RecyclerView recyclerView,
            int direction, Rect previouslyFocusedRect) {
        View view = findViewByPosition(mFocusPosition);
        if (view != null) {
            // 2
            boolean result = view.requestFocus(direction, previouslyFocusedRect);
            if (!result && DEBUG) {
                Log.w(getTag(), "failed to request focus on " + view);
            }
            return result;
        }
        return false;
    }
    

    能够看到当焦点翻滚战略是FOCUS_SCROLL_ALIGNED(注释1)时,会主动寻觅当时mFocusPosition的item,并调用其requestFocus(注释2)

作用

使用HorizontalGridView实现一个TV应用的分类标题栏

最终大约的作用就是这样了,下面有一个View用于模拟内容区焦点选中的作用。

最终

本文首要介绍如何运用HorizontalGridView完成一个TV运用的分类标题栏,中间交叉了一些对于焦点处理的解析,因为Android的焦点流程比较复杂,考虑后续写一篇文章专门整理,便利后续处理问题。