Android - TabLayout设计思路与实现思路
UI设计思路
https://material.io/design/components/tabs.html#
解剖图:https://material.io/design/components/tabs.html#anatomy
行为:https://material.io/design/components/tabs.html#behavior
如何放置:https://material.io/design/components/tabs.html#placement
固定的选项卡:https://material.io/design/components/tabs.html#fixed-tabs
滚动的选项卡:https://material.io/design/components/tabs.html#scrollable-tabs
选项卡的状态:https://material.io/design/components/tabs.html#states
主题:https://material.io/design/components/tabs.html#theming
规格与标注:https://material.io/design/components/tabs.html#spec
该篇文档中详细介绍了TabLayout的设计原则、使用指导、规格参数等信息。
代码实现思路
根据解剖图中,可以明确的知道分为Container
和Tab Item
,而该容器为了可以滚动并结合HorizontalScrollView
的使用原则,从而将整个TabLayout
分离设计为三个部分:
1. TabLayout
作为一个HorizontalScrollView
。
2. SlidingTabStrip
作为TabLayout
的管理容器、作为Tab Item
的父容器。
3. Tab Item
承载具体的选项卡内容。
A HorizontalScrollView is a FrameLayout, meaning you should place one child in it containing the entire contents to scroll; this child may itself be a layout manager with a complex hierarchy of objects.
在具体分析细节前,要先对整体有个更高、更好的把握,站在全局下看细节往往更有领悟,分别看下TabLayout
与SlidingTabStrip
以及TabView
都具有什么方法,帮助我们进一步辨别他们的职责与关系。
方法名 | 作用 |
---|---|
TabLayout() | 解析参数,构造View |
add (FromItemView/TabView/View/ViewInternal/Listener) | 添加View、Listener等逻辑 |
animateToTab / ensureScrollAnimator | 动画逻辑 |
applyModeAndGravity | 属性生效逻辑 |
calculateScrollXForTab | 计算逻辑 |
configureTab | 配置逻辑 |
create ColorStateList/LayoutParamsForTabs/TabView or newTab | 创建 View 等逻辑 |
dispatchTab (Reselected/Selected/Unselected) | 事件分发逻辑 |
get DefaultHeight/ScrollPosition/SelectedTabPosition | 数据读取逻辑 |
getTab At/Count/Gravity/MaxWidth/MinWidth/Mode/ScrollRange/TextColors | 数据读取逻辑 |
onMeasure | 测量逻辑 |
populateFromPagerAdapter | 状态重置逻辑 |
remove All/Tab/ /At/ViewAt | 移除 View 等逻辑 |
selectTab | Tab选择逻辑 |
setPagerAdapter | 建立关联的逻辑 |
setScroll (AnimatorListener/Position) | 滚动、监听逻辑 |
setSelectedTab View/Indicator Color/Height | 设置属性逻辑 |
setTab Gravity/Mode/sFromPagerAdapte/TextColors | 设置属性逻辑 |
setupWithViewPager | 建立关联逻辑 |
update AllTabs/TabViewLayoutParams/TabViews | 更新逻辑 |
可以看出TabLayout
中大部分方法都是动词开头的,虽然UI设计原则中TabLayout
仅仅起承载作用,但是在代码实现原则中,几乎所有的 - 初始化、测量、设置、获取、更新、重置、动画等等主要操作都是在该类中完成的。
SlidingTabStrip
:
方法 | 描述 |
---|---|
SlidingTabStrip | 构造 |
animateIndicatorToPosition | 执行指示器动画,将指示器移动到指定位置 |
draw / onLayout | 绘制和布局指示器 |
updateIndicatorPosition | 根据位置和偏移更新指示器位置 |
onMeasure | 根据模式和重心设置TabView的布局参数 |
set/get IndicatorPosition/IndicatorPositionFromTabPosition/SelectedIndicatorColor/SelectedIndicatorHeight | 参数设置和获取 |
可以看出SlidingTabStrip
作为UI容器,容纳TabView
,根据模式与重心设置TabView
的布局参数,根据偏移量与位置绘制指示器。
TabView
:
方法 | 描述 |
---|---|
TabView | |
onMeasure | 根据maxWidth来测量自身,根据icon与text来调整View宽度 |
performClick | |
reset | 重置状态 |
setSelected | 设置状态 |
setTab | |
update | UI数据更新 |
可以看出TabView
作为具体数据的UI容器,用于显示文本和图片,在测量自身宽度的同时,也依据一些参数来调整整个View
的显示状态与内容。
根据UI解剖图中,我们一次简要介绍了TabLayout
和SlidingTabStrip
以及TabView
这三个类,并根据它们内部封装的类,猜测了它们所起的作用。
他们各自的职责如下:
1. TabLayout
负责总揽整体逻辑,包括参数解析、与Adapter
和ViewPager
的关联、透传数据、操作动画等。
2. SlingTabStrip
负责容纳TabView
与绘制指示器。
虽然上面整体逻辑已经连贯,但是缺少了横跨三者数据承载类,在TabLayout
的具体实现中,使用了Tab
类来作为了横跨三者的数据承载类。
具体实现分析
下面已如下角度来分析下TabLayout
的源码:
1. 外部参数的解析与使用
2. TabLayout
的测量、SlidingTabStrip
的测量、TabView
的测量
3. 指示器的移动与绘制
4. 一些其他小技巧
外部参数的解析与使用
属性 | 内部变量 | 描述 |
---|---|---|
TabLayout_tabBackground | mTabBackgroundResId | |
TabLayout_tabContentStart | mContentInsetStart | |
TabLayout_tabGravity | mTabGravity ( GRAVITY_FILL GRAVITY_FILL) | |
TabLayout_tabIndicatorColor | ||
TabLayout_tabIndicatorHeight | ||
TabLayout_tabMaxWidth | TabLayout_tabMaxWidth | |
TabLayout_tabMinWidth | mRequestedTabMinWidth | |
TabLayout_tabMode | mMode (MODE_FIXED MODE_SCROLLABLE) | |
TabLayout_tabPadding | mTabPaddingStart | |
TabLayout_tabPaddingBottom | mTabPaddingBottom | |
TabLayout_tabPaddingEnd | mTabPaddingEnd | |
TabLayout_tabPaddingStart | mTabPaddingStart | |
TabLayout_tabPaddingTop | mTabPaddingTop | |
TabLayout_tabSelectedTextColor | mTabTextColors | |
TabLayout_tabTextAppearance | mTabTextAppearance | |
TabLayout_tabTextColor | mTabTextColors |
这些参数中较为重要的是TabLayout_tabMode
和TabLayout_tabGravity
,它们的改变会带来UI样式与交互的改变,而且mode
的默认模式MODE_FIXED
,gravity
的默认模式是GRAVITY_FILL
。
获取参数后是使用applyModeAndGravity()
方法来设置参数,其内部使用了ViewCompat
来协助设置View
的padding
(ViewCompat
是自定义View
时兼容的好帮手)
ViewCompat.setPaddingRelative(view, 0, 0, 0, 0);
根据mode
来确定SlidingTabStrip
的gravity
值是Gravity.CENTER_HORIZONTAL
还是 GravityCompat.START
。并在后续updateTabViews
中更新TabView
时根据mode
和gravity
来更改TabView
的布局参数,这样就带来了TabLayout
UI与交互 行为的不同。
private void updateTabViewLayoutParams(LinearLayout.LayoutParams lp) {
if (mMode == MODE_FIXED && mTabGravity == GRAVITY_FILL) {
lp.width = 0;
lp.weight = 1;
} else {
lp.width = LinearLayout.LayoutParams.WRAP_CONTENT;
lp.weight = 0;
}
}
TabLayout
、SlidingTabStrip
、TabView
的测量过程
一个View
的测量主要是根据父布局和自身的其他因素来确定自身宽高,或者去确定子View
的宽高。
TabLayout
而对于TabLayout
的宽高,根据UI的设计可以知道它是有个默认高度的(48
或72
,由getDefaultHeight()
获得),onMeasure()
中主要就是使用这个默认高度来作为自身的测量结果。
当然,还顺便确定了当存在多个Tab
时,Tab
的最大宽度。此外,通过re measure
这个过程,让SlidingTabStrip
宽度撑满整个TabLayout
,也使用getChildMeasureSpec()
方法根据TabLayout
和高度和SlidingTabStrip
的高度参数来确定SlidingTabStrip
的高度。
if (remeasure) {
// Re-measure the child with a widthSpec set to be exactly our measure width
int childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec, getPaddingTop()
+ getPaddingBottom(), child.getLayoutParams().height);
int childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(
getMeasuredWidth(), MeasureSpec.EXACTLY);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
SlidingTabStrip
对于SlidingTabStrip
的宽高已经由TabLayout
的onMeasure()
来确定了,自身的onMeasure()
主要是在MODE_FIXED
和GRAVITY_CENTER
的情况下,重新设置子孩子的宽度。
boolean remeasure = false;
if (largestTabWidth * count <= getMeasuredWidth() - gutter * 2) {
// If the tabs fit within our width minus gutters, we will set all tabs to have
// the same width
for (int i = 0; i < count; i++) {
final LinearLayout.LayoutParams lp =
(LayoutParams) getChildAt(i).getLayoutParams();
if (lp.width != largestTabWidth || lp.weight != 0) {
lp.width = largestTabWidth;
lp.weight = 0;
remeasure = true;
}
}
} else {
mTabGravity = GRAVITY_FILL;
updateTabViews(false);
remeasure = true;
}
if (remeasure) {
// Now re-measure after our changes
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
TabView
在TabView
的onMeasure()
,结合了tabMaxWidth
对TabView
的最大宽度进行限制。此外也根据Icon和Text展示状况设置Text的maxLines
,并重新测量确定TabView
的高度。
指示器的移动与绘制
上面我们说过,指示器的更新主要位于SlidingTabStrip
中,其中涉及animateIndicatorToPosition(position, duration)
和setIndicatorPosition(left,right)
以及`draw
,这三个方法。
animateIndicatorToPosition(position, duration)
用于确定指示器目标位置与起始位置,如果两者相差小于1,执行边到边的动画,否则使用边缘动画。
...
if (Math.abs(position - mSelectedPosition) <= 1) {
// If the views are adjacent, we'll animate from edge-to-edge
startLeft = mIndicatorLeft;
startRight = mIndicatorRight;
} else {
// Else, we'll just grow from the nearest edge
final int offset = dpToPx(MOTION_NON_ADJACENT_OFFSET);
if (position < mSelectedPosition) {
startLeft = startRight = targetRight + offset;
} else {
// We're going start-to-end
startLeft = startRight = targetLeft - offset;
}
}
if (startLeft != targetLeft || startRight != targetRight) {
...
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animator) {
final float fraction = animator.getAnimatedFraction();
setIndicatorPosition(
AnimationUtils.lerp(startLeft, targetLeft, fraction),
AnimationUtils.lerp(startRight, targetRight, fraction));
}
});
...
}
在动画的Update
回调中,使用setIndicatorPosition()
方法更新指示器的left``right
值,并调用ViewCompat.postInvalidateOnAnimation(this)
方法引发draw()
方法的调用,来重绘指示器的位置与UI。
其中使用了AnimationUtils.lerp(startLeft, targetLeft, fraction)
方法根据fraction来获取两点之间的一个值。
static float lerp(float startValue, float endValue, float fraction) {
return startValue + (fraction * (endValue - startValue));
}
此外,值得注意的是由于在drawRect
参数中top
和bottom
的设置,让指示器只能位于底边。
// Thick colored underline below the current selection
if (mIndicatorLeft >= 0 && mIndicatorRight > mIndicatorLeft) {
canvas.drawRect(mIndicatorLeft, getHeight() - mSelectedIndicatorHeight,
mIndicatorRight, getHeight(), mSelectedIndicatorPaint);
}
小技巧 Pools
TabLayout
中的Tab
和TabView
都是用了Pools
来作为缓存池,提升内存的使用效率。
总结
本片文章中从粗到细、从顶到下分析了TabLayout
的UI设计思路与代码设计思路,以及代码实现思路。
想要开发一个优秀的自定义控件,详细的UI设计必不可少,控件内部的设计与架构也需要非常合理,而在实现的各种细节上都是需要我们仔细思考的。
- 外部参数的使用,需要由UI需求中提取。
- 自身宽高与孩子宽高的测量。
- 动画合理的使用。
- 数据与UI的更新。