仿掌阅实现 TabLayout 切换时的字体和 Indicator 动画

前言

最近在作的一个小说阅读 APP,打算模仿掌阅实现 TabLayout 切换时的动画效果。php

首先看下掌阅的切换效果:java

接下来是个人实现效果:android

分析

切换动画主要有两部分组成:canvas

  1. 字体的缩放动画:进入页面的字体逐渐放大,移除页面的字体逐渐缩小
  2. Indicator 的长度变化动画:在进行页面滑动时,Indicator 的长度由短边长再变短。以页面移出一半为分界线,前半部分 Indicator 由短变长,后半部分 Indicator 由长变短。

接下来的实现也分这两部分进行。缓存

实现字体缩放动画

这里的重点是获取到当前页面移出屏幕和旁边页面进入屏幕的比例,我采用的方法是实现 ViewPager.PageTransformer 接口,经过里面 transformPage 方法的 position 参数获取页面移出(进入)屏幕的比例。bash

这里的难点是如何理解 position 参数的变化规律,怎么理解就不在这里讲了,要解释清楚须要很大篇幅,想要了解的话能够另外查资料,或者看下我写的这篇分析:ViewPager.PageTransformer 的 position 分析app

具体的接口实现以下:ide

/**
 * @author Feng Zhaohao
 * Created on 2019/11/2
 */
public class MyPageTransformer implements ViewPager.PageTransformer {

    public static final float MAX_SCALE = 1.3f;

    private TabLayout mTabLayout;
    @SuppressLint("UseSparseArrays")
    private HashMap<Integer, Float> mLastMap = new HashMap<>();

    public MyPageTransformer(TabLayout mTabLayout) {
        this.mTabLayout = mTabLayout;
    }

    @Override
    public void transformPage(@NonNull View view, float v) {
        if (v > -1 && v < 1) {
            int currPosition = (int) view.getTag(); // 获取当前 View 对应的索引
            final float currV = Math.abs(v);
            if (!mLastMap.containsKey(currPosition)) {
                mLastMap.put(currPosition, currV);
                return;
            }
            float lastV = mLastMap.get(currPosition);
            // 获取当前 TabView 的 TextView 
            LinearLayout ll = (LinearLayout) mTabLayout.getChildAt(0);
            TabLayout.TabView tb = (TabLayout.TabView) ll.getChildAt(currPosition);
            View textView = tb.getTextView();

            // 先判断是要变大仍是变小
            // 若是 currV > lastV,则为变小;若是 currV < lastV,则为变大
            if (currV > lastV) {
                float leavePercent = currV; // 计算离开屏幕的百分比
                // 变小
                textView.setScaleX(MAX_SCALE - (MAX_SCALE - 1.0f) * leavePercent);
                textView.setScaleY(MAX_SCALE - (MAX_SCALE - 1.0f) * leavePercent);
            } else if (currV < lastV) {
                float enterPercent = 1 - currV; // 进入屏幕的百分比
                // 变大
                textView.setScaleX(1.0f + (MAX_SCALE - 1.0f) * enterPercent);
                textView.setScaleY(1.0f + (MAX_SCALE - 1.0f) * enterPercent);
            }
            mLastMap.put(currPosition, currV);
        }
    }
}
复制代码

有几点说明一下。布局

  1. 为了经过 View 获取到其对应的位置,在给 Fragment 建立视图的时候,给它设置一个 tag,值为它的位置索引:
@Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        View view = LayoutInflater.from(getActivity()).inflate(R.layout.fragment_test, null);
        view.setTag(index);     // index 为该 Fragment 的位置索引

        return view;
    }
复制代码
  1. 在获取当前 TabView 的 TextView 时,我经过当前位置获取到的 TabView 能够直接进行转换:
LinearLayout ll = (LinearLayout) mTabLayout.getChildAt(0);
            TabLayout.TabView tb = (TabLayout.TabView) ll.getChildAt(currPosition);
复制代码

若是使用官方 TabLayout,是不能这样作的,由于它内部的 TabView 不是 public,你在外界不能访问到。我这里能这样作是由于这个 TabLayout 是我拷贝官方 TabLayout 到个人项目中,并对它进行了一些修改(之因此要修改官方 TabLayout,是为了实现 Indicator 的动画效果,以后会说到)。这里只需将内部类 TabView 的访问等级修改成 public 就好了。post

  1. 这里我是经过 setScaleX 和 setScaleY 方法对 TextView 进行缩放操做。

其实一开始我是直接使用 setTextSize 来进行缩放的,不过这样作的话会很发生很明显的文字抖动,因此放弃了这种作法。

后来参考一个开源库(MagicIndicator)的实现,使用 setScaleX 和 setScaleY 方法进行缩放,发现这样作效果好了不少。最终采用了这种方式实现文字的缩放。

如下是我对于这种差异的缘由猜想(不知道对不对):setScaleX 和 setScaleY 内部使用 invalidate(false) 重绘视图,而 setTextSize 使用 invalidate()(即 invalidate(true))重绘视图, invalidate 方法传入的参数表示是否让此视图的缓存无效,也就是说传入 true 时不使用缓存。因此我以为是由于 setScaleX 和 setScaleY 使用了缓存,因此效率更高,特别是进行动画时须要重绘屡次,使用缓存对于效率的提升就更加明显了。

实现 Indicator 的长度变化动画

实现这个仍是挺不容易的。要实现长度变化动画,你首先就要改变它的长度,但 TabLayout 并无直接提供设置 Indicator 宽度的方法,只能经过其余方式来设置。

总的来讲有这几种方法:反射实现、自定义 View、sdk28+ 属性配置、layer-list。可是这几种方法都不能实现最终的动画效果。

反射不能让 Indicator 的宽度小于文本宽度,否则会压缩文本。sdk28+ 属性配置虽然简单,但 Indicator 的宽度只能等于文本宽度。自定义 View 实现麻烦,而且很难实现动画效果。layer-list 实现简单,但缺点是 Indicator 没有动画效果。

以上方法除了自定义 View,我都一一尝试过,后来发现很难知足本身的需求。后来看到这篇文章骚操做之改造TabLayout,修改指示线宽增长切Tab过渡动画,里面讲到能够经过修改官方 TabLayout 来实现功能,受此启发,我最终经过修改官方 TabLayout 实现了 Indicator 的长度变化动画。

下面看下具体过程:

1、准备工做

  1. 导入相关类

这里我使用的是 API26 的 TabLayout。

  1. 一开始发现 Indicator 不显示,需在 xml 文件设置 Indicator 的颜色和高度
<com.feng.tablayoutdemo.tablayout.TabLayout android:id="@+id/tab_layout" android:layout_width="match_parent" android:layout_height="wrap_content" app:tabIndicatorColor="@android:color/holo_blue_light" app:tabIndicatorHeight="3dp" />
复制代码
  1. 一些分析

TabLayout 包含一个 SlidingTabStrip,SlidingTabStrip 中包含 n 个 TabView,TabView 包含:

private Tab mTab;
        private TextView mTextView;
        private ImageView mIconView;
复制代码

经过 TabView 的 update() 方法添加 mIconView 和 mTextView

TextView 不能放大到整个 tab,是由于它的父 View(TabView)默认设置了 padding。

2、让 TextView 撑满 TabView

默认状况下,TabView 是有 padding 的,因此文本之间才会有间距。可是这样会影响到我放大字体,字体放大是须要空间的,因此要在 TextView 内部设置 padding,可是因为 TabView 也有 padding,两个 padding 加起来会使得文本间距很大,很难看。因此我要取消 TabView 的 padding,让 TextView 撑满整个 TabView,而后在 TextView 内部设置 padding,留下放大的空间。

实现过程以下:

  1. 取消 TabView 默认的 padding:
public TabView(Context context) {
// ViewCompat.setPaddingRelative(this, mTabPaddingStart, mTabPaddingTop,
// mTabPaddingEnd, mTabPaddingBottom);
        }
复制代码
  1. 给 TabView 的 TextView 加上本身的布局:
final void update() {

                if (mTextView == null) {
// TextView textView = (TextView) LayoutInflater.from(getContext())
// .inflate(R.layout.design_layout_tab_text, this, false);
                    TextView textView = (TextView) LayoutInflater.from(getContext())
                            .inflate(com.feng.tabdemo.R.layout.tab_text, this, false);
                }

        }
复制代码

R.layout.tab_text:

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:ellipsize="end" android:gravity="center" android:maxLines="2" android:paddingStart="10dp" android:paddingEnd="10dp"/>
复制代码

经过 paddingStartpaddingEnd 设置字体间的间距。

  1. TabView 设置了一个最小宽度,为了防止 TextView 比较短时不能撑满,把它取消掉:
private TabView createTabView(@NonNull final Tab tab) {

// tabView.setMinimumWidth(getTabMinWidth());

    }
复制代码

3、实现滑动时 Indicator 的动画效果

动画效果:当从一个页面滑向另外一个页面时,Indicator 的宽度由短变长再变短。

下面简单分析下动画过程

以页面滑出一半(另外一个页面进来一半)为分界线,在前半段,由短变长:

页面滑动一半时,Indicator 的宽度达到最长:

在后半段,由长变短:

实现步骤:

  1. SlidingTabStrip 的修改
public class SlidingTabStrip extends LinearLayout {
        
        // Indicator 的左右边界
        private float left;
        private float right;
        
        @Override
        public void draw(Canvas canvas) {
            super.draw(canvas);

            canvas.drawRect(left, getHeight() - mSelectedIndicatorHeight,
                    right, getHeight(), mSelectedIndicatorPaint);
        }
    }
复制代码

在 SlidingTabStrip 中增长两个变量表示 Indicator 的左右边界,而且改写它的 draw 方法,这样作是为了方便滑动监听器修改 Indicator 的左右边界。

  1. 修改 TabLayoutOnPageChangeListener 的 onPageScrolled 方法

TabLayoutOnPageChangeListener 的 onPageScrolled 方法在页面滑动时回调,能够从中知道偏移页面和页面偏移量。知道偏移量就能够从新设置 Indicator 的左右边界并进行动画重绘。具体代码以下:

/** * @param position 当前显示的第一页的索引(右侧页面进入时显示的是当前页面,左侧页面进入时显示的是左侧页面) * @param positionOffset 取值范围 [0, 1),表示 position 页面的偏移量(在屏幕外的比例) * @param positionOffsetPixels */
        @Override
        public void onPageScrolled(final int position, final float positionOffset, final int positionOffsetPixels) {
            // ... 原有代码不用删,保留便可
            
            if (tabLayout == null) {
                return;
            }
            // Indicator 的宽度占 TabView 的比例
            float scale = 0.3f;

            // 左滑(右侧页面进入)的第一阶段
            // 以及右滑(左侧页面进入)的第二阶段
            if (positionOffset > 0 && positionOffset < 0.5) {
                tabLayout.mTabStrip.left = tabLayout.mTabStrip.getChildAt(position).getLeft()
                        + scale * tabLayout.mTabStrip.getChildAt(position).getWidth();
                float lr = tabLayout.mTabStrip.getChildAt(position).getRight()
                        - scale * tabLayout.mTabStrip.getChildAt(position).getWidth();
                float rr = tabLayout.mTabStrip.getChildAt(position + 1).getRight()
                        - scale * tabLayout.mTabStrip.getChildAt(position + 1).getWidth();
                tabLayout.mTabStrip.right = lr + (positionOffset / 0.5f) * (rr - lr);
                ViewCompat.postInvalidateOnAnimation(tabLayout.mTabStrip);
            }
            // 左滑(右侧页面进入)的第二阶段
            // 以及右滑(左侧页面进入)的第一阶段
            if (positionOffset > 0.5) {
                float rr = tabLayout.mTabStrip.getChildAt(position + 1).getRight()
                        - scale * tabLayout.mTabStrip.getChildAt(position + 1).getWidth();
                // 先确保 Indicator 滑动最右
                if (tabLayout.mTabStrip.right < rr) {
                    tabLayout.mTabStrip.right = rr;
                    ViewCompat.postInvalidateOnAnimation(tabLayout.mTabStrip);
                }
                float ll = tabLayout.mTabStrip.getChildAt(position).getLeft()
                        + scale * tabLayout.mTabStrip.getChildAt(position).getWidth();
                float rl = tabLayout.mTabStrip.getChildAt(position + 1).getLeft()
                        + scale * tabLayout.mTabStrip.getChildAt(position + 1).getWidth();
                tabLayout.mTabStrip.left = ll + ((positionOffset - 0.5f) / 0.5f) * (rl - ll);
                ViewCompat.postInvalidateOnAnimation(tabLayout.mTabStrip);
            }
            // 滑动开始或结束
            if (positionOffset == 0) {
                tabLayout.mTabStrip.left = tabLayout.mTabStrip.getChildAt(position).getLeft()
                        + scale * tabLayout.mTabStrip.getChildAt(position).getWidth();
                tabLayout.mTabStrip.right = tabLayout.mTabStrip.getChildAt(position).getRight()
                        - scale * tabLayout.mTabStrip.getChildAt(position).getWidth();
                ViewCompat.postInvalidateOnAnimation(tabLayout.mTabStrip);
            }
        }
复制代码

到此为止,就实现了完整的 Indicator 动画效果。

字体的缩放动画和 Indicator 的动画是分开实现的,把它们二者合并起来就是最后的动画效果了,合并的时候并不会产生什么冲突,毕竟它们的实现原理不一样,也没有什么耦合。

写在最后

实现完整的动画效果花了我三天时间,这三天包括还周末的两天,时间仍是花了挺久的。不过总的来讲仍是值得的,由于在实现过程不断地发现问题不断地解决,学到了很多。对反射的做用有了新的理解,对 TabLayout 的布局更加清晰了,也体会到了改官方源码的快感(笑)。写这篇文章更可能是为了趁刚写完把本身的理解记录下来,方便之后回看。若是可以帮助到有须要的人,那就更好了。

参考

相关文章
相关标签/搜索