手把手讲解全能型GreenTabLayout研发全攻略

前言

上一篇文章讲了如何 从ViewPager的源码入手,定义自己的ViewPager滑动特效。ViewPager由于有自己的动画接口接口,我们可以直接拿到当前ItemView,以及它的position位置参数,因此可以做出任何我们能够想到的特效。

但是,TabLayout,谷歌貌似就没有那么周到的服务,像是今日头条那样的TabLayout 滑动时的 文字部分颜色变化,还有很多其他app中出现的下方横条indicator长短变化的 特效,还有更多其他特效。如果使用谷歌原生的TabLayout是无法做到的,这个时候就需要我们 自定义TabLayout,但是,说是自定义,前提还是要参照 谷歌的TabLayout源码,然后在其基础上进行再创作。因为,从0开始要制作一个 和谷歌原生同样质量的控件,包括滑动流畅度和边界控制,并且具有良好的扩展性,并没有那么容易,如果存在改造原生TabLayout的可能性,改造的代价要小于 从0创造。所以,优先 阅读源码,探寻这种可能性,如果没有可能性,再去从0创造。

Demo的地址为:https://github.com/18598925736/StudyTabLayout/tree/hank_v1

正文大纲

  • 源码分析
  • 开发思路
  • 开始搬砖
    • 一. 尊重原著
    • 二. 联动滑动
    • 三.特效解耦

正文

源码分析

创建一个androidStudio工程,然后写一个TabLayout+ViewPager效果,之后进入看TabLayout的java源码。源码总长度超过了3000行。但是其中大部分都是注释以及空行,无需害怕。

从我们对TabLayout UI 上的感官印象,可以得知,它应该是一个横向可滚动的 HorizontalScrollView, 它包含两个关键的元素,一个是文字部分的 TabView,一个是 下划线 Indicator .

image-20200320164934056-1585550575830.png

我们的动画也是围绕这两个部分,所以明确这次源码分析的最终目标:

  1. TabView 在TabLayout中是如何 添加 进去的

2)Indicator在TabLayout中是如何 绘制 进去的

TabView明显是一个以TextView为基础,所以是添加到TabLayout 中;Indicator是 一个图形,所以应该是绘制的。

带上目标来探索源码,事半功倍。

开工。先看TabView.

从注释中得知,TabLayout其实可以主动去addTab来添加子view,那就从 addTab方法来入手。

image-20200320165636066-1585550578377.png

注释中有这么一句。

tabLayout.addTab(tabLayout.newTab().setText("Tab 1"));

找到一个关键方法,关于Tab是如何创建的。

  public Tab newTab() {
    Tab tab = createTabFromPool();
    tab.parent = this;
    tab.view = createTabView(tab);
    return tab;
  }

从pool中创建,并且 指定了当前TabLayout为它的parent,并且createTabView(tab)然后 给他的view属性赋值。

后续还有一句:setText("Tab 1") , 在创建出来的Tab上调用setText方法,进入看看:

public Tab setText(@Nullable CharSequence text) {
  if (TextUtils.isEmpty(contentDesc) && !TextUtils.isEmpty(text)) {
    // If no content description has been set, use the text as the content description of the
    // TabView. If the text is null, don't update the content description.
    view.setContentDescription(text);
  }

  this.text = text;
  updateView();
  return this;
}

由此得知,我们在上图中所看到的title0 文本内容,就是传到了这个方法(可以debug验证)。那么 这里的 text 属性用在了什么位置呢?内部类TabView的updateTextAndIcon()方法

image-20200320173530548-1585550586641.png

从这段中可以得出,我们的文本内容,最终传给了 参数 textView : textView.setText(text);

追踪updateTextAndIcon()的调用位置,看看这个textView是什么。

经过4处调用位置的检查,

image-20200320174011680-1585550589010.png

发现它是:

image-20200320174106769-1585550590865.png

这两个属性之一。TabView的两个TextView类型的成员变量。

那么只需要关心这两个TextView是如何添加到TabView中去的。最后发现 customTextView没有被addView,而唯一一处addView(textView)的代码如下:

    private void inflateAndAddDefaultTextView() {
      ViewGroup textViewParent = this;
      if (BadgeUtils.USE_COMPAT_PARENT) {
        textViewParent = createPreApi18BadgeAnchorRoot();
        addView(textViewParent);
      }
      this.textView =
          (TextView)
              LayoutInflater.from(getContext())
                  .inflate(R.layout.design_layout_tab_text, textViewParent, false);
      textViewParent.addView(textView);// 在这里添加的
    }

所以,可以断定,我们之前设置的 title0 这个文本被设置到 了 内部类TabView(一个线性布局)的TextView成员中。

所以 title0 这个文本 的所在对象,从小到大,依次是,

原生TextView -> 内部类TabView -> 内部类Tab -> 原生TabLayout

基于这样的认知,要探究一下是不是存在文字被重新绘制的可能性。

要想对TextView根据需求重新绘制,那么除非可以像ViewPager一样,把View以及当前Position反馈到最外层。Position暂且不管,先看 最终的TextView.

经过一番搏斗,发现并没有这样的接口。。。所以没办法了。TabView 在TabLayout中是如何 添加 进去的 的探索结果表明,谷歌并没有给机会让我们 定制文本部分的内容特效。所以,放弃吧。

然后从头看起,如果以 Indicator在TabLayout中是如何 绘制 进去的 为准来进行探索。

image-20200321201914158-1585550594385.png

这里有两个方法,configureTab 方法,只是对tab对象进行了 保存。看addTabView方法。

image-20200321202058037-1585550596434.png

这里的tab.view 是 TabView对象,它最终添加到了 slidingTabIndicator 中去。而 slidingTabIndicator 它则是一个 内部类,同样是线性布局,方向为横向,它把TabView对象添加进去之后,多个TabView就会横向排列。而底下那一个横向的indicator,则是由 画笔 selectedIndicatorPaint 绘制而成。根据如下:

image-20200321203148966-1585550597890.png
image-20200321203307642-1585550599417.png

得出最终结论:TabLayout的设计布局如下图:

image-20200321204132776-1585550603705.png

最后我探索了一下,indicator 横条,谷歌是不是有提供对外接口来编辑特效。倒是 内部类 SlidingTabIndicator 有一个ValueAnimator indicatorAnimator 在控制 横条滑动的位置动画,使用的是 FastOutSlowInInterpolator 插值器。但是对我们自定义特效没啥用。

最后结论,放弃治疗了。在TabLayout上,谷歌确实不给机会。


开发思路

谷歌工程师设计的控件是针对全世界的开发者和使用者,肯定会考虑周全,支持很多自定义属性,细节细致入微,所以代码看上起会显得非常复杂,难以读懂,而且这么多英文注释,你懂的,反正我看他们的注释一边看一边猜。

然而我们的UI姐姐有自己的要求,所以如果我们可以做自己的UI控件,就可以摆脱谷歌源码的控制,随心所欲地控制TabLayout的视觉效果。

今天本文的最终目的:

是开发一个 绿色版的 GreenTabLayout,去掉谷歌原本一些繁杂的设定,增添开发常用的自定义属性,并且开放 自定义效果的接口,让其他开发者可以在不改动我原本代码的前提下,编辑自己的动画特效。

上面TabLayout UI层级图,展示了谷歌工程师的设计思路,此思路没有问题,我们可以参照它。

但是一步达成最终效果不太可能,我们分阶段来达成效果:

  • 尊重原著

    GreenTabLayout 必须与原TabLayout相差不大,要有文字title标题,要有indicator横条

  • 联动滑动

    自定义TabLayout必须能够和ViewPager一样,产生同样的联动滑动效果,包括横条的滑动和 标题部分的滑动

  • 特效解耦

    自定义TabLayout 把 标题栏的View,indicator横条View,对外提供方便的动画特效定制接口,符合开闭法则.


开始搬砖

确定了基本思路,接下来就要脚踏实地了。在Kotlin语言如此之香的潮流下,我也追求一波时尚,开发将采用Kotlin编码,最大程度节省代码量,使用kotlin"域"的概念隔离程序逻辑,尽可能使源码可读性提高。

一. 尊重原著

要实现与原生TabLayout一样的效果,可以抄谷歌的作业, 原本的UI层级,照搬即可。

下载源码之后,git checkout 4ed2 运行看效果

从外到内有三层:

最外层

它的最外层是一个横向可滚动的 HorizontalScrollView 的子类,同时它提供addTabView方法 供外界添加item

/**
 * 最外层
 */
class HankTabLayout : HorizontalScrollView {
    constructor(ctx: Context) : super(ctx) {
        init()
    }

    constructor(ctx: Context, attributes: AttributeSet) : super(ctx, attributes) {
        init()
    }

    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        init()
    }


    private lateinit var indicatorLayout: IndicatorLayout

    private fun init() {
        indicatorLayout = IndicatorLayout(context)
        val layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, MATCH_PARENT)
        addView(indicatorLayout, layoutParams)

        overScrollMode = View.OVER_SCROLL_NEVER
        isHorizontalScrollBarEnabled = false
    }

    fun addTabView(text: String) {
        indicatorLayout.addTabView(text)
    }

}       

中间层

中间层,是一个横向线性布局,宽度自适应,根据内容而定,提供addTabView方法,用来添加 TabView到自身,同时 绘制 indicator横条, 横条与当前选中的tabView等长并处于最下方.

绘制横条可能有多种方式,这里借鉴了谷歌的思路,使用Drawable.draw(canvas) ,好处就是,可以指定drawable图片,使用图片内容绘制在canvas上。后续会有体现。

/**
 * 中间层 可滚动的
 */
class IndicatorLayout : LinearLayout {
    constructor(ctx: Context) : super(ctx) {
        init()
    }

    private fun init() {
        setWillNotDraw(false) // 如果不这么做,它自身的draw方法就不会调用
    }

    var indicatorLeft = 0
    var indicatorRight = 0

    /**
     * 作为一个viewGroup,有可能它不会执行自身的draw方法,这里有一个值去控制,好像是 setWillNotDraw
     */
    override fun draw(canvas: Canvas?) {
        val indicatorHeight = dpToPx(context, 4f)// 指示器高度
        // 现在貌似应该去画indicator了
        // 要绘制,首先要确定范围,左上右下
        var top = height - indicatorHeight
        var bottom = height
        Log.d("drawTag", "$indicatorLeft    $indicatorRight   $top     $bottom")
        // 现在只考虑在底下的情况
        var selectedIndicator: Drawable = GradientDrawable()
        selectedIndicator.setBounds(indicatorLeft, top, indicatorRight, bottom)
        DrawableCompat.setTint(selectedIndicator, resources.getColor(R.color.c2))
        selectedIndicator.draw(canvas!!)
        super.draw(canvas)
    }

    fun updateIndicatorPosition(tabView: TabView, left: Int, right: Int) {
        indicatorLeft = left
        indicatorRight = right
        postInvalidate()//  刷新自身,调用draw
        // 把其他的都设置成未选中状态
        for (i in 0 until childCount) {
            val current = getChildAt(i) as TabView
            if (current.hashCode() == tabView.hashCode()) {// 如果是当前被点击的这个,那么就不需要管
                current.setSelectedStatus(true) // 选中状态
            } else {// 如果不是
                current.setSelectedStatus(false)// 非选中状态
            }
        }
    }

    /**
     * 但是onDraw一定会执行
     */
    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
    }

    // 对外提供方法,添加TabView
    fun addTabView(text: String) {
        val tabView = TabView(context, this)
        val param = LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
        param.setMargins(dpToPx(context, 10f))

        val textView = TextView(context)
       textView.setBackgroundDrawable(resources.getDrawable(R.drawable.my_tablayout_textview_bg))
        textView.text = text
        textView.gravity = Gravity.CENTER
        textView.setPadding(dpToPx(context, 15f))
        textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f)
        textView.setTextColor(resources.getColor(TabView.unselectedTextColor))
        tabView.setTextView(textView)

        addView(tabView, param)
        postInvalidate()

        if (childCount == 1) {
            val tabView0 = getChildAt(0) as TabView
            tabView0.performClick()
        }
    }
}

最里层

说是最里层,其实这里分为两小层,一个是TabView(继承线性布局),一个是TextView(用来展示 title),

提供点击事件,和状态切换的方法setSelectedStatus(boolean)

/**
 * 最里层TabView
 */
class TabView : LinearLayout {
    private lateinit var titleTextView: TextView
    private var selectedStatue: Boolean = false
    private var parent: IndicatorLayout

    companion object {
        const val selectedTextColor = R.color.cf
        const val unselectedTextColor = R.color.c1
    }

    constructor(ctx: Context, parent: IndicatorLayout) : super(ctx) {
        init()
        this.parent = parent
    }

    fun setTextView(textView: TextView) {
        titleTextView = textView
        removeAllViews()
        val param = LayoutParams(WRAP_CONTENT, MATCH_PARENT)
        addView(titleTextView, param)

        titleTextView.setOnClickListener {
            parent.updateIndicatorPosition(this, left, right)
        }
    }

    private fun init() {
    }

    fun setSelectedStatus(selected: Boolean) {
        selectedStatue = selected
        if (selected) {
            titleTextView.setTextColor(resources.getColor(R.color.cf))
        } else {
            titleTextView.setTextColor(resources.getColor(R.color.c1))
        }
    }

}

初阶效果

做完这些,基本就呈现出下图的状态:

尊重原著.gif

上一半是原生TabLayout,用来对比,下一半是刚刚完成的效果。但是和上面的原生TabLayout比起来. 第一步完成。从开始写代码,到完成这个效果,一直参考的 谷歌的代码。

二. 联动滑动

下载源码之后,git checkout a132 运行看效果

布局层级已经完成,现在需要联动Viewpager的滑动参数,让GreenTabLayout 跟随ViewPager一起滑动。

注册监听

要实现联动,首先要知道,谷歌源码中,TabLayout是如何与ViewPager发生联动的,它们的联结点在哪里,请看代码:

tabLayout.setupWithViewPager(viewpager)

平时我们用 原生TabLayout,两者唯一发生交集的地方就是这里,进入看源码:

image-20200330142618611.png

显然他们的交集可能是某个回调监听,顺着这个线索,最终确定,上面的 pageChangeListener就是 联动滑动的交界点,这里把监听器传给ViewPager,ViewPager则可以把自己的滑动参数传递给TabLayout,TabLayout则做出相应的行为。

监听器的源码为:

private TabLayoutOnPageChangeListener pageChangeListener;

public static class TabLayoutOnPageChangeListener implements ViewPager.OnPageChangeListener {
     @Override
    public void onPageScrolled(final int position, final float positionOffset, final int positionOffsetPixels) {
    ....
    }
     @Override
    public void onPageSelected(final int position) {
    ...
    }
    @Override
    public void onPageScrollStateChanged(final int state) {
    ...
    }
}

了解到这里,我们可以给 GreenTabLayuot 直接加上 这个接口实现

class GreenTabLayout : HorizontalScrollView, ViewPager.OnPageChangeListener {
    @Override
    public void onPageScrolled(final int position, final float positionOffset, final int positionOffsetPixels) {
    ....
    }
     @Override
    public void onPageSelected(final int position) {
    ...
    }
    @Override
    public void onPageScrollStateChanged(final int state) {
    ...
    }
}

然后提供一个 相同的 setupWithViewPager(viewpager) 方法, 在内部,给ViewPager绑定监听,同时根据 viewPager的adapter内的 page数目,决定TabView的数目和每一个的标题。

fun setupWithViewPager(viewPager: ViewPager) {
    this.mViewPager = viewPager
    viewPager.addOnPageChangeListener(this)// 注册监听
    val adapter = viewPager.adapter ?: return
    val count = adapter!!.count // 栏目数量
    for (i in 0 until count) {
        val pageTitle = adapter.getPageTitle(i)
        addTabView(pageTitle.toString())// 根据adapter的item数目,决定TabView的数目和每一个标题
    }
}

参数分析

注册监听之后,Viewpager可以把自己的滑动参数的变化告知TabLayout,但是TabLayout如何去处理这个参数变化,还需要从参数的规律上去着手。重点分析 监听的 onPageScrolled 方法, 重点中的重点,则是前两个参数:position(当前page的index) 和 positionOffset(当前page的偏移百分比,小数表示的)

为了研究规律,我们用上面刚刚完成的代码把GreenTabLayout和ViewPager连结上,然后打印日志onPageScrolled

image-20200330145008704.png

基本得出一个结论:

position为0的,为当前选中的这个page,当慢慢从当前page划走时,它的positionOffset会从0慢慢变成1

并且,如果手指分方向滑动试验,可知:

当手指向左,positionOffset会递增,从0到极限值1,到达极限之后归0,同时 position递加1

反之,手指向右,positionOffset会递减,从1 递减到0,从递减的那一刻开始,position递减1

基于上面的规律,我们可以调试出 indicator横条动画的代码:

...
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
        scrollTabLayout(position, positionOffset)
}

private fun scrollTabLayout(position: Int, positionOffset: Float) {
    // 如果手指向左划,indicator横条应该从当前位置,滑动到 下一个子view的位置上去,position应该+1
    // 如果手指向右滑动,position立即减1,indicator横条应该从当前位置向左滑动
        val currentTabView = indicatorLayout.getChildAt(position) as GreenTabView
        val currentLeft = currentTabView.left
        val currentRight = currentTabView.right

        val nextTabView = indicatorLayout.getChildAt(position + 1)
        if (nextTabView != null) {
            val nextLeft = nextTabView.left
            val nextRight = nextTabView.right
            
            Log.d("scrollTabLayout","当前index:${position}  left:${currentLeft} right:${currentRight}  " +" 目标index:${position + 1}  left:${nextLeft} right:${nextRight} positionOffset:${positionOffset}" )

            val leftDiff = nextLeft - currentLeft
            val rightDiff = nextRight - currentRight

            indicatorLayout.updateIndicatorPosition(
                currentLeft + (leftDiff * positionOffset).toInt(),
                currentRight + (rightDiff * positionOffset).toInt()
            )
        }
    }

为什么这样就能正确区分滑动的方向?把日志打印出来一看就明白:

这是手指向左划一格

image-20200330151551105.png
  • 观察positionOffset的变化,从0 变为1,然后归零。

  • 而看横条的当前 left = 26,right=170, 以及 目标left=222,right=380 ,随着positionOffset的递增,横条会慢慢向右。

  • 而到达最后,positionOffset归零了,当前left 也变成了 目标的left = 222,right=380.

横条向右平移完成。

手指向右划一格,日志如下:

image-20200330152206881.png
  • position先直接减1,positionOffset则从1慢慢变成0.

  • 横条从 left=26 right=170 的起始位置,向 目标 left=222,righ=380 移动,但是由于positionOffset是递减的,所以,横条的移动方向反而是 向左。一直到positionOffset为0,到达 left=26 right=170.

横条向左平移也完成。

整体平移

横条虽然可以跟着viewPager的滑动而滑动,但是如果TabView已经排满了当前屏幕,横条到达了当前屏幕最右侧,viewPager上右侧还有内容还可以让手指向左滑动。此时,就必须滚动最外层布局,来让TabView显示出来。

通过观察原生TabLayout,它会尽量让 当前选中的tabView位于 控件的横向居中的位置。而随着 ViewPager的当前page的变化,最外层GreenTabLayout也要发生横向滚动。

所以我选择在 回调函数onPageSelected中执行滚动:

    class GreenTabLayout: HorizontalScrollView, ViewPager.OnPageChangeListener {
        ...
        override fun onPageSelected(position: Int) {
            val tabView = indicatorLayout.getChildAt(position) as GreenTabView
            if (tabView != null) {
                indicatorLayout.updateIndicatorPositionByAnimator(tabView, tabView.left, tabView.right)
            }
        }
    }

执行滚动的思路为:

  • 确定 当前选中的tabView的 矩形范围 tabView.getHitRect(tabViewBounds)
  • 确定 确定最外层GreenTbaLayout的矩形范围 getHitRect(parentBounds)
  • 计算两个矩形的x轴的中点,然后计算出两个中点的差值,差值就是需要滚动的距离
  • 使用属性动画进行平滑滚动
    /**
     * 用动画平滑更新indicator的位置
     * @param tabView 当前这个子view
     */
    fun updateIndicatorPositionByAnimator(
        tabView: GreenTabView,
        targetLeft: Int,
        targetRight: Int) {
        ...
        // 处理最外层布局( HankTabLayout )的滑动
        parent.run {
            tabView.getHitRect(tabViewBounds) //确定 当前选中的tabView的 矩形范围
            getHitRect(parentBounds) // 确定最外层GreenTbaLayout的矩形范围
            val scrolledX = scrollX // 已经滑动过的距离
            val tabViewRealLeft = tabViewBounds.left - scrolledX  // 真正的left, 要算上scrolledX
            val tabViewRealRight = tabViewBounds.right - scrolledX // 真正的right, 要算上scrolledX

            val tabViewCenterX = (tabViewRealLeft + tabViewRealRight) / 2
            val parentCenterX = (parentBounds.left + parentBounds.right) / 2
            val needToScrollX = -parentCenterX + tabViewCenterX //  差值就是需要滚动的距离

            startScrollAnimator(this, scrolledX, scrolledX + needToScrollX)
        }
    }

    /**
     * 用动画效果平滑滚动过去
     */
    private fun startScrollAnimator(tabLayout: GreenTabLayout, from: Int, to: Int) {
        if (scrollAnimator != null && scrollAnimator.isRunning) scrollAnimator.cancel()
        scrollAnimator.duration = 200
        scrollAnimator.interpolator = FastOutSlowInInterpolator()
        scrollAnimator.addUpdateListener {
            val progress = it.animatedValue as Float
            val diff = to - from
            val currentDif = (diff * progress).toInt()
            tabLayout.scrollTo(from + currentDif, 0)
        }
        scrollAnimator.start()
    }

二阶效果

完成到这里,就能达成下图中的效果:

联动滑动.gif

上半部分为原生TabLayout效果,下把那部分为 刚刚完成的效果,几乎没有差别了。

当然,我们这是把TabLayout本地化,完成这些,仅仅用了kotlin 300多行代码。可见Kotlin在省代码方面,确实是一绝,比java简洁很多。

三.特效解耦

这一阶段主要做2件事:

  • 支持开发中的常用的UI设计要求,这个可以做成自定义属性
  • 开放无耦合接口,使得开发者可以使用该接口编辑 indicator横条 / TabView文本 的滑动特效,而不用改动GreenTabLayout的内部实现

第一点,都是一些基础性的改造,就不赘述了,关于自定义属性的添加和使用,都是死框架,没什么好说的,下面,总结一下 我所支持的所有属性:

盘点自定义属性

TabView标题栏部分:

属性名 意义 取值类型
tabViewTextSize 标题字体大小 dimension|reference
tabViewTextSizeSelected 选中后的标题字体大小 dimension|reference
tabViewTextColor 标题字体颜色 color|reference
tabViewTextColorSelected 选中后的标题字体颜色 color|reference
tabViewBackgroundColor 标题区域背景色 color|reference
tabViewTextPaddingLeft 标题区内边距左 dimension|reference
tabViewTextPaddingRight 标题区内边距右 dimension|reference
tabViewTextPaddingTop 标题区内边距上 dimension|reference
tabViewTextPaddingBottom 标题区内边距下 dimension|reference
tabViewDynamicSizeWhenScrolling 是否允许滚动时的字体大小渐变 boolean

Indicator横条部分:

属性名 意义 取值类型
indicatorColor 横条颜色 color|reference
indicatorLocationGravity 横条位置 枚举:TOP 放在顶部 / BOTTOM 放在底部
indicatorMargin 横条间距,当indicatorLocationGravity为TOP时表示距离顶端的距离,BOTTOM时表示距离底部的距离 dimension|reference
indicatorWidthMode 横条宽度模式 枚举:RELATIVE_TAB_VIEW 取TabView宽度的倍数 / EXACT 取精确值
indicatorWidthPercentages 横条宽度百分比,当indicatorWidthMode 为 RELATIVE_TAB_VIEW时才会生效,表示横条宽度占TabView宽度的百分比 float(大于0)
indicatorExactWidth 横条宽度精确值,当indicatorWidthMode 为 EXACT时才会生效,表示横条的精确宽度 dimension|reference
indicatorHeight 横条高度 dimension|reference
indicatorAlignMode 横条对其模式 枚举: LEFT / CENTER / RIGHT
indicatorDrawable 横条drawable,可以指定横条的内容为图片 reference
indicatorElastic 是否开启滚动时横条的弹性效果 boolean
indicatorElasticBaseMultiple 当indicatorElastic开启时生效,表示弹性倍数,数字越大,弹性越明显 float

其中大部分属性的处理都是基于非常基础的View控件知识和简单的数学计算,只有几点需要讲解说明:

  • tabViewDynamicSizeWhenScrolling 是否允许滚动时的字体大小渐变
  • indicatorElastic 是否开启滚动时横条的弹性效果

这两点,都与 viewPager滑动时的参数变化有关系,所以处理这两个特性,需要结合参数变化规律

较复杂属性处理

  • tabViewDynamicSizeWhenScrolling viewPager滚动时,标题的字体大小会发生渐变:
标题栏字体大小渐变.gif
class GreenTabLayout : HorizontalScrollView, ViewPager.OnPageChangeListener {
    ...
    override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int){
        Log.d("positionOffset", "$positionOffset")
        scrollTabLayout(position, positionOffset)
    }
    
    fun scrollTabLayout(position: Int, positionOffset: Float) {
        val currentTabView = indicatorLayout.getChildAt(position) as GreenTabView
        val currentLeft = currentTabView.left
        val currentRight = currentTabView.right

        val nextTabView = indicatorLayout.getChildAt(position + 1) // 目标TabView
        if (nextTabView != null) {
            val nextGreenTabView = nextTabView as GreenTabView
            dealAttrTabViewDynamicSizeWhenScrolling(// 关键代码 
                positionOffset,
                currentTabView,
                nextGreenTabView
            )
            ...
        }
    }
    
    /**
     *  处理属性 tabViewDynamicSizeWhenScrolling
     */
    private fun dealAttrTabViewDynamicSizeWhenScrolling(
        positionOffset: Float,
        currentTabView: GreenTabView,
        nextTabView: GreenTabView
    ) {
        if (tabViewAttrs.tabViewDynamicSizeWhenScrolling) {
            if (positionOffset != 0f) {
                // 在这里,让当前字体变小,next的字体变大
                val diffSize =
                    tabViewAttrs.tabViewTextSizeSelected - tabViewAttrs.tabViewTextSize
                when (mScrollState) {
                    ViewPager.SCROLL_STATE_DRAGGING -> {
                        currentTabViewTextSizeRealtime =
                            tabViewAttrs.tabViewTextSizeSelected - diffSize * positionOffset
                        currentTabView.titleTextView.setTextSize(
                            TypedValue.COMPLEX_UNIT_PX,
                            currentTabViewTextSizeRealtime
                        )

                        nextTabViewTextSizeRealtime =
                            tabViewAttrs.tabViewTextSize + diffSize * positionOffset
                        nextTabView.titleTextView.setTextSize(
                            TypedValue.COMPLEX_UNIT_PX,
                            nextTabViewTextSizeRealtime
                        )

                        settingFlag = false
                    }
                    ViewPager.SCROLL_STATE_SETTLING -> {
                        // OK,定位到问题,在 mScrollState 为setting状态时,positionOffset的变化没有 dragging时那么细致
                        // 只要不处理 SETTING下的字体大小变化,也可以达成效果
                        if (!settingFlag)
                           indicatorLayout.resetTabViewsStatueByAnimator(indicatorLayout[mCurrentPosition] as GreenTabView)
                        settingFlag = true
                    }
                }
            }
        }
    }
}

处理思路依旧是围绕 onPageScrolled 的参数变化,核心方法为:dealAttrTabViewDynamicSizeWhenScrolling(..), 让当前tabView的文本渐渐变小,而nextTabView的文本逐渐变大。这里如果有疑问可以参照上文的 参数分析小章节。

但是,有一个坑,就是当拖拽停止的时候,viewpager会有一个自动的回弹动作,如果这里没处理好,就会出现,字体大小突变的情况,和我要的平滑动画过渡不相符,所以,这里我做了一个特殊处理,当拖拽停止,也就是手指松开的时候,抓准 ViewPager的 SCROLL_STATE_SETTLING 状态刚刚进入的时机,使用属性动画平滑改变字体,核心代码就是上文代码块中的:indicatorLayout.resetTabViewsStatueByAnimator(indicatorLayout[mCurrentPosition] as GreenTabView) 这句话可以让 tabView的文本字体平滑地从 当前值(不确定,因为dragging状态是用户人为控制),变为 目标值(这是确定值,要么是 正常状态下的字体大小,要么是选中状态下的字体大小),由此完美解决字体平滑变化的问题。

  • indicatorElastic 滚动时,横条会拉伸和回缩,也是跟随 onPageScrolled的参数变化而变化

    关键代码在 SlidingIndicatorLayout.kt 中的 draw方法:

    override fun draw(canvas:Canvas?){
    ...
    
            val baseMultiple = parent.indicatorAttrs.indicatorElasticBaseMultiple // 基础倍数,决定拉伸
            val indicatorCriticalValue = 1 + baseMultiple
            val ratio =
                if (parent.indicatorAttrs.indicatorElastic) {
                    when {
                        positionOffset >= 0 && positionOffset < 0.5 -> {
                            1 + positionOffset * baseMultiple // 拉伸长度
                        }
                        else -> {// 如果到了下半段,当offset越过中值之后ratio的值
                            indicatorCriticalValue - positionOffset * baseMultiple
                        }
                    }
                } else 1f
            // 可以开始绘制
            selectedIndicator.run {
                setBounds(
                    ((centerX - indicatorWidth * ratio / 2).toInt()),
                    top,
                    ((centerX + indicatorWidth * ratio / 2).toInt()),
                    bottom
                )// 规定它的边界
                draw(canvas!!)// 然后绘制到画布上
            }
    ...
    
    }
    

    这一段提出来特别说明,因为它代表了一种解题思路,我需要的效果是:

    viewPager滚动1格,我需要它在滚动一半的时候,横条拉伸到最长,从一半滚完的时候,横条回缩到应该的宽度

    但是,viewPager滚1格,positionOffset的变化是从0 到1(手指向右),或者是从1到0(手指向左),我需要把positionOffset在到达0.5的时候当作一个临界时间点,计算出 这个临界时间点上,indicator横条应该的长度。

    关键在于:在临界点0.5上,前半段的0->0.5的最终值,必须等于 后半段 0.5->1 的 开始值

    由于我是按照倍数来拉伸,所以,原始倍率是1。我还想用参数控制拉伸的程度,所以设计一个变量 baseMultiple(拉伸倍数,数值越大,拉伸越明显)

    列出公式

    • 前半段的ratio最终值 = 1(原始倍率)+ 0.5 * baseMultiple

    • 后半段的ratio值 = indicatorCriticalValue临界值) - 0.5 * baseMultiple

    • 前半段的ratio最终值 = 后半段的ratio值

    计算得出,indicatorCriticalValue(临界值) = 1 (原始倍率)+ baseMultiple

    于是就写出了上面的代码。

三阶效果

说了这么多,不如亲眼看一眼效果更佳实在,以上各项属性,下面的动态图基本都有体现, 具体效果可以按需定制,基本可以满足UI姐姐的各种骚操作要求,如果还不行,可以拿我的代码自行修改,我的代码注释应该比谷歌大佬要亲民很多。,欢迎fork,star...

自定义属性效果.gif

开放无耦合特效接口

为什么生出这种想法?这个是源自:ViewPager的无耦合动画接口。

Viewpager.setPageTransformer(true, MyPageTransformer(this, adapter.count))

viewPager的setPageTransformer,可以传入一个 PageTransformer(接口)的实现类,从而控制ViewPager滑动时的动画,开发者可以自由定制效果,而不用关心ViewPager的内部实现。符合程序设计的开闭法则,让控件开发者和 控件使用者都省心省力。

GreenTabView接口

我在Demo中,提供了 GreenTabLayout的setupWithViewPager泛型方法,使用者可以传入 GreenTextView的子类.两段关键代码如下:

open class GreenTextView : AppCompatTextView {
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
    constructor(context: Context) : super(context)

    /**
     * 可重写,接收来自viewpager的position参数,做出随心所欲的textView特效
     *
     * @param isSelected 是不是当前选中的TabView
     * @param positionOffset 偏移值   0<= positionOffset <=1
     */
    open fun handlerPositionOffset(positionOffset: Float, isSelected: Boolean) {}

    /**
     * 如果发生了滑动过程中特效残留的情况,可以重写此方法用来清除特效
     */
    open fun removeShader(oldPosition: Int, newOldPosition: Int) {}

    /**
     *  添加特效
     */
    open fun addShader(oldPosition: Int, newOldPosition: Int) {}

    /**
     * 通知,viewPager 即将进入setting状态
     * @param positionOffset 当前offset
     * @param isSelected 是否是被选择的TabView
     * @param direction 滑动方向,大于0 表示向右回弹,小于0 表示向左回弹
     */
    open fun onSetting(positionOffset: Float, isSelected: Boolean, direction: Int) {}
}
class GreenTabLayout : HorizontalScrollView, ViewPager.OnPageChangeListener{
    ...
    fun <T : GreenTextView> setupWithViewPager(viewPager: ViewPager, t: T?) {
        ...
    }
}

你可以按照下面的模板使用这个接口:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val adapter = MyPagerAdapter(supportFragmentManager)
        hankViewpager.adapter = adapter
        hankViewpager.offscreenPageLimit = 3
        hankViewpager.setPageTransformer(true, MyPageTransformer(this, adapter.count))
        
        //*******************关键代码*****************
        hankTabLayout.setupWithViewPager(hankViewpager, GradientTextView(this))
        //*******************************************
        hankTabLayout2.setupWithViewPager(hankViewpager)
    }
    ....
 }

GradientTextView是GreenTabView的一个子类,它的源码是:

/**
 * 提供颜色渐变的TextView
 */
class GradientTextView : GreenTextView {
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
    constructor(context: Context) : super(context)

    private var mLinearGradient: LinearGradient? = null
    private var mGradientMatrix: Matrix? = null
    private lateinit var mPaint: Paint
    private var mViewWidth = 0f
    private var mTranslate = 0f
    private val mAnimating = true

    private val fontColor = Color.BLACK
    private val shaderColor = Color.YELLOW

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        if (mViewWidth == 0f) {
            mViewWidth = measuredWidth.toFloat()
            if (mViewWidth > 0) {
                mPaint = paint
                mLinearGradient = LinearGradient(
                    0f,// 初始状态,是隐藏在x轴负向,一个view宽的距离
                    0f,
                    mViewWidth,
                    0f,
                    intArrayOf(fontColor, shaderColor, shaderColor, fontColor),
                    floatArrayOf(0f, 0.1f, 0.9f, 1f),
                    Shader.TileMode.CLAMP
                )
                mPaint.shader = mLinearGradient
                mGradientMatrix = Matrix()
            }
        }
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        if (mAnimating && mGradientMatrix != null) {
            mGradientMatrix!!.setTranslate(mTranslate, 0f)
            mLinearGradient!!.setLocalMatrix(mGradientMatrix)
        }
    }


    private inline fun dealSwap(positionOffset: Float, isSelected: Boolean) {
        // 如果不是初始值,那说明已经赋值过,那么用 参数positionOffset 和 它对比,来得出滑动的方向
        Log.d(
            "setMatrixTranslate",
            " positionOffset:$positionOffset  isSelected:$isSelected   "
        )
        // 来,先判定滑动的方向,因为方向会决定从哪个角度
        mTranslate = if (mPositionOffset < positionOffset) {// 手指向左
            if (isSelected) {// 如果当前是选中状态,那么 offset会从0到1 会如何变化?
                mViewWidth * positionOffset // OK,没问题。
            } else {
                -mViewWidth * (1 - positionOffset)
            }
        } else {// 手指向右
            if (isSelected) {// 如果当前是选中状态,那么 offset会从0到1 会如何变化?
                -mViewWidth * (1 - positionOffset) // OK,没问题。
            } else {
                mViewWidth * positionOffset
            }
        }
        postInvalidate()
    }

    /**
     * 由外部参数控制shader的位置
     * @param positionOffset 只会从0到1变化
     * @param isSelected 是否选中
     */
    override fun handlerPositionOffset(positionOffset: Float, isSelected: Boolean) {

        if (mPositionOffset == -1f) {// 如果你是初始值
            mPositionOffset = positionOffset // 那就先赋值
        } else {
            dealSwap(positionOffset, isSelected)
        }
    }

    override fun removeShader(direction: Int) {
        Log.d("removeShaderTag", "要根据它当前的mTranslate位置决定从哪个方向消失  mTranslate:$mTranslate")
        mTranslate = mViewWidth
        postInvalidate()
    }

    override fun addShader(direction: Int) {
        // 属性动画实现shader平滑移动
        val from =
            if (direction < 0) {
                -mViewWidth
            } else {
                mViewWidth
            }
        startAnimator(from, 0f)
    }

    override fun onSetting(positionOffset: Float, isSelected: Boolean, direction: Int) {
        Log.d(
            "onSettingTag",
            "isSelected:$isSelected   positionOffset:$positionOffset direction:$direction"
        )
        mPositionOffset = -1f

        val targetTranslate = if (isSelected) {
            0f
        } else {
            if (direction > 0f) {// 向右回弹
                mViewWidth
            } else {
                Log.d("onSettingTag2", "难道这里还要分情况么?mTranslate:$mTranslate  mViewWidth:$mViewWidth")
                if (mTranslate == mViewWidth || mTranslate == -mViewWidth) {
                    mTranslate // 如果已经到达了最右边,那就保持你这个样子就行了, 可是你是怎么到最右边的?
                } else
                    -mViewWidth
            }

        }
        val thisTranslate = mTranslate
        startAnimator(thisTranslate, targetTranslate)
    }

    private fun startAnimator(from: Float, targetTranslate: Float) {
        if (animator != null) animator?.cancel()
        // 属性动画实现shader平滑移动

        animator = ValueAnimator.ofFloat(from, targetTranslate)
        animator?.run {
            duration = animatorDuration
            addUpdateListener {
                mTranslate = it.animatedValue as Float
                postInvalidate()
            }
            start()
        }
    }

    private var mPositionOffset: Float = -1f

    private val animatorDuration = 200L
    private var animator: ValueAnimator? = null
}

运行效果:请注意看下图的上面半部分,下半部分只是没有加特效的对比。理论上,利用现在的参数,可以定制出想要的任何效果,下图只是我的一些效果测试。

文字渐变最终效果.gif

注意,使用了Shader特效之后,原本的 titleTextView字体颜色可能会失效,这是由shader机制决定的,但是依然可以用shader控制字体的颜色,运行Demo,阅读源码,很快就能得出答案。

既然这是一个开放接口,那么所能达成的效果,就不仅仅是上图中所示, 利用 handlerPositionOffset的几个参数,发挥想象力(或者UI姐姐发挥想象力),想要做出任何你希望的效果,只是时间问题。

Indicator接口

同样,针对Indicator横条的绘制,你也可以完全自定义,使用自己的实现方式,强制接管 原代码中的绘制逻辑

接口在 GreenTabLayout.kt 中,入口方法为:

    /**
     * 注意,使用了此方法,传入了非空的CustomDrawHandler实现类对象,
     * 原本indicator的所有属性都会失效,因为indicator的绘制工作,全部由CustomDrawHandler接管
     */
    fun setIndicatorDrawHandler(customDrawHandler: SlidingIndicatorLayout.CustomDrawHandler?) {
        indicatorLayout.customDrawHandler = customDrawHandler
    }

接口为:SlidingIndicatorLayout.kt类中的 CustomDrawHandler ,提供一个draw方法,方法内提供2个关键参数,第一个是 SlidingIndicatorLayout 对象,第二个是,画布canvas对象, 前者可以让我们拿到任何想要拿的参数,后者,让我们可以动用想象力,把想象的特效,绘制在画布上。

    interface CustomDrawHandler {
        fun draw(indicatorLayout: SlidingIndicatorLayout, canvas: Canvas?)
    }

    var customDrawHandler: CustomDrawHandler? = null

使用方法:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val adapter = MyPagerAdapter(supportFragmentManager)
        hankViewpager.adapter = adapter
        hankViewpager.offscreenPageLimit = 3
        hankViewpager.setPageTransformer(true, MyPageTransformer(this, adapter.count))
        hankTabLayout.setupWithViewPager(hankViewpager, GradientTextView(this))

        hankTabLayout.setIndicatorDrawHandler(CustomDrawHandlerImpl(this))

        hankTabLayout2.setupWithViewPager(hankViewpager)
    }

    
    class CustomDrawHandlerImpl : SlidingIndicatorLayout.CustomDrawHandler {
        val context: Context

        constructor(context_: Context) {
            context = context_
        }

        override fun draw(indicatorLayout: SlidingIndicatorLayout, canvas: Canvas?) {
            val paint = Paint()
            paint.color = context.resources.getColor(R.color.c1)
            val fraction =
                (indicatorLayout.parent.mCurrentPosition.toFloat() + 1) / indicatorLayout.childCount.toFloat()// 分数
            val left = indicatorLayout.parent.scrollX
            val right =
                (indicatorLayout.parent.scrollX + indicatorLayout.parent.measuredWidth * fraction).toInt()
            val rect = Rect(left, 0, right, dpToPx(context, 10f))
            canvas?.drawRect(rect, paint)
        }
    }

    ...
}

运行效果请看下图上半部分(下面一半仍然是用来对比),我实现了一个用indicator来记录当前滑动的进度的特效,只作为简单效果的展示,表示它可以实现任何你能想到的indicator动效,上面的代码,我只绘制了矩形,其实还可以绘制任何其他图形,任你想像。

indicator特效解耦.gif

结语

Demo的地址为:https://github.com/18598925736/StudyTabLayout/tree/hank_v1

请下载运行最新版本代码看效果。

至此,所有内容放送完毕,全文技术从立意到实践编码,再到文章出炉,历时半月,终于功成。由于只是业余时间研究所得,细节上还没有打磨得十分圆满。

写出一个类似这样的控件并不难,技术上基本没有什么纵深,但是涉及面很广,而且一旦开头的思路错了,后续隐患无穷。我的思维是,向源码学习,将基础架构学到手,具体的实操,我们再自己把握。谷歌的注释虽然有些生涩难懂,但是大体思维,只要认真研读源码,总是能得到启发的。

希望能给其他开发者带来新的思路和借鉴。

欢迎 看到的各位大佬留言交流,批评指正。谢过!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容