1. 程式人生 > >打造Material Design風格的TabBar

打造Material Design風格的TabBar

自從Material Design問世以來, 各種Material Design風格的控制元件層出不窮, 尤其是google家的幾個APP更是將Material Design應用到了極致. 最近在使用google photos的時候發現這款軟體的Tabbar做的非常不錯, 內容突出, Material Design風味很濃, 再者, 我還沒有做過一個Material Design風格的Tabbar, 所以萌生了仿照一個google photos這種tabbar的念想, 今天我們就來一步步的去實現一下這種tabbar. 在開始之前, 我們先來看看效果咋樣:

仔細觀察效果,我們可以發現有一下幾個特點:

  1. 選中的條目會有一個變色的效果
  2. 選中的條目會有一個放大突出的效果
  3. 選中的時候條目的背景有一個波紋效果

我們再開始講解實現程式碼之前先來看看這樣的控制元件如何使用, 這樣再下面講解實現程式碼的時候才會更加清晰.

如何使用

如果你現在正在使用android studio, 那恭喜你, 可以使用一下compile語句引入MDTab.

compile 'org.loader:mdtab:1.0.0'

如果你還在使用eclipse的話, 有兩種選擇:
1. 更換android studio
2. 文章最後我會給出原始碼, 可以自行下載原始碼

該控制元件再使用方式上和普通的控制元件沒有什麼區別, 也是在佈局檔案中新增,

<org.loader.mdtab.MDTab
    android:id="@+id/tab"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="bottom"
    android:textSize="12sp"
    app:checked_color="#FF0000FF"
    app:checked_percent="130%"
app:normal_color="@android:color/black" app:ripple_color="#22448aca" android:background="@android:color/white" app:tab_padding="5dp" />

這裡有幾個屬性條目需要說明一下, 首先我們可以通過android:textSize屬性來指定預設字型的大小, 通過app:checked_percent屬性來指定選中的條目放大多少, 這裡指定為130%說明選中的時候字型是預設字型的1.3倍, app:normal_colorapp:checked_color這一組是指定顏色的,前者指定預設顏色, 後者指定選中的顏色, app:ripple_color是選中時那個波紋的顏色, 通過app:tab_padding來指定tabbar的上下邊距.

上面的程式碼我們並沒有指定條目的圖片和文字, 那應該就是在java檔案中寫了, 再來看看java檔案咋寫吧.

MDTab tab = (MDTab) findViewById(R.id.tab);
tab.setAdapter(new Adapter());
tab.itemChecked(0);
tab.setOnItemCheckedListener(new MDTab.OnItemCheckedListener() {
  @Override
  public void onItemChecked(int position, View view) {
    Toast.makeText(MainActivity.this, mMenus[position], Toast.LENGTH_SHORT).show();
  }
});

第二行程式碼我們設定了一個Adapter, 我們可以猜到資料肯定是在Adapter裡提供的, 第三行我們通過呼叫tab.itemChecked(0)來預設選中第一個條目, 第四行程式碼我們監聽了條目的選中事件, 這裡都很好理解, 我們再來看看Adapter如何實現.

class Adapter extends MDTab.TabAdapter {

  @Override
  public int getItemCount() {
    return mMenus.length;
  }

  @Override
  public Drawable getDrawable(int position) {
    int res = getResources().getIdentifier("icon_" + position, "drawable", getPackageName());
    return getResources().getDrawable(res);
  }

  @Override
  public CharSequence getText(int position) {
    return mMenus[position];
  }
}

這裡的Adapter要繼承MDTab的一個名叫TabAdapter的內部類, 有兩個方法我們需要說明一下, getDrawable方法我們要根據引數返回該條目對應的圖示, 如果不需要圖示我們可以返回null, getText方法是返回的條目的文字. ok, 簡單幾行程式碼我們就實現了上面的效果, 再知道如何使用後, 我們開始MDTab的實現.

波紋背景的實現

在上面的效果中我們發現, 每個條目在點選的時候會有一個波紋效果, 這裡我們為了向下相容, 並沒有使用Android預設的波紋效果, 而是完全自己實現了一個帶有波紋效果的控制元件, 我們就叫它RippleButton吧, 接下來我們就來看看這個RippleButton如何實現.

public class RippleButton extends TextView {

    private Paint mPaint; // 繪製波紋的畫筆

    private int mStepSize; // 波紋變化的步長
    private int mMinRadius = 0; // 波紋從多大開始變化
    private int mRadius; // 當前的波紋大小
    private int mMaxRadius; // 波紋最大大小
    private int mCenterX; // 該控制元件的中心位置
    private int mCenterY;
    private boolean isAnimating; // 是否正在動畫中

    private OnBeforeClickedListener mListener;

    public RippleButton(Context context) {
        this(context, null, 0);
    }

    public RippleButton(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public RippleButton(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        resolveAttrs(context, attrs, defStyle);
    }

    private void resolveAttrs(Context context, AttributeSet attrs, int defStyle) {

    }

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

    @Override
    public boolean onTouchEvent(MotionEvent event) {

    }

    @Override
    protected void onDraw(Canvas canvas) {

    }

    public void setOnBeforeClickedListener(OnBeforeClickedListener li) {
        mListener = li;
    }

    public interface OnBeforeClickedListener {
        void onBeforeClicked(View view);
    }
}

上面的程式碼是整個RippleButton的架子, 我們的實現思路是重寫onTouchEvent方法, 通過監聽down事件來引起重繪, 在重繪的過程中不斷改變波紋的半徑,並且將點選事件的響應推遲到波紋動畫完成後. 下面我們就開始根據這個思路來完善上面的程式碼.
首先是resolveAttrs方法, 這裡我們解析出xml中一些配置項, 雖然在整個MDTab中沒有讓RippleButton在xml中使用, 不過一個完善的控制元件必須要支援在xml中配置.

private void resolveAttrs(Context context, AttributeSet attrs, int defStyle) {
  TypedArray ta = context.getTheme().obtainStyledAttributes(attrs, R.styleable.Bar, defStyle, 0);
  mPaint.setColor(ta.getColor(R.styleable.Bar_ripple_color, Color.TRANSPARENT));
  ta.recycle();
}

只有一個屬性, 那就是波紋的顏色, 我們直接將它設定到繪製的畫筆上.
在上面的思路中, 我們提到了半徑, 所以我們還需要根據當前View的大小來算出波紋的最大半徑和波紋動畫的步長, 來看看程式碼:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
  super.onMeasure(widthMeasureSpec, heightMeasureSpec);
  mMaxRadius = Math.max(getMeasuredWidth(), getMeasuredHeight()) / 2;
  mCenterX = getMeasuredWidth() / 2;
  mCenterY = getMeasuredHeight() / 2;
  mStepSize = mMaxRadius / 20;
}

不用質疑, 這些大小肯定是在控制元件測量完畢後進行的, 波紋的最大半徑我們是取的寬度和長度的最大值, 同時, 這裡我們還初始化了該view的中心位置的座標.
下面, 我們就開始RippleButton的關鍵程式碼, 通過重寫onTouchEvent事件來取消掉預設的點選事件的回撥, 並且要這這裡引起重繪,

@Override
public boolean onTouchEvent(MotionEvent event) {
  if(event.getAction() == MotionEvent.ACTION_DOWN) {
    if(mListener != null) mListener.onBeforeClicked(this);
    mRadius = mMinRadius;
    isAnimating = true;
    postInvalidate();
  }
  return true;
}

在down事件下, 我們初始化了波紋當前半徑, 並且引起重繪, 這裡我們並沒有呼叫super.onTouchEvent方法, 所以不會引起點選事件的發生, 不過我們還是用過自定義的一個介面來回調了一下down事件的發生, 這裡的作用主要是為了保證選中時條目的變化不過發生在波紋動畫結束之後. 這裡我們引起重繪了, 下面理所當然我們要看看onDraw怎麼實現了.

@Override
protected void onDraw(Canvas canvas) {
  if(!isAnimating) {
    super.onDraw(canvas);
    return;
  }

  if(isAnimating && mRadius > mMaxRadius) {
    isAnimating = false;
    mRadius = mMinRadius;
    performClick();
    super.onDraw(canvas);
    return;
  }

  mRadius += mStepSize;
  canvas.drawCircle(mCenterX, mCenterY, mRadius, mPaint);
  super.onDraw(canvas);
  postInvalidate();
}

兩個if截斷了程式碼的正常流程, 第一個, 在沒有down事件的時候, 我們就讓他執行預設的繪製程式碼, 第二個, 在繪製的半徑大於最大半徑的時候,也就是波紋動畫需要結束了, 我們通過呼叫performClick()方法來響應點選事件. 正常的流程中, 我們會不斷的改變波紋的繪製半徑並通過程式碼canvas.drawCircle來繪製出波紋, 最後通過postInvalidate繼續重繪.

ok, 到現在, 一個帶有波紋背景的控制元件就完成了, 下面我們就來完成一下MDTab的程式碼, 在MDTab中我們會使用到上面的RippleButton.

MDTab的實現

上面的程式碼我們完成了tabbar條目的實現, 下面我們就開始著重實現一下MDTab了, 在開始之前, 我們先來羅列一下MDTab要實現的功能.

  1. TabAdapter的實現
  2. 選中條目文字放大
  3. 選中條目的圖片變色

下面我們就開始根據上面所提到的功能來一一實現它.

TabAdapter的實現

TabAdapter我們打算模仿BaseAdapter來實現一個符合觀察者的Adapter, 儘管這裡觀察者並沒有多大用處.

public abstract static class TabAdapter {
  private DataSetObserver mObserver;

  public void registerObserver(DataSetObserver observer) {
    mObserver = observer;
  }

  public void unregisterObserver() {
    mObserver = null;
  }

  public void notifyDataSetChanged() {
    if(mObserver != null) mObserver.onChanged();
  }

  public void notifyDataSetInvalidate() {
    if(mObserver != null) mObserver.onInvalidated();
  }

  public abstract int getItemCount();
  public abstract Drawable getDrawable(int position);
  public abstract CharSequence getText(int position);
}

這個抽象的TabAdapter除了我們前面介紹的幾個必須要實現的方法外, 還提供了對DataSetObserver的註冊功能, 這樣的一個Adapter的實現可以參考我的另外一篇部落格自己實現notifyDatasetChanged. 那這個DataSetObserver具體怎麼實現的呢? 我們繼續來看程式碼:

public void setAdapter(TabAdapter adapter) {
  mAdapter = adapter;
  mAdapter.registerObserver(mObserver);
  mAdapter.notifyDataSetChanged();
}

private DataSetObserver mObserver = new DataSetObserver() {
  @Override
  public void onChanged() {
    onInvalidated();
    if(mAdapter == null) return;
    int itemCount = mAdapter.getItemCount();T);
    params.weight = 1;

    for (int i = 0; i < itemCount; i++) {
      addView(buildRipple(i), params);
    }
  }

  @Override
  public void onInvalidated() {
    removeAllViews();
  }
}

我們是在setAdapter方法中為這個adapter設定的Observer, 這個mObserver成員變數實現了兩個方法,分別是onChangedonInvalidated, 後者很簡單, 我們只是簡單的將所有的view移出MDTab, 而onChanged方法中我們根據具體adapter為我們返回的個數來新增多個條目, 另外需要說明的一點是, 我們要實現的MDTab其實就是一個橫向的LinearLayout, 這樣就好理解了, 我們需要多少個條目, 這裡就會新增多少個子view, 而且他們的大小都是平分的, 大家也可以猜到, 這裡的子view肯定就是前面我們完成的RippleButton了, 我們再來看看buildRipple方法的具體實現吧.

private RippleButton buildRipple(final int pos) {
  RippleButton ripple = new RippleButton(getContext());
  ripple.setGravity(Gravity.CENTER);
  ripple.setRippleColor(mRippleColor);
  ripple.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize);
  ripple.setPadding(0, mTabPadding, 0, mTabPadding);

  ripple.setTextColor(mNormalItemColor);
  ripple.setText(mAdapter.getText(pos));

  ripple.setCompoundDrawablesWithIntrinsicBounds(null, mAdapter.getDrawable(pos),
      null, null);
  ripple.setOnBeforeClickedListener(new OnBeforeClickedListener() {
    @Override
    public void onBeforeClicked(View view) {
      if(mItemCheckedListener != null && pos != mCheckedPosition) {
        mItemCheckedListener.onItemChecked(pos, getChildAt(pos));
      }

      itemChecked(pos);
    }
  });

  return ripple;
}

buildRipple方法就是一個具體建立RippleButton的過程, 這裡我們就不再多講了, 唯一要點名的一點是我們提供的圖片是以drawableTop的形式展現的.

選中條目的效果處理

我們繼續功能的實現, 還有兩個問題我們沒有看到, 選中條目文字放大選中條目的圖片變色這兩個問題其實是在一個地方實現的, 下面我們就來觀察一下程式碼是如何解決這兩個問題的.

public void itemChecked(int pos) {
  mCheckedPosition = pos;
  int itemCount = getChildCount();
  RippleButton ripple;
  Drawable drawable;
  for (int i = 0; i < itemCount; i++) {
    ripple = (RippleButton) getChildAt(i);
    drawable = ripple.getCompoundDrawables()[1];
    ripple.cancel();
    if(i == mCheckedPosition) {
      ripple.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize * mCheckedSizePercent);
      ripple.setTextColor(mCheckedItemColor);
      if(drawable != null) {
        drawable.setColorFilter(new PorterDuffColorFilter(mCheckedItemColor,
            PorterDuff.Mode.SRC_IN));
      }
      continue;
    }

    ripple.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize);
    ripple.setTextColor(mNormalItemColor);
    if(drawable != null) {
      drawable.clearColorFilter();
    }
  }
}

在這個方法中我們去遍歷MDTab所有的子view, 如果是選中的位置, 那好, 呼叫ripple.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize * mCheckedSizePercent)來將字型放大我們希望的倍數, 呼叫ripple.setTextColor(mCheckedItemColor)來突出字型顏色, 同時我們還獲取到它的drawableTop, 並且使用setColorFilter方法來巧妙的改變了圖片的顏色. 而普通的條目我們要做的僅僅是將它恢復預設.

到現在為止, 我們已經實現了上面展示的效果了, 一個好看的Material Design風格的tabbar就算完成了.

滑動隱藏的實現

不過不要著急, 這樣就滿足了嗎?? 還能不能讓它逼格更好點? 在文章最開始我提到過, 這樣的一個效果的靈感是來自google photos, 不過我同時還看到了google G+的tabbar, 這樣有什麼特殊之處呢? 簡單描述一下就是 在內容可以滑動的情況下, 如果是往下滑動內容, 則tabbar隱藏, 如果是往上滑動, 則tabbar顯示. 這個設計太讚了! 我們往下滑動肯定是要看更多的內容, 讓tabbar隱藏可以看到更多的內容, 同時我們想要顯示tabbar的時候, 只需要稍稍往上滑動一下內容就可以, 在有限的螢幕空間內, 這樣實現可以說是將螢幕利用到了極致! 廢話不多說了, 我們先來看看這種效果到底長啥樣吧!

當看到這樣一個效果的時候, 大多數人可能會想到重寫ListView, 在事件處理的相關方法中來監聽我們手指滑動的方向吧. 不過這樣做真的是太不明智了, 有沒有考慮到ScrollView, RecyclerView呢? 難道要把所有的可滑動的控制元件都要重寫一遍? 這樣做也不現實, 還有什麼好的方式去實現這樣的一個效果呢? 趕緊將你的思路轉移到強大的CoordinatorLayout上吧, 它的Behavior機制完全可以輕鬆的讓我們實現這樣的一個效果, 具體Behavior如何自定義, 可以參考我的另外一篇部落格CoordinatorLayout高階用法-自定義Behavior.我們就參考我的這篇部落格來實戰到這裡. 我們定義了一個TabBehavior, 現在我們只需要給MDTab新增一條屬性app:layout_behavior="@string/tab_behavior"就可以實現上面的效果了, 不過現在這個MDTab必須要在CoordinatorLayout裡了, 上面的效果的佈局應該是這樣的.

<android.support.design.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal">

    <android.support.v4.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical">
            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:padding="50dp"
                android:text="loader" />
            <!-- 像這樣的TextView還有不少個-->
        </LinearLayout>

    </android.support.v4.widget.NestedScrollView>

    <org.loader.mdtab.MDTab
        android:id="@+id/tab"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom"
        android:textSize="12sp"
        app:checked_color="#FF0000FF"
        app:checked_percent="130%"
        app:layout_behavior="@string/tab_behavior"
        app:normal_color="@android:color/black"
        app:ripple_color="#22448aca"
        android:background="@android:color/white"
        app:tab_padding="5dp" />
</android.support.design.widget.CoordinatorLayout>

來看看這個TabBehavior具體怎麼寫的.

public class TabBehavior extends CoordinatorLayout.Behavior<View> {
    private TranslateAnimation mAnimation;

    public TabBehavior(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean onStartNestedScroll(CoordinatorLayout coordinatorLayout,
                                       View child, View directTargetChild,
                                       View target, int nestedScrollAxes) {
        return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
    }

    @Override
    public void onNestedPreScroll(CoordinatorLayout coordinatorLayout,
                                  View child, View target, int dx, int dy, int[] consumed) {
        super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed);
        if(dy > 0) {
            if(child.getVisibility() == View.GONE) return;
            startAnim(child, 0, child.getMeasuredHeight());
        } else {
            if(child.getVisibility() == View.VISIBLE) return;
            startAnim(child, child.getMeasuredHeight(), 0);
        }
    }

    private void startAnim(final View child, final int startY, int endY) {
        child.clearAnimation();
        mAnimation = new TranslateAnimation(0.f, 0.f, startY, endY);
        mAnimation.setDuration(500);
        mAnimation.setAnimationListener(new Animation.AnimationListener() {
            @Override
            public void onAnimationEnd(Animation animation) {
                if(startY == 0) child.setVisibility(View.GONE);
                else child.setVisibility(View.VISIBLE);
                mAnimation = null;
            }

            @Override
            public void onAnimationStart(Animation animation) {

            }

            @Override
            public void onAnimationRepeat(Animation animation) {

            }
        });

        child.startAnimation(mAnimation);
    }
}

程式碼也不多, 我們需要重寫onStartNestedScrollonNestedPreScroll方法, 前者我們需要指定我們是對上下滑動感興趣, 在後者中我們就需要根據滑動的方向來給使用了這個Behavior的view應用隱藏和顯示的動畫了, 具體如何判斷呢? 來看一下程式碼,

if(dy > 0) {
    if(child.getVisibility() == View.GONE) return;
    startAnim(child, 0, child.getMeasuredHeight());
} else {
    if(child.getVisibility() == View.VISIBLE) return;
    startAnim(child, child.getMeasuredHeight(), 0);
}

dy是onNestedPreScroll方法的一個引數, 我們通過判斷這個引數是不是大於0就可以知道現在滑動的方向了, 如果>0, 則是往下滑動, 我們需要將MDTab使用位移動畫慢慢隱藏掉, 相反, 則將它慢慢顯示出來, 具體的動畫程式碼就不多說了, 還是傳統的TranslateAnimation.

到現在為止, 一個綜合了google photos和google G+的tabbar風格的控制元件就出來了, 程式碼我放github上了, 如何喜歡就star一下吧. 下面是github的地址:
https://github.com/qibin0506/MDTab