打造Material Design风格的TabBar

Posted 亓斌

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了打造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

以上是关于打造Material Design风格的TabBar的主要内容,如果未能解决你的问题,请参考以下文章

打造极致Material Design动画风格Button

[Material Design] 打造简单朴实的CheckBox

开发Google Material Design风格的WPF程序

Material Design Components React 风格按钮

基于React Native的Material Design风格的组件库 MRN

如何在 WPF 中禁用 Material Design 风格