1. 程式人生 > >Android自定義ViewGroup打造各種風格的SlidingMenu

Android自定義ViewGroup打造各種風格的SlidingMenu

看鴻洋大大的QQ5.0側滑選單的視訊課程,對於側滑的時的動畫效果的實現有了新的認識,似乎打通了任督二脈,目前可以實現任意效果的側滑選單了,感謝鴻洋大大!!

鴻洋大大用的是HorizontalScrollView來實現的側滑選單功能,HorizontalScrollView的好處是為我們解決了滑動功能,處理了滑動衝突問題,讓我們使用起來非常方便,但是滑動和衝突處理都是android中的難點,是我們應該掌握的知識點,掌握了這些,我們可以不依賴於系統的API,隨心所欲打造我們想要的效果,因此這篇文章我將直接自定義ViewGroup來實現側滑選單功能

首先我們先來看一看效果圖,第一個效果圖是一個最普通的側滑選單,我們一會兒會先做出這種側滑選單,然後再在此基礎上實現另外兩個效果

第一種
這裡寫圖片描述

第二種
這裡寫圖片描述

第三種
這裡寫圖片描述

實現第一種側滑選單,繼承自ViewGroup

繼承自ViewGroup需要我們自己來測量,佈局,實現滑動的效果,處理滑動衝突,這些都是一些新手無從下手的知識點,希望看了這篇文章後可以對大家有一個幫助

自定義ViewGroup的一般思路是重寫onMeasure方法,在onMeasure方法中呼叫measureChild來測量子View,然後呼叫setMeasuredDimension來測量自己的大小。然後重寫onLayout方法,在onLayout中呼叫子View的layout方法來確定子View的位置,下面我們先來做好這兩件工作

這裡寫圖片描述

初始時候我們的Content應該是顯示在螢幕中的,而Menu應該是顯示在螢幕外的。當Menu開啟時,應該是這種樣子的
這裡寫圖片描述


mMenuRightPadding是Menu距螢幕右側的一個距離,因為我們Menu開啟後,Content還是會留一部分,而不是完全隱藏的

public class MySlidingMenu extends ViewGroup {
public MySlidingMenu(Context context) {
        this(context, null, 0);
    }

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

    public
MySlidingMenu(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); DisplayMetrics metrics = new DisplayMetrics(); WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); wm.getDefaultDisplay().getMetrics(metrics); //獲取螢幕的寬和高 mScreenWidth = metrics.widthPixels; mScreenHeight = metrics.heightPixels; //設定Menu距離螢幕右側的距離,convertToDp是將程式碼中的100轉換成100dp mMenuRightPadding = convertToDp(context,100); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { //拿到Menu,Menu是第0個孩子 mMenu = (ViewGroup) getChildAt(0); //拿到Content,Content是第1個孩子 mContent = (ViewGroup) getChildAt(1); //設定Menu的寬為螢幕的寬度減去Menu距離螢幕右側的距離 mMenuWidth = mMenu.getLayoutParams().width = mScreenWidth - mMenuRightPadding; //設定Content的寬為螢幕的寬度 mContentWidth = mContent.getLayoutParams().width = mScreenWidth; //測量Menu measureChild(mMenu,widthMeasureSpec,heightMeasureSpec); //測量Content measureChild(mContent, widthMeasureSpec, heightMeasureSpec); //測量自己,自己的寬度為Menu寬度加上Content寬度,高度為螢幕高度 setMeasuredDimension(mMenuWidth + mContentWidth, mScreenHeight); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { //擺放Menu的位置,根據上面圖可以確定上下左右的座標 mMenu.layout(-mMenuWidth, 0, 0, mScreenHeight); //擺放Content的位置 mContent.layout(0, 0, mScreenWidth, mScreenHeight); } /** * 將傳進來的數轉化為dp */ private int convertToDp(Context context , int num){ return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,num,context.getResources().getDisplayMetrics()); } }

目前我們的側滑選單中的兩個子View的位置應該是這個樣子
這裡寫圖片描述
接下來我們編寫xml佈局檔案

left_menu.xml 左側選單的佈局檔案,是一個ListView

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
   <ListView
       android:id="@+id/menu_listview"
       android:layout_width="wrap_content"
       android:divider="@null"
       android:dividerHeight="0dp"
       android:scrollbars="none"
       android:layout_height="wrap_content">
   </ListView>
</RelativeLayout>

其中ListView的Item佈局為left_menu_item.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal" android:layout_width="match_parent"
    android:gravity="center_vertical"
    android:layout_height="match_parent">
    <ImageView
        android:id="@+id/menu_imageview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/menu_1"
        android:padding="20dp"
        />
    <TextView
        android:id="@+id/menu_textview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="選單1"
        android:textColor="#000000"
        android:textSize="20sp"
        />
</LinearLayout>

我們再來編寫內容區域的佈局檔案 content.xml 其中有一個header,header中有一個ImageView,這個ImageView是menu的開關,我們點選他的時候可以自動開關menu,然後header下面也是一個listview

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical" android:layout_width="match_parent"
    android:layout_height="match_parent">
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="65dp"
        android:background="#000000"
        android:gravity="center_vertical"
        android:orientation="horizontal"
        >
        <ImageView
            android:id="@+id/menu_toggle"
            android:layout_width="40dp"
            android:layout_height="40dp"
            android:src="@drawable/toggle"
            android:paddingLeft="10dp"
            />
    </LinearLayout>
        <ListView
            android:id="@+id/content_listview"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:dividerHeight="0dp"
            android:divider="@null"
            android:scrollbars="none"
            />
</LinearLayout>

content的item的佈局檔案為 content_item.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal" android:layout_width="match_parent"
    android:gravity="center_vertical"
    android:background="#ffffff"
    android:layout_height="match_parent">
    <ImageView
        android:id="@+id/content_imageview"
        android:layout_width="80dp"
        android:layout_height="80dp"
        android:src="@drawable/content_1"
        android:layout_margin="20dp"
        />
    <TextView
        android:id="@+id/content_textview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Content - 1"
        android:textColor="#000000"
        android:textSize="20sp"/>


</LinearLayout>

在activity_main.xml中,我們將menu和content新增到我們的slidingMenu中

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#aaaaaa"
   >
<com.example.user.slidingmenu.MySlidingMenu
    android:id="@+id/slidingmenu"
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    >
        <include
            android:id="@+id/menu"
            layout="@layout/left_menu"
            />
        <include
            android:id="@+id/content"
            layout="@layout/content"
            />
</com.example.user.slidingmenu.MySlidingMenu>

</RelativeLayout>

現在應該是這種效果
這裡寫圖片描述
左側選單是隱藏在螢幕左側外部的,但是現在還不能滑動,如果想要實現滑動功能,我們可以使用View的scrollTo和scrollBy方法,這兩個方法的區別是scrollTo是直接將view移動到指定的位置,scrollBy是相對於當前的位置移動一個偏移量,所以我們應該重寫onTouchEvent方法,用來計算出當前手指的一個偏移量,然後使用scrollBy方法一點一點的移動,就形成了一個可以跟隨手指移動的view的動畫效果了

在寫程式碼之前,我們先掃清一下障礙,我們先來弄清楚這些座標是怎麼回事

這裡寫圖片描述
這裡寫圖片描述
這裡寫圖片描述

好了,把這些座標弄清楚後,我們就簡單多了,下面直接看onTouchEvent方法

@Override
    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getAction();
        switch (action){
            case MotionEvent.ACTION_DOWN:
                mLastX = (int) event.getX();
                mLastY = (int) event.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                int currentX = (int) event.getX();
                int currentY = (int) event.getY();
                //拿到x方向的偏移量
                int dx = currentX - mLastX;
                if (dx < 0){//向左滑動
                    //邊界控制,如果Menu已經完全顯示,再滑動的話
                    //Menu左側就會出現白邊了,進行邊界控制
                    if (getScrollX() + Math.abs(dx) >= 0) {
                        //直接移動到(0,0)位置,不會出現白邊
                        scrollTo(0, 0);

                    } else {//Menu沒有完全顯示呢
                        //其實這裡dx還是-dx,大家不用刻意去記
                        //大家可以先使用dx,然後執行一下,發現
                        //移動的方向是相反的,那麼果斷這裡加個負號就可以了
                        scrollBy(-dx, 0);

                    }

                }else{//向右滑動
                    //邊界控制,如果Content已經完全顯示,再滑動的話
                    //Content右側就會出現白邊了,進行邊界控制
                    if (getScrollX() - dx <= -mMenuWidth) {
                        //直接移動到(-mMenuWidth,0)位置,不會出現白邊
                        scrollTo(-mMenuWidth, 0);

                    } else {//Content沒有完全顯示呢
                        //根據手指移動
                        scrollBy(-dx, 0);

                    }

                }
                mLastX = currentX;
                mLastY = currentY;

                break;


        }
        return true;
    }

現在我們的SlidingMenu依然是不能夠水平滑動的,但是listview可以豎直滑動,原因是我們的SlidingMenu預設是不攔截事件的,那麼事件會傳遞給他的子View去執行,也就是說傳遞給了Content的ListView去執行了,所以listview是可以滑動的,為了簡單,我們先重寫onInterceptTouchEvent方法,我們返回true,讓SlidingMenu攔截事件,我們的SlidingMenu就能夠滑動了,但是ListView是不能滑動的,等下我們會進行滑動衝突的處理,現在先實現SlidingMenu的功能

@Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return true;
    }

好了,現在我們可以自由的滑動我們的SlidingMenu了,並且進行了很好的邊界控制,現在我們再添加個功能,就是當Menu開啟大於二分之一時,鬆開手指,Menu自動開啟。當Menu開啟小於二分之一時,鬆開手指,Menu自動關閉。自動滑動的功能我們要藉助Scroller來實現

我們在構造方法中初始化一個Scroller

public MySlidingMenu(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        ...
        mScroller = new Scroller(context);
        ...
    }

然後重寫computeScroll方法,這個方法是保證Scroller自動滑動的必須方法,這是一個模板方法,到哪裡都這麼些就好了

 @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()){
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            invalidate();
        }
    }

接著我們在onTouchEvent的ACTION_UP中進行判斷,判斷當前menu打開了多少

  case MotionEvent.ACTION_UP:
                if (getScrollX() < -mMenuWidth / 2){//開啟Menu
                    //呼叫startScroll方法,第一個引數是起始X座標,第二個引數
                    //是起始Y座標,第三個引數是X方向偏移量,第四個引數是Y方向偏移量
                    mScroller.startScroll(getScrollX(), 0, -mMenuWidth - getScrollX(), 0, 300);
                    //設定一個已經開啟的標識,當實現點選開關自動開啟關閉功能時會用到
                    isOpen = true;
                    //一定不要忘了呼叫這個方法重繪,否則沒有動畫效果
                    invalidate();
                }else{//關閉Menu
                    //同上
                    mScroller.startScroll(getScrollX(), 0, -getScrollX(), 0, 300);
                    isOpen = false;
                    invalidate();
                }

                break;

關於startScroll中的startX和startY好判斷,那麼dx和dy怎麼計算呢?其實也非常簡單,比如我們startX座標為30,我們想移動到-100,那麼startX+dx = -100 –> dx = -100 - startX –> dx = -130

好了現在我們就可以實現鬆開手指後自動滑動的動畫效果了
現在我們還需要點選content中左上角的一個三角,如果當前menu沒有開啟,則自動開啟,如果已經開啟,則自動關閉的功能,自動滑動的效果我們要藉助Scroller.startScroll方法

/**
     * 點選開關,開閉Menu,如果當前menu已經開啟,則關閉,如果當前menu已經關閉,則開啟
     */
    public void toggleMenu(){
        if (isOpen){
            closeMenu();
        }else{
            openMenu();
        }
    }

    /**
     * 關閉menu
     */
    private void closeMenu() {
        //也是使用startScroll方法,dx和dy的計算方法一樣
        mScroller.startScroll(getScrollX(),0,-getScrollX(),0,500);
        invalidate();
        isOpen = false;
    }

    /**
     * 開啟menu
     */
    private void openMenu() {
        mScroller.startScroll(getScrollX(),0,-mMenuWidth-getScrollX(),0,500);
        invalidate();
        isOpen = true;
    }

然後我們可以在MainActivity中拿到我們content左上角三角形的imageview,然後給他設定一個點選事件,呼叫我們的toggleMenu方法

mMenuToggle.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mSlidingMenu.toggleMenu();
            }
        });

處理滑動衝突

由於我們的menu和content是listview,listview是支援豎直滑動的,而我們的slidingMenu是支援水平滑動的,因此會出現滑動的衝突。剛才我們直接在onInterceptTouchEvent中返回了true,因此SlidingMenu就會攔截所有的事件,而ListView接收不到任何的事件,因此ListView不能滑動了,我們要解決這個滑動衝突很簡單,只需要判斷當前是水平滑動還是豎直滑動,如果是水平滑動的話則讓SlidingMenu攔截事件,如果是豎直滑動的話就不攔截事件,把事件交給子View的ListView去執行

@Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercept = false;
        int x = (int) ev.getX();
        int y = (int) ev.getY();
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:
                intercept = false;
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = (int) ev.getX() - mLastXIntercept;
                int deltaY = (int) ev.getY() - mLastYIntercept;
                if (Math.abs(deltaX) > Math.abs(deltaY)){//橫向滑動
                    intercept = true;
                }else{//縱向滑動
                    intercept = false;
                }
                break;
            case MotionEvent.ACTION_UP:
                intercept = false;
                break;
        }
        mLastX = x;
        mLastY = y;
        mLastXIntercept = x;
        mLastYIntercept = y;
        return intercept;
    }

好了,現在我們的滑動衝突就解決了,我們既可以水平滑動SlidingMenu,又可以豎直滑動ListView,那麼第一種SlidingMenu就已經實現了,我們再來看看另外兩種怎麼去實現

實現第二種QQ V6.2.3風格的SlidingMenu

這裡寫圖片描述
這種SlidingMenu是和QQ v6.2.3 的側滑選單風格一致的,我們發現Menu和Content的滑動速度是有一個速度差的,實際上我們可以通過修改Menu的偏移量來達到這種效果
這裡寫圖片描述
此時Menu的偏移量為mMenuWidth的2/3,當我們慢慢開啟Menu的同時,修改Menu的偏移量,最終修改為0
這裡寫圖片描述
這樣就達到了一種速度差的效果,我們只需要在onTouchEvent的ACTION_MOVE和computeScroll中新增一行如下程式碼就可以

mMenu.setTranslationX(2*(mMenuWidth+getScrollX())/3);

我們分析一下,在最開始,mMenuWidth+getScrollX=mMenuWidth,再乘以2/3,得到的就是mMenuWidth的2/3 , 當我們滑動至Menu完全開啟時,mMenuWidth+getScrollX=0 , 這就達到了我們的效果

為什麼要在computeScroll中也新增這一行程式碼呢,因為當我們滑動過程中,如果我們手指離開螢幕,ACTION_MOVE肯定就不執行了,但是當我們手指離開屏幕後,會有一段自動開啟或者關閉的動畫,那麼這段動畫應該繼續去設定Menu的偏移量,因此我們在computeScroll中也要新增這一行程式碼。

好了,效果我們已經實現了,只需要去設定Menu的偏移量就可以了,是不是非常簡單

實現第三種QQ V5.0風格的SlidingMenu

這裡寫圖片描述
這個效果中Menu有一個偏移的效果,透明度的變化以及放大的效果。Content中有一個縮小的效果。
首先我們要有一個變數,用來記錄當前menu已經打開了多少百分比。
這裡寫圖片描述
這裡寫圖片描述

這裡我們要注意,getScrollX得到的數值正好是負值,所以我們計算的時候要將getScrollX的值取絕對值再去計算,我們在onTouchEvent的MOVE中要計算這個值,同時在computeScroll方法中也要計算這個值,因為當我們手指擡起時,可能會執行一段自動開啟或者關閉的動畫,那麼我們在MOVE中的計算肯定停止了,但是在執行動畫的過程中,是Scroller在起作用,那麼computeScroll就會執行直到動畫結束,因此我們要在computeScroll中同樣進行計算

scale = Math.abs((float)getScrollX()) / (float) mMenuWidth;

scale的值是[0,1]的,因此我們就可以根據這個值來對menu的偏移量進行設定。
我們可以通過設定View的setScaleX和setScaleY來對View進行放大縮小,當然這個縮放比例要根據我們的scale值來改變,首先我們的Menu有一個放大的效果,我們就指定為Menu從0.7放大到1.0,那麼我們就可以這樣寫

mMenu.setScaleX(0.7f + 0.3f*scale);
        mMenu.setScaleY(0.7f + 0.3f*scale);

透明度是從0到1的,所以我們直接用scale的值就可以了

        mMenu.setAlpha(scale);

我還給Menu設定了一個偏移量,這個偏移量大家可以自己計算,我是這樣計算的

mMenu.setTranslationX(mMenuWidth + getScrollX() - (mMenuWidth/2)*(1.0f-scale));

設定完Menu後,我們再來設定Content,Content的大小是從1.0縮小到0.7,因此我們這樣寫

mContent.setScaleX(1 - 0.3f*scale);
        mContent.setPivotX(0);
        mContent.setScaleY(1.0f - 0.3f * scale);

其中mContent.setPivotX(0)是讓Content的縮放中心店的X軸座標為0點

我們可以將這個變化的過程抽取為一個方法

private void slidingMode3(){
        mMenu.setTranslationX(mMenuWidth + getScrollX() - (mMenuWidth/2)*(1.0f-scale));
        mMenu.setScaleX(0.7f + 0.3f*scale);
        mMenu.setScaleY(0.7f + 0.3f*scale);
        mMenu.setAlpha(scale);

        mContent.setScaleX(1 - 0.3f*scale);
        mContent.setPivotX(0);
        mContent.setScaleY(1.0f - 0.3f * scale);
    }

將這個方法新增到onTouchEvent的ACTION_MOVE和computeScroll中就可以了。

我們看到所有的滑動風格都是在基於第一種基礎上,修改Menu或者Content的translationX或者scaleX scaleY的值來決定的,因此我們可以打造各種各樣的SlidingMenu來。

完整程式碼

完整程式碼大家可以到我的GitHub中下載