Android進階:手把手教你實現高仿微信底部導航欄動畫

本文轉載自掘金 原作者不惜留戀_
原文鏈接:https://juejin.im/post/5d5365876fb9a06b130f1aa6

微信自發布以來,底部導航欄的動畫一直讓開發者津津樂道,而且伴隨着版本更新,底部導航欄的動畫也一直在改進。
最近有人問我,微信的最新版本的底部導航欄的動畫的原理是什麼。閒暇之餘,我仔細瞅了瞅最新版本的微信,底部的動畫非常可謂非常之有意思,這也是這篇文章的由來。
我想大家都安裝有微信,大家可以自己看看自己手機上微信的底部導航欄的動畫效果,然後再對比看看我實現的效果(如下圖),幾乎是一毛一樣。

原理

首先,項目的架構是一個ViewPager加上底部導航欄,ViewPager的滑動可以產生一個滑動比例,底部導航欄根據這個比例值做相應的動畫。那麼,現在問題來了,底部導航欄如何實現。其實我們可以對底部導航欄的tab寫一個自定義View,這個自定義View可以接收一個進度值(ViewPager產生的滑動比例值)來做一些動畫。

實現

ViewPager的初始化代碼我就不展示了,這個是基本功了,本文主要展示底部的Tab如何自定義View。

佈局

這個自定義View的名字叫做TabView, 我選擇讓它繼承自FrameLayout(繼承其他的ViewGroup控件也可以的),並且加載一個如下的組合控件佈局

// 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>

android:id="@+id/tab_title"的TextView顯示標題。android:id="@+id/tab_image"的ImageView顯示的是一個輪廓圖片,也就是未選中時候的圖片。android:id="@+id/tab_image_top"的ImageView在android:id="@+id/tab_image"的ImageView之上,顯示的是選中時候的圖片。輪廓圖片和選中的圖片,可以看下如下的圖片例子

第一圖片就是輪廓圖片,第二個圖片就是選中後的圖片。爲什麼佈局要這麼設計呢?這當然是根據微信的動畫而設計的佈局(廢話!),首先默認顯示的是輪廓圖片,當接收到一個進度值後,會讓輪廓圖片的輪廓變色,當進度值超過某個閾值的時候,讓輪廓圖片的透明度漸漸變爲0(也就是完全透明,看不見),而讓選中的圖片的透明度漸漸變爲255(也就是慢慢變清晰)。

TabView

既然知道了變色的原理,現在就來寫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();
    }

當加載完佈局後,需要爲各個控件設置相應的屬性

@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);
}

第二步中,爲輪廓圖調用了Drawable.setTint()方法爲輪廓着色,默認着色爲黑色。

Drawable.setTint() 其實就是利用 PorterDuff.Mode.DST_IN 來進行顏色混合。

然後我還需要一個接收進度值(範圍爲0.f 到 1.f)的方法,從而利用這個進度值做動畫

/**
 * 根據進度值進行變色和透明度處理。
 *
 * @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到1,所以可以把這個方法拷貝過來使用。計算出顏色值後,就可以對標題和輪廓圖着色了。第二步,按照之前說的動畫原理,要利用進度值計算透明度,然後分別對輪廓圖和選中圖設置透明度。透明度的計算原理已經在註釋中寫清楚了,這裏不再贅述。最後就是更新UI了。

與ViewPager聯動

一切準備就緒,就等一個ViewPager的進度值

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

    }
});

既然想知道ViewPager如何提供進度值,那就必須瞭解onPageScrolled方法的幾個參數的意思,首先從源碼的註釋中進行了解

/**
 * 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,它保存了所有的TabViewmTabViews.get(posistion)獲取的是左邊的頁面,mTabViews.get(position)獲取的是右邊的頁面。當從左邊向右邊滑動的時候,左邊頁面的positionOffset的值是從0到1的。

當從右邊到左邊滑動的時候,左邊頁面的positionOffset的值是從1到0的。而我在設計TabView的時候,如果進度值是1就表示選中,這與positionOffset的值的變動範圍恰恰相反(我這個設計是否需要改進下?)。所以對左邊的頁面取的進度值就是1 - positionOffset,而對右邊頁面的進度值取的就是positionOffset

然而,右邊的頁面也有不可見的時候,那就是positionOffset爲0的時候,這個時候就不需要對右邊的頁面執行動畫,這個處理很關鍵。

結束

有些細節,本文並沒有給出,例子已經上傳
github:https://github.com/buxiliulian/WeChatBottomNavigation

學習分享,共勉

題外話,我從事Android開發已經五年了,此前我指導過不少同行。但很少跟大家一起探討,正好最近我花了一個多月的時間整理出來一份包括不限於高級UI、性能優化、移動架構師、NDK、混合式開發(ReactNative+Weex)微信小程序、Flutter等全方面的Android進階實踐技術,今天暫且開放給有需要的人,若有關於此方面可以轉發+關注+點贊後加羣 878873098 領取,或者評論與我一起交流探討。

資料免費領取方式:轉發+關注+點贊後,加入點擊鏈接加入羣聊:Android高級開發交流羣(878873098)即可獲取免費領取方式!

重要的事說三遍,關注!關注!關注!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章