前言
最近在研讨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的根底内容可参阅:
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仍显现为选中状况”的需求,这儿区分运用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的选中作用”的意图,这种做法是经过外部监听的方式完成的,假如有更好的办法也能够一同评论。
参阅:
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的
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的焦点流程比较复杂,考虑后续写一篇文章专门整理,便利后续处理问题。