欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Android - TabLayout设计思路与实现思路

程序员文章站 2022-06-17 09:39:26
...

UI设计思路

https://material.io/design/components/tabs.html#

解剖图:https://material.io/design/components/tabs.html#anatomy

Android - TabLayout设计思路与实现思路

行为:https://material.io/design/components/tabs.html#behavior
Android - TabLayout设计思路与实现思路

Android - TabLayout设计思路与实现思路

如何放置:https://material.io/design/components/tabs.html#placement

Android - TabLayout设计思路与实现思路

固定的选项卡:https://material.io/design/components/tabs.html#fixed-tabs

Android - TabLayout设计思路与实现思路

滚动的选项卡:https://material.io/design/components/tabs.html#scrollable-tabs

Android - TabLayout设计思路与实现思路

选项卡的状态:https://material.io/design/components/tabs.html#states

Android - TabLayout设计思路与实现思路

主题: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类来作为了横跨三者的数据承载类。

Android - TabLayout设计思路与实现思路

具体实现分析

下面已如下角度来分析下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的更新。