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的设计原则、使用指导、规格参数等信息。

代码实现思路

根据解剖图中,可以明确的知道分为ContainerTab 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.

在具体分析细节前,要先对整体有个更高、更好的把握,站在全局下看细节往往更有领悟,分别看下TabLayoutSlidingTabStrip以及TabView都具有什么方法,帮助我们进一步辨别他们的职责关系

TabLayout :

方法名 作用
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解剖图中,我们一次简要介绍了TabLayoutSlidingTabStrip以及TabView这三个类,并根据它们内部封装的类,猜测了它们所起的作用。

他们各自的职责如下:
1. TabLayout负责总揽整体逻辑,包括参数解析、与AdapterViewPager的关联、透传数据、操作动画等。
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_tabModeTabLayout_tabGravity,它们的改变会带来UI样式与交互的改变,而且mode的默认模式MODE_FIXEDgravity的默认模式是GRAVITY_FILL

获取参数后是使用applyModeAndGravity()方法来设置参数,其内部使用了ViewCompat来协助设置Viewpadding(ViewCompat是自定义View时兼容的好帮手)

ViewCompat.setPaddingRelative(view, 0, 0, 0, 0);

根据mode来确定SlidingTabStripgravity值是Gravity.CENTER_HORIZONTAL还是 GravityCompat.START。并在后续updateTabViews中更新TabView时根据modegravity来更改TabView的布局参数,这样就带来了TabLayoutUI与交互 行为的不同。

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;
     }
 }

TabLayoutSlidingTabStripTabView的测量过程

一个View的测量主要是根据父布局自身的其他因素来确定自身宽高,或者去确定子View的宽高。

TabLayout

而对于TabLayout的宽高,根据UI的设计可以知道它是有个默认高度的(4872,由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的宽高已经由TabLayoutonMeasure()来确定了,自身的onMeasure()主要是在MODE_FIXEDGRAVITY_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

TabViewonMeasure(),结合了tabMaxWidthTabView的最大宽度进行限制。此外也根据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参数中topbottom的设置,让指示器只能位于底边。

// Thick colored underline below the current selection
if (mIndicatorLeft >= 0 && mIndicatorRight > mIndicatorLeft) {
    canvas.drawRect(mIndicatorLeft, getHeight() - mSelectedIndicatorHeight,
            mIndicatorRight, getHeight(), mSelectedIndicatorPaint);
}

小技巧 Pools

TabLayout中的TabTabView都是用了Pools来作为缓存池,提升内存的使用效率。

总结

本片文章中从粗到细、从顶到下分析了TabLayout的UI设计思路与代码设计思路,以及代码实现思路。

想要开发一个优秀的自定义控件,详细的UI设计必不可少,控件内部的设计与架构也需要非常合理,而在实现的各种细节上都是需要我们仔细思考的。

  1. 外部参数的使用,需要由UI需求中提取。
  2. 自身宽高与孩子宽高的测量。
  3. 动画合理的使用。
  4. 数据与UI的更新。

猜你喜欢

转载自blog.csdn.net/biezhihua/article/details/80876209