前言
最近在研究Android TV开发的leanback
,打算学习使用它的HorizontalGridView
,并在此基础上实现一个常见TV应用的分类标题栏。类似的实现有云视听极光app,如下图:
Leanback家族
leanback依赖:
implementation "androidx.leanback:leanback:1.0.0"
参考:Leanback
leanback
为我们提供了HorizontalGridView
和VerticalGridView
用于水平和垂直方向的网格布局,两者均集成自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;
}
}
ItemBridgeAdapter
在getItemViewType
时,可以通过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:如果涉及到行列嵌套的场景,可以使用ListRow
、ListRowPresenter
达到HorizontalGridView和VerticalGridView嵌套的效果。详情可参考下面的文章或项目。
关于Leanback的基础内容可参考:
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仍显示为选中状态”的需求,这里区分使用isFocused
和isSelected
的绘制部分,在焦点离开标题栏后,会将当前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集合- 通过
ItemBridgeAdapter
往HorizontalGridView
设置视图和数据适配
失去焦点后,继续保持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的选中效果”的目的,这种做法是通过外部监听的方式实现的,如果有更好的办法也可以一起讨论。
参考:
焦点始终居中
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)
}
参考:
重新获得焦点后,选中上次的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的
focusable
和focusableInTouchMode
属性为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
可以获取到当前需要获取焦点的view,alignToView
即为前面提到的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
。 - 注释1:
-
还有一种情况是,在其余情况下手动调用
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)。
效果
最后大概的效果就是这样了,下面有一个View用于模拟内容区焦点选中的效果。
最后
本文主要介绍如何使用HorizontalGridView实现一个TV应用的分类标题栏,中间穿插了一些对于焦点处理的解析,由于Android的焦点流程比较复杂,考虑后续写一篇文章专门梳理,方便后续处理问题。