TabLayout改造,支持对TabView扩展

1,755 阅读5分钟

在实际开发中,我们经常需要使用viewpage搭配SlideTabLayout联动滚动使用。以前google官方并没有SlideTabLayout相关Widget供我们直接使用,因此需要我们自定义实现。

后来google在material design中推出了TabLayout,但自定义能力不足,动画样式有限,往往不能满足我们的要求。

不过在github上也许多关于SlideTabLayout,大家可以去搜一下,有不是有很多start的。但类似ios西瓜视频主页那种,随着页面滑动,被选中的tab逐渐放大的效果确很少,或者使用流畅性不够。

![Demo](i.postimg.cc/0y0Ms5rJ/de…)

改造TabLayout

下面我但大家在官方TabLayout的基础上,去增加一些事件接口供我们扩展使用。

首先把TabLayout的源码拷贝出来,总共没几个文件很简单。

上面三个类是三种样式的滑动指示器,主要作用是根据滑动进度或者tab位置确定指示器的宽度和位置。

TabItem没什么,就是一个Tab声明,虽然是一个View但是最终不会被添加到TabLayout布局中,主要用于xml中声明在TabLayout中有几个TabItem,可以指定icon,text,customView。最终会被转换为TabView添加到TabLayout布局中。

TabLayout就是今天的主角,继承自HorizontalScrollView,拥有了横向滚动能力,直接子View是SlidingTabIndicator,它继承自LinearLayout,因为TabView都是线性横向排列,可以直接复用测量和布局。

TabLayout整体视图结构还是比较简单的,一个HorizontalScrollView下包裹一个LinearLayout,LinearLayout里面是一个个TabView。

TabLayoutMadiator主要是为了兼容ViewPager2,这个就不多叙述。

本期我们的扩展目标主要是在滑动的过程中对TabView进行处理。

任务分解

目前效果是TabView中的文字随着滚动放大或缩小,放大缩小会涉及到一个问题,就是会改变View的显示大小,如果TabView之间的间隔太小,还可能造成TabView重叠显示。为了更好的用户体验,在放大缩小的同时我会取平移每一个TabView,保证每一个TabView文字之间的间距始终是相等的。

  • 目标1:动态缩放文字
  • 目前2:动态水平移动TabView的位置

为了保持TabView之间的间距不变,又会带来一个新的问题,会造成所有TabView的z总宽度是不等于LinearLayout的宽度,因为总是会有一个或者两个TabView是被缩放的,如果我们设置的是一个放大效果,那就会造成Tab总宽度大于LinearLayout的宽度,最后一个TabView会被截断。为了解决这个问题,我们需要重写LinearLayout的逻辑,预留出额外的空间供TabView平移。预留出的空间是最宽的tabView乘以最大放大系数。

  • 目标3:重新测量LinearLayout的宽度,预留空间

为了预留空间还会带来一个新的问题,就是滚动到LinearLayout的最右边,可能会多出空白区域,因为我们预留的是TabView的最大空间,如果当前选中的TabView不是宽度最大的那个,就会出现空白区域。因此我们需要动态改变最大滚动距离。

  • 目标4:动态改变最大滚动距离

事件接口定义

定义tabLayout一些关键节点的事件接口,让外部控制器有机会去改变Tablayout的默认行为,从而达到定制的效果。

public interface ITabEventListener {

    // 匹配模式
    boolean matchMode(@TabLayout.Mode int mode);

    // tabView的宽高确认
    default void onTabViewLayout(@NonNull TabLayout.TabView tabView) {

    }

    default void onReMeasureChildren(@NonNull LinearLayout slidingTabIndicator, Consumer<Integer> action) {
    }

    default void onRelayoutChildren(@NonNull LinearLayout slidingTabIndicator) {

    }

    default void onUpdateProgress(@NonNull LinearLayout slidingTabIndicator, @NonNull TabLayout.TabView currentTab, @Nullable TabLayout.TabView nextTab, float progress) {

    }

    default int getScaleTabContentWidth(@NonNull TabLayout.TabView tabView, int originSize) {
        return originSize;
    }

    default int getScaleTabContentHeight(@NonNull TabLayout.TabView tabView, int originSize) {
        return originSize;
    }

    default int getScaleTabWidth(@NonNull TabLayout.TabView tabView, int originSize) {
        return originSize;
    }

    default int getScaleTabHeight(@NonNull TabLayout.TabView tabView, int originSize) {
        return originSize;
    }

    default int getScaleTabLeft(@NonNull TabLayout.TabView tabView, int left) {
        return left;
    }

    default int getScaleTabTop(@NonNull TabLayout.TabView tabView, int top) {
        return top;
    }

    default int transformScrollX(int x) {
        return x;
    }

    default int transformScrollY(int y) {
        return y;
    }

}

在TabLayout中插入事件点

可以改变最大滚动距离

   @Override
    public void scrollTo(int x, int y) {
        if (tabEventListener != null && tabEventListener.matchMode(mode)) {
            x = tabEventListener.transformScrollX(x);
            y = tabEventListener.transformScrollY(y);
        }
        super.scrollTo(x, y);
    }

    @Override
    protected void onOverScrolled(int scrollX, int scrollY, boolean clampedX, boolean clampedY) {
        if (tabEventListener != null && tabEventListener.matchMode(mode)) {
            scrollX = tabEventListener.transformScrollX(scrollX);
            scrollY = tabEventListener.transformScrollY(scrollY);
        }
        super.onOverScrolled(scrollX, scrollY, clampedX, clampedY);
    }

layout TabView,可以确定TabView的宽高

@Override
  protected void onLayout(boolean changed, int l, int t, int r, int b) {
      super.onLayout(changed, l, t, r, b);
      if (tabEventListener != null && tabEventListener.matchMode(mode)) {
          tabEventListener.onTabViewLayout(this);
      }
  }

提供给指示器计算位置使用,因为缩放TabView的同时,我们也需要改变指示器的位置

int getScaleContentWidth() {
            int result = getContentWidth();
            if (tabEventListener != null && tabEventListener.matchMode(mode)) {
                result = tabEventListener.getScaleTabContentWidth(this, result);
            }
            return result;
        }

        int getScaleContentHeight() {
            int result = getContentHeight();
            if (tabEventListener != null && tabEventListener.matchMode(mode)) {
                result = tabEventListener.getScaleTabContentHeight(this, result);
            }
            return result;
        }

        int getScaleTabWidth() {
            int width = getWidth();
            if (tabEventListener != null && tabEventListener.matchMode(mode)) {
                width = tabEventListener.getScaleTabWidth(this, width);
            }
            return width;
        }

        int getScaleTabHeight() {
            int height = getHeight();
            if (tabEventListener != null && tabEventListener.matchMode(mode)) {
                height = tabEventListener.getScaleTabHeight(this, height);
            }
            return height;
        }

        int getScaleLeft() {
            int left = getLeft();
            if (tabEventListener != null && tabEventListener.matchMode(mode)) {
                left = tabEventListener.getScaleTabLeft(this, left);
            }
            return left;
        }

        int getScaleTop() {
            int top = getTop();
            if (tabEventListener != null && tabEventListener.matchMode(mode)) {
                top = tabEventListener.getScaleTabTop(this, top);
            }
            return top;
        }

        int getScaleRight() {
            int right = getRight();
            if (tabEventListener != null && tabEventListener.matchMode(mode)) {
                right = getScaleLeft() + tabEventListener.getScaleTabWidth(this, getWidth());
            }
            return right;
        }

        int getScaleBottom() {
            int bottom = getBottom();
            if (tabEventListener != null && tabEventListener.matchMode(mode)) {
                bottom = getScaleTop() + tabEventListener.getScaleTabHeight(this, getHeight());
            }
            return bottom;
        }

linearLayout 重新测量

@Override
        protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);

            if (tabEventListener != null && tabEventListener.matchMode(mode)) {
                tabEventListener.onReMeasureChildren(this, new Consumer<Integer>() {

                    @Override
                    public void accept(Integer width) {
                        setMeasuredDimension(width, getMeasuredHeight());
                    }
                });
            }
    }

linearLayout 重新布局

@Override
        protected void onLayout(boolean changed, int l, int t, int r, int b) {
            super.onLayout(changed, l, t, r, b);

            if (tabEventListener != null && tabEventListener.matchMode(mode)) {
                tabEventListener.onRelayoutChildren(this);
            }

            if (indicatorAnimator != null && indicatorAnimator.isRunning()) {
                // It's possible that the tabs' layout is modified while the indicator is animating (ex. a
                // new tab is added, or a tab is removed in onTabSelected). This would change the target end
                // position of the indicator, since the tab widths are different. We need to modify the
                // animation's updateListener to pick up the new target positions.
                updateOrRecreateIndicatorAnimation(
                        /* recreateAnimation= */ false, getSelectedTabPosition(), /* duration= */ -1);
            } else {
                // If we've been laid out, update the indicator position
                jumpIndicatorToSelectedPosition();
            }
        }

根据进度改变TabView的大小

private void tweenIndicatorPosition(View startTitle, View endTitle, float fraction) {
            boolean hasVisibleTitle = startTitle != null && startTitle.getWidth() > 0;
            if (hasVisibleTitle) {
                if (tabEventListener != null && tabEventListener.matchMode(mode)) {
                    tabEventListener.onUpdateProgress(this, (TabView) startTitle, (TabView) endTitle, fraction);
                }
                tabIndicatorInterpolator.updateIndicatorForOffset(
                        TabLayout.this, startTitle, endTitle, fraction, tabSelectedIndicator);
            } else {
                // Hide the indicator by setting the drawable's width to 0 and off screen.
                tabSelectedIndicator.setBounds(
                        -1, tabSelectedIndicator.getBounds().top, -1, tabSelectedIndicator.getBounds().bottom);
            }

            ViewCompat.postInvalidateOnAnimation(this);
        }

在TabIndicatorInterpolator中插入事件点

根据缩放值确定新边界

static RectF calculateTabViewContentBounds(
            @NonNull TabView tabView, @Dimension(unit = Dimension.DP) int minWidth) {
        int tabViewContentWidth = tabView.getScaleContentWidth();
        int tabViewContentHeight = tabView.getScaleContentHeight();
        int minWidthPx = (int) ViewUtils.dpToPx(tabView.getContext(), minWidth);

        if (tabViewContentWidth < minWidthPx) {
            tabViewContentWidth = minWidthPx;
        }

        int tabViewCenterX = (tabView.getScaleLeft() + tabView.getScaleRight()) / 2;
        int tabViewCenterY = (tabView.getScaleTop() + tabView.getScaleBottom()) / 2;
        int contentLeftBounds = tabViewCenterX - (tabViewContentWidth / 2);
        int contentTopBounds = tabViewCenterY - (tabViewContentHeight / 2);
        int contentRightBounds = tabViewCenterX + (tabViewContentWidth / 2);
        int contentBottomBounds = tabViewCenterY + (tabViewCenterX / 2);

        return new RectF(contentLeftBounds, contentTopBounds, contentRightBounds, contentBottomBounds);
    }

    static RectF calculateIndicatorWidthForTab(TabLayout tabLayout, @Nullable View tab) {
        if (tab == null) {
            return new RectF();
        }

        // If the indicator should fit to the tab's content, calculate the content's width
        if (!tabLayout.isTabIndicatorFullWidth() && tab instanceof TabView) {
            return calculateTabViewContentBounds((TabView) tab, MIN_INDICATOR_WIDTH);
        }

        if (tab instanceof TabView) {
            TabView tabView = (TabView) tab;
            return new RectF(tabView.getScaleLeft(), tabView.getScaleTop(), tabView.getScaleRight(), tabView.getScaleBottom());
        }

        // Return the entire width of the tab
        return new RectF(tab.getLeft(), tab.getTop(), tab.getRight(), tab.getBottom());
    }

监听事件,对TabView进行自定义控制

tabEventListener = object : ITabEventListener {

            private val selectedTabScale = 1.6f

            private var startView: TabView? = null
            private var endView: TabView? = null

            private var maxScrollX = 0f

            private var extraWidth = 0

            override fun matchMode(mode: Int): Boolean {
                return TabLayout.MODE_SCROLLABLE == mode
            }

            override fun onReMeasureChildren(slidingTabIndicator: LinearLayout, action: Consumer<Int>) {
                slidingTabIndicator.clipChildren = false
                slidingTabIndicator.clipToPadding = false
                slidingTabIndicator.apply {
                    if (selectedTabScale == 1f) {
                        return
                    }
                    val largestTabWidth = children.fold(0) { acc, child ->
                        val tabView = child as TabView
                        if (child.visibility == View.VISIBLE) {
                            acc.coerceAtLeast(tabView.textView.measuredWidth)
                        } else {
                            acc
                        }
                    }
                    if (largestTabWidth <= 0) {
                        // If we don't have a largest child yet, skip until the next measure pass
                        return@apply
                    }

                    extraWidth = ((selectedTabScale - 1f) * largestTabWidth).roundToInt()
                    action.accept(measuredWidth + extraWidth)
                }
            }

            override fun onRelayoutChildren(slidingTabIndicator: LinearLayout) {
                layoutChildren(slidingTabIndicator, true)
            }

            override fun onTabViewLayout(tabView: TabLayout.TabView) {
                tabView.clipChildren = false
                tabView.clipToPadding = false
                tabView.textView.apply {
                    pivotX = 0f
                    pivotY = height * 0.8f
                }
            }

            override fun onUpdateProgress(slidingTabIndicator: LinearLayout, currentTab: TabView, nextTab: TabView?, progress: Float) {
                if (startView != null && startView != currentTab && startView != nextTab) {
                    startView?.textView?.apply {
                        scaleX = 1f
                        scaleY = 1f
                    }
                }

                if (endView != null && endView != currentTab && endView != nextTab) {
                    endView?.textView?.apply {
                        scaleX = 1f
                        scaleY = 1f
                    }
                }

                currentTab.textView.apply {
                    val scale = selectedTabScale.plus(1.minus(selectedTabScale).times(progress))
                    scaleX = scale
                    scaleY = scale
                    startView = currentTab
                }

                nextTab?.textView?.apply {
                    val scale = 1.plus(selectedTabScale.minus(1f).times(progress))
                    scaleX = scale
                    scaleY = scale
                    endView = nextTab
                }
                layoutChildren(slidingTabIndicator, false)
            }

            private fun layoutChildren(slidingTabIndicator: LinearLayout, fromOnLayout: Boolean) {
                var translationX = 0f
                slidingTabIndicator.forEach { child ->
                    val tabView = child as TabView
                    if (child.visibility == View.VISIBLE) {
                        val rightGap = (tabView.textView.scaleX - 1).takeIf {
                            it != 0f
                        }?.let { (it * tabView.textView.measuredWidth) } ?: 0f
                        child.translationX = translationX
                        translationX += rightGap
                    }
                }
                maxScrollX = slidingTabIndicator.right - (slidingTabIndicator.parent as View).width - extraWidth + translationX
            }

            override fun getScaleTabContentWidth(tabView: TabView, originSize: Int): Int {
                return tabView.textView.scaleX.times(originSize).toInt()
            }

            override fun getScaleTabContentHeight(tabView: TabView, originSize: Int): Int {
                return tabView.textView.scaleY.times(originSize).toInt()
            }

            override fun getScaleTabWidth(tabView: TabView, originSize: Int): Int {
                return originSize.plus(tabView.textView.scaleX.minus(1).times(tabView.textView.width).toInt())
            }

            override fun getScaleTabHeight(tabView: TabView, originSize: Int): Int {
                return originSize.plus(tabView.textView.scaleY.minus(1).times(tabView.textView.height).toInt())
            }

            override fun transformScrollX(x: Int): Int {
                return maxScrollX.toInt().coerceAtMost(x)
            }
        }

最终效果

![Demo](i.postimg.cc/0y0Ms5rJ/de…