開源庫原始碼學習---MagicIndicator

語言: CN / TW / HK

前言

最近在做一個需求,是關於底部導航欄的,實現效果如下:

動態變化toolbar.gif

其中icon是使用lottie動畫實現,可以進行回溯,也就是在fragment切換時可以動畫載入到一半且可以返回,這個使用lottie動畫很容易實現。

還有就是文字的顏色,也是根據滑動進行漸變,且可以回溯。本來想把這個效果進行優化一點,做成一個元件,但是發現還是有一些細節需要考慮,為了少踩坑,準備研究一下之前用過的一個很有名的指示器框架:MagicIndicator。

程式碼開源庫是:https://github.com/hackware1993/MagicIndicator

效果圖如下

效果圖.gif

這就是MagicIndicator中一些效果,功能很強大,我們就來看看它是如何實現的。

正文

先從簡單入手,分析一下需要做些什麼,因為原始碼程式碼實在太多了,必須要從問題入手,先看一下:

效果圖解析.jpg

從這裡我們就可以簡單列出幾個問題需要解決:

問題.png

帶著問題,我們再來看一下原始碼,這樣就會有思路。

MagicIndicator

看xml佈局裡:

```

<net.lucode.hackware.magicindicator.MagicIndicator
    android:id="@+id/magic_indicator1"
    android:layout_width="wrap_content"
    android:layout_height="@dimen/common_navigator_height"
    android:layout_gravity="center_horizontal" />

``` 可以發現上面效果圖中的3個tab在這裡是一個自定義View,這樣設計好處是為了可以放置無限多個tab,既然如此設計,那肯定要有個介面卡,來提供tab的文字、個數以及指示器的樣子, 所以這裡在Java程式碼中是:

MagicIndicator magicIndicator = (MagicIndicator) findViewById(R.id.magic_indicator1); //new出一個導航例項 CommonNavigator commonNavigator = new CommonNavigator(this); //導航例項的介面卡 commonNavigator.setAdapter(new CommonNavigatorAdapter() { //需要知道有多少個tab項 @Override public int getCount() { return mDataList == null ? 0 : mDataList.size(); } //需要知道每個標題View是什麼樣式的 @Override public IPagerTitleView getTitleView(Context context, final int index) { ...省略 return simplePagerTitleView; } //需要知道指示器是什麼樣子的 @Override public IPagerIndicator getIndicator(Context context) { LinePagerIndicator indicator = new LinePagerIndicator(context); indicator.setColors(Color.parseColor("#40c4ff")); return indicator; } }); //導航器設定介面卡 magicIndicator.setNavigator(commonNavigator); 其實這裡的邏輯和普通設定介面卡是一樣的,接下來就是把這個MagicIndicator和ViewPager給結合起來: ViewPagerHelper.bind(magicIndicator, mViewPager); 這裡就一行程式碼即可,使用ViewPagerHelper輔助類來完成。

從上面程式碼我們不禁可以看出,很多邏輯是在導航器CommonNavigator中,我們來看一下MagicIndicator的程式碼:

``` //自定義View,繼承值FrameLayout public class MagicIndicator extends FrameLayout { //導航器例項 private IPagerNavigator mNavigator;

public MagicIndicator(Context context) {
    super(context);
}

public MagicIndicator(Context context, AttributeSet attrs) {
    super(context, attrs);
}
//ViewPager滑動回撥
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
    if (mNavigator != null) {
        mNavigator.onPageScrolled(position, positionOffset, positionOffsetPixels);
    }
}
//ViewPager選中回撥,
public void onPageSelected(int position) {
    if (mNavigator != null) {
        mNavigator.onPageSelected(position);
    }
}
//ViewPager滑動狀態回撥
public void onPageScrollStateChanged(int state) {
    if (mNavigator != null) {
        mNavigator.onPageScrollStateChanged(state);
    }
}

public IPagerNavigator getNavigator() {
    return mNavigator;
}
//設定導航器
public void setNavigator(IPagerNavigator navigator) {
    if (mNavigator == navigator) {
        return;
    }
    if (mNavigator != null) {
        mNavigator.onDetachFromMagicIndicator();
    }
    mNavigator = navigator;
    removeAllViews();
    //這裡會發現導航器其實就是View,設定導航器時add View到FrameLayout
    if (mNavigator instanceof View) {
        LayoutParams lp = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
        addView((View) mNavigator, lp);
        mNavigator.onAttachToMagicIndicator();
    }
}

} ```

哦,看到這裡大家應該就明白了,這裡的MagicIndicator只是一個橋樑,具體的View實現在IPagerNavigator中,而通過MagicIndicator把ViewPager的滑動狀態傳遞給IPagerNavigator。

所以很有必要看一下ViewPagerHelper類:

``` //把ViewPager的滑動、選擇狀態傳遞給MagicIndicator中 public class ViewPagerHelper { public static void bind(final MagicIndicator magicIndicator, ViewPager viewPager) { viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {

        @Override
        public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
            magicIndicator.onPageScrolled(position, positionOffset, positionOffsetPixels);
        }

        @Override
        public void onPageSelected(int position) {
            magicIndicator.onPageSelected(position);
        }

        @Override
        public void onPageScrollStateChanged(int state) {
            magicIndicator.onPageScrollStateChanged(state);
        }
    });
}

} ``` 所以看到程式碼,和我們預期的一模一樣。類的大致關係:

大體架構.png

看到這裡我不禁有個疑問,就是滑動ViewPager時狀態傳遞給了MagicIndicator,這時tabView可以做出對應變化,但是點選TabView時,ViewPager如何切換呢,這個在哪做的呢,其實是在獲取標題View中做的,看一下上面說的IPagerNavigator的介面卡中getTitleView方法: @Override public IPagerTitleView getTitleView(Context context, final int index) { SimplePagerTitleView simplePagerTitleView = new ColorTransitionPagerTitleView(context); simplePagerTitleView.setText(mDataList.get(index)); simplePagerTitleView.setNormalColor(Color.parseColor("#88ffffff")); simplePagerTitleView.setSelectedColor(Color.WHITE); //這裡直接設定點選事件來切換ViewPager simplePagerTitleView.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { mViewPager.setCurrentItem(index); } }); return simplePagerTitleView; } ok,第一個問題已經解決,就是點選tab時切換viewPager是在getTitleView中手動處理。

既然知道了大體架構,那就主要看一下IPagerNavigator實現類即可,下面是CommonNavigator,也是原始碼中使用最多最簡單的一個IPagerNavigator。

CommonNavigator

這個就是上面效果圖中的導航器,正常來說就是一個MagicIndicator對應一個導航器Navigator,因為很簡單,這個Navigator也是是一個View,所以這裡的主要邏輯就集中在了這裡,如何新增標題View以及指示器View,都在這裡實現。

public class CommonNavigator extends FrameLayout implements IPagerNavigator , NavigatorHelper.OnNavigatorScrollListener

這裡實現了2個介面,我們來看一下。

IPagerNavigator

這個就是主要導航器介面了,

``` public interface IPagerNavigator {

// ViewPager的3個回撥
void onPageScrolled(int position, float positionOffset, int positionOffsetPixels);

void onPageSelected(int position);

void onPageScrollStateChanged(int state);
//

/**
 * 當IPagerNavigator被新增到MagicIndicator時呼叫
 */
void onAttachToMagicIndicator();

/**
 * 當IPagerNavigator從MagicIndicator上移除時呼叫
 */
void onDetachFromMagicIndicator();

/**
 * ViewPager內容改變時需要先呼叫此方法,自定義的IPagerNavigator應當遵守此約定
 */
void notifyDataSetChanged();

} ``` 其中主要就是前面也說過了,通過MagicIndicator把ViewPager的狀態傳遞到導航器中,所以ViewPager的3個回撥是必須的,還有就是新增、刪除、更新導航器的回撥。

NavigatorHelper.OnNavigatorScrollListener

這是啥玩意呢,看一下程式碼:

``` public interface OnNavigatorScrollListener { void onEnter(int index, int totalCount, float enterPercent, boolean leftToRight);

void onLeave(int index, int totalCount, float leavePercent, boolean leftToRight);

void onSelected(int index, int totalCount);

void onDeselected(int index, int totalCount);

} ``` 有點離譜,這裡是把ViewPager的3個回撥,給轉成了這4個回撥,為什麼要這麼做呢,原因很簡單,原來ViewPager的幾個回撥方法不是很好適配使用,所以改成這4個方法,具體原因我們後面分析。

新增view

到現在我們兵分2路,首先來看一下如何把View新增到導航器Navigator中,然後再看如何和ViewPager做聯動。

看一下CommonNavigator中的init程式碼:

``` private void init() { //先移除所有的view removeAllViews(); View root; //判斷是否是自適應模式 if (mAdjustMode) { root = LayoutInflater.from(getContext()).inflate(R.layout.pager_navigator_layout_no_scroll, this); } else { root = LayoutInflater.from(getContext()).inflate(R.layout.pager_navigator_layout, this); }

//這個就是標題容器
mTitleContainer = (LinearLayout) root.findViewById(R.id.title_container);
mTitleContainer.setPadding(mLeftPadding, 0, mRightPadding, 0);
//指示器容器
mIndicatorContainer = (LinearLayout) root.findViewById(R.id.indicator_container);
if (mIndicatorOnTop) {
    mIndicatorContainer.getParent().bringChildToFront(mIndicatorContainer);
}
//進行初始化
initTitlesAndIndicator();

} ```

看一下這裡的rootView是個什麼樣子: ```

<LinearLayout
    android:id="@+id/indicator_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal" />

<LinearLayout
    android:id="@+id/title_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal" />

``` 很意外,這裡就2個容器View,一個是標題的容器,一個是指示器的容器,

到這裡第二個疑問我們也知道了,整個佈局是由2個佈局容器形成的,標題和指示器分開。

2個容器.png

所以後面要把這2個容器做的和一個View一樣聯動就很關鍵,主要程式碼就是如何往這2個容器中新增View: private void initTitlesAndIndicator() { //這裡的NavigatorHelper就是一個輔助類,儲存一些資訊 for (int i = 0, j = mNavigatorHelper.getTotalCount(); i < j; i++) { IPagerTitleView v = mAdapter.getTitleView(getContext(), i); //這裡的程式碼就是拿到titleView然後挨個新增到線性佈局中,如果自適應佈局可以設定weight if (v instanceof View) { View view = (View) v; LinearLayout.LayoutParams lp; if (mAdjustMode) { lp = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT); lp.weight = mAdapter.getTitleWeight(getContext(), i); } else { lp = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT); } mTitleContainer.addView(view, lp); } } if (mAdapter != null) { //指示器就不一樣了,因為只有一個指示器,所以就直接新增到指示器容器中即可 mIndicator = mAdapter.getIndicator(getContext()); if (mIndicator instanceof View) { LayoutParams lp = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); mIndicatorContainer.addView((View) mIndicator, lp); } } }

既然新增View如此簡單,那複雜的邏輯肯定封裝在了具體TitleView中,下面來分析一波TitleView。

IPagerTitleView

這個就是所有titleView所繼承的介面,這裡就很關鍵,看一下介面: ``` public interface IPagerTitleView { /* * 被選中 / void onSelected(int index, int totalCount);

/**
 * 未被選中
 */
void onDeselected(int index, int totalCount);

/**
 * 離開
 *
 * @param leavePercent 離開的百分比, 0.0f - 1.0f
 * @param leftToRight  從左至右離開
 */
void onLeave(int index, int totalCount, float leavePercent, boolean leftToRight);

/**
 * 進入
 *
 * @param enterPercent 進入的百分比, 0.0f - 1.0f
 * @param leftToRight  從左至右離開
 */
void onEnter(int index, int totalCount, float enterPercent, boolean leftToRight);

} ``` 其中選中和未被選中很好理解,這裡有個離開是什麼作用呢,而且有百分比和方向之分,這裡就是為了做文字的特效而需要的,用來做動畫進度,這個很關鍵,下面來看個示例:

左右滑動特效.gif

會發現在ViewPager左右滑動時,第一排的文字會變大和縮小,同時有顏色漸變,這裡先不討論如何知道滑動進度,這裡就只要明白我知道了滑動進度即Percent和哪個是進入和離開就可以實現這個動畫,那方向呢 就是設計第二排的效果實現。

第二排中間那個TextView,當都是leave即離開狀態時,往左和往右是不一樣的,其中字型顏色變化一個從左邊一個從右邊,所以還需要知道方向。

到這裡我們對文字標題為啥要實現這幾個介面就大概知道了,然後看一下最普通的一個文字實現,首先是文字顏色變化,這個其實我之前的文章中已經說過很多次了: ``` public class ColorTransitionPagerTitleView extends SimplePagerTitleView {

public ColorTransitionPagerTitleView(Context context) {
    super(context);
}

@Override
public void onLeave(int index, int totalCount, float leavePercent, boolean leftToRight) {
    int color = ArgbEvaluatorHolder.eval(leavePercent, mSelectedColor, mNormalColor);
    setTextColor(color);
}

@Override
public void onEnter(int index, int totalCount, float enterPercent, boolean leftToRight) {
    int color = ArgbEvaluatorHolder.eval(enterPercent, mNormalColor, mSelectedColor);
    setTextColor(color);
}

@Override
public void onSelected(int index, int totalCount) {
}

@Override
public void onDeselected(int index, int totalCount) {
}

} 就是根據及進度計算2個顏色的差值,然後就是大小變化了: public class ScaleTransitionPagerTitleView extends ColorTransitionPagerTitleView { private float mMinScale = 0.75f;

public ScaleTransitionPagerTitleView(Context context) {
    super(context);
}

@Override
public void onEnter(int index, int totalCount, float enterPercent, boolean leftToRight) {
    super.onEnter(index, totalCount, enterPercent, leftToRight);    // 實現顏色漸變
    setScaleX(mMinScale + (1.0f - mMinScale) * enterPercent);
    setScaleY(mMinScale + (1.0f - mMinScale) * enterPercent);
}

@Override
public void onLeave(int index, int totalCount, float leavePercent, boolean leftToRight) {
    super.onLeave(index, totalCount, leavePercent, leftToRight);    // 實現顏色漸變
    setScaleX(1.0f + (mMinScale - 1.0f) * leavePercent);
    setScaleY(1.0f + (mMinScale - 1.0f) * leavePercent);
}

public float getMinScale() {
    return mMinScale;
}

public void setMinScale(float minScale) {
    mMinScale = minScale;
}

} ``` 這2種最簡單的動畫就不做過多敘述了,知道其中原理即可,關於複雜點的那個文字顏色左右變化,後面單獨再說。

到這裡,我們已經知道了標題如何排列,以及標題如何根據viewPager的切換而變化了,那接著看一下指示器。

IPagerIndicator

對於指示器會有點複雜,其實原因很簡單,標題是多個view挨個加到容器中,但是指示器就一個view,它要做到能隨著ViewPager滑動,並且滑動還有動畫,所以要考慮的東西會多一點,在這裡我們先不討論ViewPager滑動回撥,後面再細說,先看一下指示器的介面:

``` public interface IPagerIndicator { void onPageScrolled(int position, float positionOffset, int positionOffsetPixels);

void onPageSelected(int position);

void onPageScrollStateChanged(int state);

void onPositionDataProvide(List<PositionData> dataList);

} 其中前3個方法都好理解,也就是ViewPager切換的回撥,第4個方法記錄著標題位置,方便指示器來移動,看一下PositionData這個類: public class PositionData { //TextView的上、下、左、右4個點座標 public int mLeft; public int mTop; public int mRight; public int mBottom; //TextView的內容座標,也就是去除padding public int mContentLeft; public int mContentTop; public int mContentRight; public int mContentBottom; //TextView的整體寬度,因為有的指示器寬度是整個TextView寬度 public int width() { return mRight - mLeft; }

public int height() {
    return mBottom - mTop;
}
//內容寬度
public int contentWidth() {
    return mContentRight - mContentLeft;
}

public int contentHeight() {
    return mContentBottom - mContentTop;
}
//TextView的中心點位置,因為指示器要移動到這裡
public int horizontalCenter() {
    return mLeft + width() / 2;
}

public int verticalCenter() {
    return mTop + height() / 2;
}

} ``` 這裡為什麼要區分這些東西呢,原因很簡單,指示器的寬度是可以定義的,比如寬度和TextView內容一樣的,

指示器寬度1.jpg

寬度是TextView寬度的,

指示器寬度2.jpg 寬度是自定義很小的,

指示器3.jpg

所以有了上面的PositionData資料寬度問題就好解決了,接下來看一下如何移動指示器。

LinePagerIndicator

從名字來看,這個就是線指示器,指示器是一條線,根據前面的思路我們大概能猜出指示器是如何實現的,也就是根據ViewPager滑動的情況來控制指示器這個View的大小和位置即可。

程式碼如下: ``` //滑動回撥 @Override public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { if (mPositionDataList == null || mPositionDataList.isEmpty()) { return; } ...略 // 計算錨點位置 PositionData current = FragmentContainerHelper.getImitativePositionData(mPositionDataList, position); PositionData next = FragmentContainerHelper.getImitativePositionData(mPositionDataList, position + 1); //各種模式不同得到不同的指示器寬度 float leftX; float nextLeftX; float rightX; float nextRightX; if (mMode == MODE_MATCH_EDGE) { leftX = current.mLeft + mXOffset; nextLeftX = next.mLeft + mXOffset; rightX = current.mRight - mXOffset; nextRightX = next.mRight - mXOffset; } else if (mMode == MODE_WRAP_CONTENT) { leftX = current.mContentLeft + mXOffset; nextLeftX = next.mContentLeft + mXOffset; rightX = current.mContentRight - mXOffset; nextRightX = next.mContentRight - mXOffset; } else { // MODE_EXACTLY leftX = current.mLeft + (current.width() - mLineWidth) / 2; nextLeftX = next.mLeft + (next.width() - mLineWidth) / 2; rightX = current.mLeft + (current.width() + mLineWidth) / 2; nextRightX = next.mLeft + (next.width() + mLineWidth) / 2; } //線條指示器的4個頂點屬性 //加上動畫,可以產生更好看的效果 mLineRect.left = leftX + (nextLeftX - leftX) * mStartInterpolator.getInterpolation(positionOffset); mLineRect.right = rightX + (nextRightX - rightX) * mEndInterpolator.getInterpolation(positionOffset); mLineRect.top = getHeight() - mLineHeight - mYOffset; mLineRect.bottom = getHeight() - mYOffset;

invalidate();

} ``` 然後在onDraw()中重繪這個rect即可,就不多說了,其中關於動畫我們可以說道一下,其實動畫在之前的文章裡尤其是貝塞爾曲線中說的很仔細,這裡就是利用非線性插值器來達到更好看的效果,

非線性動畫.gif

這裡的動畫就不是預設的線性動畫,是非線性的, public IPagerIndicator getIndicator(Context context) { LinePagerIndicator indicator = new LinePagerIndicator(context); //加速動畫 indicator.setStartInterpolator(new AccelerateInterpolator()); //減速動畫 indicator.setEndInterpolator(new DecelerateInterpolator(1.6f)); indicator.setYOffset(UIUtil.dip2px(context, 39)); indicator.setLineHeight(UIUtil.dip2px(context, 1)); indicator.setColors(Color.parseColor("#f57c00")); return indicator; } 這裡減速動畫有個值是1.6,在之前貝塞爾曲線的QQ小紅點說過,有個網站可以除錯動畫就是:

http://inloop.github.io/interpolator/

,在這個網站,可以找到合適的factor,來繪製出更漂亮的動畫。

NavigatorHelper

在前面我們說過,我們在處理標題時,需要把ViewPager的回撥轉成特定的幾個,好判斷方向和進度,這個邏輯程式碼就是在NavigatorHelper中實現的,在說這個之前,我們來看一下ViewPager的3個回撥,這個回撥值有個很巧妙的地方: ``` viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {

@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
    Log.i("zyh", "onPageScrolled: position = " + position);
    Log.i("zyh", "onPageScrolled: positionOffset = " + positionOffset);
    Log.i("zyh", "onPageScrolled: positionOffsetPixels = " + positionOffsetPixels);
    magicIndicator.onPageScrolled(position, positionOffset, positionOffsetPixels);
}

@Override
public void onPageSelected(int position) {
    magicIndicator.onPageSelected(position);
    Log.i("zyh", "onPageSelected: position = " + position);
}

@Override
public void onPageScrollStateChanged(int state) {
    magicIndicator.onPageScrollStateChanged(state);
    Log.i("zyh", "onPageScrollStateChanged: state = " + state);
}

}); ``` 比如下面我從位置0滑動到1,這裡的onPageScrolled的回撥中的值變化是:

position: 0 -> 0 -> 0 .... -> 1

positionOffset: 0 -> 1

但是我從位置1滑動0,這裡的值變化是

position: 1 -> 0 -> 0 .... -> 0

positionOffset: 1 -> 0

所以我們會發現這個position始終是左邊那個tab的位置,記住這一點,很關鍵。

然後就是根據具體邏輯把上面3個回撥轉成下面4個回撥:

``` public interface OnNavigatorScrollListener { void onEnter(int index, int totalCount, float enterPercent, boolean leftToRight);

void onLeave(int index, int totalCount, float leavePercent, boolean leftToRight);

void onSelected(int index, int totalCount);

void onDeselected(int index, int totalCount);

} ``` 具體程式碼就不細說了,大家可以去原始碼看。

結尾

這篇文章只是介紹了其中的基本原理,很多稍微複雜點的動畫特效後面有用到再說,其實瞭解了原理後,其他的動畫實現起來也就不麻煩了。

總結以下幾點在我們平時使用中常用的地方:

  • ViewPager的滑動回撥方法,裡面是position位置永遠是左邊的頁面。

  • ViewPager的3個回撥方法在使用中需要轉換,改成更為方便使用的4個回撥,同時記錄方向。

  • 標題和指示器進行分離處理,分別放入2個容器,通過回撥進度進行聯動。

  • 動畫要會熟練使用,這個也是作為Android UI boy的基本素養。