高仿微信底部导航栏动画

4,329 阅读11分钟

转载请注明出处: juejin.cn/post/684490…

微信自发布以来,底部导航栏的动画一直让开发者津津乐道,而且伴随着版本更新,底部导航栏的动画也一直在改进。我最近在闲暇之余,看了下微信的底部导航栏动画,于是思考了下这个动画的原理,感觉非常有意思,于是写下这篇文章。

下图就是我实现的效果,大家可以对比下微信的效果,几乎可以以假乱真。

微信底部动画

动画过程

关于这个动画的过程,我刚开始了是瞅了老半天了,因为如果我们不了解动画的过程也是无从去实现了,所以动画过程很重要,这个动画其实有两个过程

  1. 首先是默认图片的轮廓变色。
  2. 轮廓变色到一定程度后,整个图片出现了绿色的填充效果,也就是整个图片开始变绿,直到整个图片完全变为了绿色。其实这是两个图片的透明度变换的达成的效果。

动画实现原理

首先我们从整体上看,滑动的页面可以用ViewPager实现,在滑动的过程中,通过监听ViewPager的滑动事件,可以获取一个滑动的比例值。

底部的导航栏的4个Tab可以用自定义一个View来实现,我把这个自定义的View叫做TabView。那么,在滑动的过程中,当前页面的TabView执行褪色动画,后一个页面执行变色动画。动画到底执行到哪一步,肯定就是由ViewPager的滑动比例值决定的。因此TabView需要一个接收动画进度比例值的方法来控制动画的程度。

代码实现

俗话说得好,Talk is cheap, show me the code!。那我们就通过代码来实现我们之前的猜想吧,这肯定是一段非常激情的旅程!

由于不想篇幅过大,因此我省略了ViewPager的一些样板代码,因为这些属于基本功。如果不会用ViewPager,在网上随便搜索就是一大堆的文章,很轻松就掌握了。那么本文主要就是解决如何自定义这个TabView

自定义View有很多方式,我相信很多人比我还懂。而我选择的是组合系统控件的方式来实现这个自定义View。那么可能有人问我,如果为了更好的绘制性能,能不能完全的自定义一个View来实现呢?这当然是可以的,学完本文你就可以做这个牛逼的操作。然而,这点绘制性能的提升,其实在现在的高配置的手机上是可以忽略的。那么为了开发效率,组合系统控件应该是首选。

实现组合控件的布局

TabView需要的组合控件的布局如下

// tab_layout.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="40dp"
    android:gravity="center_horizontal"
    android:orientation="vertical">

    <FrameLayout
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        android:layout_weight="1">

        <ImageView
            android:id="@+id/tab_image"
            android:layout_width="wrap_content"
            android:layout_height="match_parent" />

        <ImageView
            android:id="@+id/tab_image_top"
            android:layout_width="wrap_content"
            android:layout_height="match_parent" />
    </FrameLayout>

    <TextView
        android:id="@+id/tab_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="12sp" />
</LinearLayout>

布局的TextView肯定是用来显示标题的,然而还有两个ImageView,为何这样设计呢?这与我们动画的实现有关。

@+id/tab_imageImageView在底部,它是用来显示一个默认的图片的,我称它为轮廓图,例如第一个页面的TabView的轮廓图如下

轮廓图片

我们需要对这个轮廓进行变色处理,大家可以观察一下动画的过程,第一个过程很显然是轮廓的变色。

@+id/tab_image_topImageView在上面,它是用来显示一个页面被选中后的图片,也是动画最终要显示的图片,例如第一个页面的TabView的选中图片如下

选中图片

现在来说明下如何用这个布局来实现动画。

  1. 首先所有的TabView都显示轮廓图,选中图都进行隐藏。如何隐藏呢,我选择使用透明度来隐藏选中图,因为整个动画过程有透明度的变换。
  2. 当滑动ViewPager的时候,TabView获取滑动的进度值,我们就让轮廓图的轮廓开始变色。那么怎么变色呢,有一个很方便的方法,就是Drawable.setTint()方法。这个方法的原理就是PorterDuff.Mode.DST_IN混合模式。如果大家有兴趣,可以去研究下原理。
  3. ViewPager滑动到一定距离的时候,如果松开手指,页面会自动滑动到下一个页面,这个比例值到底是多少呢?我暂时还没有考究,我假定是0.5吧。当滑动的比较超过0.5的时候,就要让轮廓图的透明度逐渐变是0,也就是慢慢地的看不见了,同时,选中图的透明度逐渐变为255,也是慢慢的清晰了。如此一来,就会出现轮廓图的整体颜色填充效果。

怎么样,实现思路是不是有点意思,那么我们来根据这个思路来实现这个自定义ViewTabView吧。

实现TabView

加载布局

既然有了布局,那么首先用在TabView的构造函数中来加载这个布局

public TabView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        // 加载布局
        inflate(context, R.layout.tab_layout, this);
}

自定义属性与解析

为了更好的在XML布局中使用TabView,我为TabView抽取的自定义属性

// res/values/tabview_attrs.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="TabView">
        <attr name="tabColor" format="color|integer" />
        <attr name="tabImage" format="reference" />
        <attr name="tabSelectedImage" format="reference" />
        <attr name="tabTitle" format="string|reference" />
    </declare-styleable>
</resources>

tabColor代表变色最终显示的颜色,这个颜色可以从选中图中用取色器获取。

tabImage代表默认显示的轮廓图。

tabSelectedImage代表选中后的图。

tabTitle代表要显示的标题。

有了这些自定义属性,那么在TabView中必须要解析这些自定义属性

public TabView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        // 加载布局
        inflate(context, R.layout.tab_layout, this);

        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabView);
        for (int i = 0; i < a.getIndexCount(); i++) {
            int attr = a.getIndex(i);
            switch (attr) {

                case R.styleable.TabView_tabColor:
                    // 获取标题和轮廓最终的着色
                    mTargetColor = a.getColor(attr, DEFAULT_TAB_TARGET_COLOR);
                    break;

                case R.styleable.TabView_tabImage:
                    // 获取轮廓图
                    mNormalDrawable = a.getDrawable(attr);
                    break;

                case R.styleable.TabView_tabSelectedImage:
                    // 获取选中图
                    mSelectedDrawable = a.getDrawable(attr);
                    break;

                case R.styleable.TabView_tabTitle:
                    // 获取标题
                    mTitle = a.getString(attr);
                    break;
            }

        }
        a.recycle();
    }

自定义属性解析完毕后,就需要给用这些属性值给控件进行初始化。ViewonFinishInflate()方法代表布局加载完成,因此在这里获取控件,并进行初始化。

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();

        // 1.设置标题,默认着色为黑色
        mTitleView = findViewById(R.id.tab_title);
        mTitleView.setTextColor(DEFAULT_TAB_COLOR);
        mTitleView.setText(mTitle);

        // 2.设置轮廓图片,不透明,默认着色为黑色
        mNormalImageView = findViewById(R.id.tab_image);
        mNormalDrawable.setTint(DEFAULT_TAB_COLOR);
        mNormalDrawable.setAlpha(255);
        mNormalImageView.setImageDrawable(mNormalDrawable);

        // 3.设置选中图片,透明,默认着色为黑色
        mSelectedImageView = findViewById(R.id.tab_selected_image);
        mSelectedDrawable.setAlpha(0);
        mSelectedImageView.setImageDrawable(mSelectedDrawable);
    }

标题设置了一个默认颜色DEFAULT_TAB_COLOR,是黑色。同样,也为轮廓图的轮廓设置黑色。轮廓图的透明度初始为255,也就是完全可见,而选中图的透明度设置为0,也就是完全不可见。所有这一切就是动画的初始状态。

控制动画进度

在前面的讲解动画的原理的时候说到一个事情,TabView需要使用ViewPager滑动进度值来控制动画的进度,因此还要为TabView定义一个接收进度值的方法。

    /**
     * 根据进度值进行变色和透明度处理。
     *
     * @param percentage 进度值,取值[0, 1]。
     */
    public void setXPercentage(float percentage) {
        if (percentage < 0 || percentage > 1) {
            return;
        }

        // 1. 颜色变换
        int finalColor = evaluate(percentage, DEFAULT_TAB_COLOR, mTargetColor);
        mTitleView.setTextColor(finalColor);
        mNormalDrawable.setTint(finalColor);

        // 2. 透明度变换
        if (percentage >= 0.5 && percentage <= 1) {
            // 原理如下
            // 进度值: 0.5 ~ 1
            // 透明度: 0 ~ 1
            // 公式: percentage - 1 = (alpha - 1) * 0.5
            int alpha = (int) Math.ceil(255 * ((percentage - 1) * 2 + 1));
            mNormalDrawable.setAlpha(255 - alpha);
            mSelectedDrawable.setAlpha(alpha);
        } else {
            mNormalDrawable.setAlpha(255);
            mSelectedDrawable.setAlpha(0);
        }

        // 3. 更新UI
        invalidateUI();
    }

在这个对外开放的接口中,首先我们要根据进度值来计算轮廓要使用的颜色。起始颜色是黑色,最终颜色是一个绿色,然后我们还有一个进度值,那么如何计算某个进度的对应的颜色值呢?其实在属性动画中有一个类,ArgbEvaluator,它提供了颜色的计算方法,代码如下

    public Object evaluate(float fraction, Object startValue, Object endValue) {
        int startInt = (Integer) startValue;
        float startA = ((startInt >> 24) & 0xff) / 255.0f;
        float startR = ((startInt >> 16) & 0xff) / 255.0f;
        float startG = ((startInt >>  8) & 0xff) / 255.0f;
        float startB = ( startInt        & 0xff) / 255.0f;

        int endInt = (Integer) endValue;
        float endA = ((endInt >> 24) & 0xff) / 255.0f;
        float endR = ((endInt >> 16) & 0xff) / 255.0f;
        float endG = ((endInt >>  8) & 0xff) / 255.0f;
        float endB = ( endInt        & 0xff) / 255.0f;

        // convert from sRGB to linear
        startR = (float) Math.pow(startR, 2.2);
        startG = (float) Math.pow(startG, 2.2);
        startB = (float) Math.pow(startB, 2.2);

        endR = (float) Math.pow(endR, 2.2);
        endG = (float) Math.pow(endG, 2.2);
        endB = (float) Math.pow(endB, 2.2);

        // compute the interpolated color in linear space
        float a = startA + fraction * (endA - startA);
        float r = startR + fraction * (endR - startR);
        float g = startG + fraction * (endG - startG);
        float b = startB + fraction * (endB - startB);

        // convert back to sRGB in the [0..255] range
        a = a * 255.0f;
        r = (float) Math.pow(r, 1.0 / 2.2) * 255.0f;
        g = (float) Math.pow(g, 1.0 / 2.2) * 255.0f;
        b = (float) Math.pow(b, 1.0 / 2.2) * 255.0f;

        return Math.round(a) << 24 | Math.round(r) << 16 | Math.round(g) << 8 | Math.round(b);
    }

熟悉属性动画的应该知道,参数float fraction的取值范围为0.f到1.f,所以可以把这个方法拷贝过来使用。

计算出颜色值后,就可以对标题和轮廓图着色了。

第二步,按照之前说的动画原理,当滑动的进度达到0.5后,要对轮廓图和选中图进行透明度的变换。

那么首先我们得计算出某个进度对应的透明度。很明显,这是一道数学题,进度的变化范围是从0.5到1.0,透明度的变换取0到1.0(之后于乘以255即可得到实际的透明度)。透明度和进度的比例值是2,那么就可以得出一个公式alpha - 1 = (percentage - 1.0) * 2。有了这个公式,就可以算出任意进度值对应的透明度了。

这一切就绪后,我们就使出杀手锏了,更新UI,让系统进行重绘。

与ViewPager联动

最重要的自定义View已经准备完毕,是时候来测试效果了。那么我们必须要知道如何获取ViewPager的滑动进度值了,我们可以为ViewPager设置滑动监听器

        mViewPager.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
            @Override
            public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {

            }
        });

参数float positionOffset就是一个进度值,但是这个进度值使用起来还是需要点小技巧的,我们先从源码中看下解释

        /**
         * This method will be invoked when the current page is scrolled, either as part
         * of a programmatically initiated smooth scroll or a user initiated touch scroll.
         *
         * @param position Position index of the first page currently being displayed.
         *                 Page position+1 will be visible if positionOffset is nonzero.
         * @param positionOffset Value from [0, 1) indicating the offset from the page at position.
         * @param positionOffsetPixels Value in pixels indicating the offset from position.
         */
        void onPageScrolled(int position, float positionOffset, int positionOffsetPixels);

从注释中可以看出,onPageScrolled方法是在滑动的时候调用,参数position代表当前显示的页面,这表解释很容易产生误解,其实无论是从左边往右边滑动,还是从右边往左边滑动,position始终代表左边的页面,因此position + 1始终代表右边的页面。

参数positionOffset代表滑动的进度值,并且还有很重要一点,大部分人都会忽略,如果参数positionOffset为非零值,表示右边的页面可见,也就是说,如果positionOffset的值是零,那么代表右边的页面是不可见的,这一点会在代码中体现出来。

既然已经对参数有所了解,那么现在来看看实现

            public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
                // 左边View进行动画
                mTabViews.get(position).setXPercentage(1 - positionOffset);
                // 如果positionOffset非0,那么就代表右边的View可见,也就说明需要对右边的View进行动画
                if (positionOffset > 0) {
                    mTabViews.get(position + 1).setXPercentage(positionOffset);
                }
            }

mTabViews是一个ArrayList,它保存了所有的TabView,我们页面中有四个TabViewmTabViews.get(posistion)获取的是滑动时左边的页面,mTabViews.get(position + 1)获取的就是右边的页面。

当从左边向右边滑动的时候,左边页面的positionOffset的值是从0到1的,此时我们需要左边的页面的TabView执行褪色动画。然而在我们设计的TabView中,进度值达到1的时候,执行的是变色动画,而不是褪色动画,因此左边页面的TabView的进度取值要改变下,取1 - positionOffset。那么右边的页面的进度取值自然就是positionOffset了。

从右到左的滑动的原理其实与从左到右的滑动的原理是一样的,大家可以从Log中看出端倪。

然而,在为左边的TabView做动画的时候,我们一定要确保有右边的页面存在。我们前面讲解的时候说过,如果positionOffset为0的时候,右边的页面是不可见的,因此我们要做一些排除的动作,这在代码中有体现的。

代码优化

  1. ViewPager可以自动滑动到下一个页面的进度值临界点是多少?TabView需要这个临界点来控制透明度的变换。
  2. TabView只能通过XML的属性来控制图片的显示,控制最终显色的颜色等等功能,其实这些可以通过代码动态控制,我们可以实现一个对外的接口。

如果大家是个精益求精的人,可以对这两点进行考究和实现。

结束

本文把动画的原理,以及如何用代码实现这些原理讲解清楚了,这些都是关键部分。然而其它部分的代码我并没有给出。为了方便想查看demo的人,我把代码上传到 github。所谓赠人玫瑰,手留余香,如果您觉得代码写的还行,客官给个star或者fork吧~