Android 酷炫自定義 View:高仿 QQ 窗簾菜單

之前寫過一篇自定義View的文章,最近搬出來統一放在簡書上。

介紹

不知道大家是否有印象,QQ 曾經有個版本用到了一種雙向側拉菜單,就像窗簾一樣可以兩邊開合,並且伴有 3D 旋轉效果,效果非常酷炫,吸引很多人模仿實現。

Android 系統提供了一個側拉抽屜控件,叫 DrawerLayout,使用過的人都知道,效果不錯並且有一定拓展性,基於 DrawerLayout 我們可以實現 QQ 的效果,但是今天我們要介紹的是另一個思路:自定義 HorizontalScrollView。

這個思路非常簡單,並且你可以很方便地拓展出任何你想要的效果,說不定做的比 QQ 更酷炫哦。

效果

首先來看下最終實現的效果(gif 版)

2D 模式
3D 模式

自定義 View 基礎

Android 自定義 View 是一個很大的主題,一篇文章肯定是講不完的,GcsSloop 的自定義 View 系列文章寫了十幾篇都不能做到面面俱到,所以今天這篇文章我們就從一個小案例入手,講講如何實現雙向側拉菜單。

大家都知道 Android 自定義 View 分爲兩大類,一是自定義 View,二是自定義 ViewGroup,這篇文章要講的顯然是自定義 ViewGroup。

自定義 View 和 ViewGroup 的區別就是 ViewGroup 除了負責自身的顯示效果外,裏面還要包含其它的子 View,這必然帶來複雜性增加,表現在代碼裏就是自定義 View 通常只需要複寫 onDraw 和 onTouch,而自定義 ViewGroup 還要考慮子 View 的測量、子 View 的佈局、子 View 的事件分發等等,涉及到的方法了 onMeasureonLayoutdispatchTouchEventonInterceptTouchEvent等。

其中事件分發是一個重點,而在自定義 View 種很重要的 onDraw 反而不是最重要的。

// 自定義View
class CustomView extends View {
    構造方法();
    onDraw();
    onTouch();
}

// 自定義ViewGroup
class CustomViewGroup extends <T instanceOf ViewGroup> {
    構造方法();
    onDraw();
    onTouch();
    onMeasure();
    onLayout();
    dispatchTouchEvent();
    onInterceptTouchEvent();
}

當然自定義 View 和自定義 ViewGroup 也有很多共通的,比如自定義屬性,繪製函數等。那我們閒言少敘,開始動手實現吧。

實現思路

我們看上面的效果挺酷炫的,感覺無從下手,但是仔細觀察你會發現,其實整個界面分爲三部分:左邊菜單、中間主佈局、右邊菜單。它們的位置關係是從左到右依次排列。再仔細觀察菜單的切換你會發現,忽略縮放、透明度等動畫,其實菜單切換的過程就是三部分滾動的過程,於是,我們就有了一個大體的思路:

用一個 HorizontalScrollView 包裹三個部分的試圖,通過控制 HorizontalScrollView 的滾動距離來實現展示不同的部分。 (如下圖)

當然,這只是一個思路,距離最終效果還差一些,我們基於這個思路,要解決以下幾個問題:

(1)初始的時候要展示中間主佈局。
(2)左右菜單區域的寬度要客配置。
(3)鬆手後,不能停在菜單的一半處,要能自動收起或打開菜單。
(4)左右菜單要是可配置的,因爲用戶可能只需要左側菜單或者只需要右側菜單。
(5)複雜的事件分發。
(6)菜單切換時的 3D 效果。

自定義 HorizontalScrollView

有了思路,我們就有了方向,廢話不多說,開始擼代碼。

(1)首先新建一個類,集成自 HorizontalScrollView
public class CurtainsLayout extends HorizontalScrollView {

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

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

    public CurtainsLayout(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    @Override
    protected void onFinishInflate() {

    }

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

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        super.onLayout(changed, l, t, r, b);
    }

    @Override
    protected void onScrollChanged(int l, int t, int oldl, int oldt) {
        super.onScrollChanged(l, t, oldl, oldt);
    }

    @Override
    public boolean onTouchEvent(MotionEvent e) {
        return super.onTouchEvent(e);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent e) {
        return super.onInterceptTouchEvent(e);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent e) {
        return super.dispatchTouchEvent(e);
    }
}

架子出來了,現在往架子裏填內容,先來獲取 3 個子 View。

(2)獲取子 View

通過上面的分析我們知道一共有三個子 View:左側菜單、中間主體、右側菜單,但是這三個子 View 不一定全有,如果用戶只配置了左側菜單,那右側菜單子 View 就不存在。

if (左右菜單都有) {
    第0個子View是左側菜單
    第1個子View是中間主體
    第2個子View是右側菜單
} else if (只有左側菜單) {
    第0個子View是左側菜單
    第1個子View是中間主體
} else if (只有右側菜單) {
    第0個子View是中間主體
    第1個子View是中間主體
}

首先我們要定義三種菜單類型常量,代表上面三種菜單類型:

public static final int MENU_TYPE_LEFT = 1;
public static final int MENU_TYPE_RIGHT = 1 << 1;
public static final int MENU_TYPE_BOTH = MENU_TYPE_LEFT | MENU_TYPE_RIGHT ;

然後根據菜單類型獲取子 View:

@Override
protected void onFinishInflate() {
    super.onFinishInflate();
    LinearLayout wrapper = (LinearLayout) getChildAt(0);
    if ((mMenuType & MENU_TYPE_LEFT) == MENU_TYPE_LEFT
            && (mMenuType & MENU_TYPE_RIGHT) == MENU_TYPE_RIGHT) {
        mLeftMenu = (ViewGroup) wrapper.getChildAt(0);
        mContent = (ViewGroup) wrapper.getChildAt(1);
        mRightMenu = (ViewGroup) wrapper.getChildAt(2);
    } else if ((mMenuType & MENU_TYPE_LEFT) == MENU_TYPE_LEFT) {
        mLeftMenu = (ViewGroup) wrapper.getChildAt(0);
        mContent = (ViewGroup) wrapper.getChildAt(1);
    } else {
        mContent = (ViewGroup) wrapper.getChildAt(0);
        mRightMenu = (ViewGroup) wrapper.getChildAt(1);
    }
}

(3)菜單寬度

獲取到了三個子 View,下面就要設置子 View 的寬度。中間主體的寬度是屏幕寬度,這個沒啥好說的。左右菜單的寬度是要窄一點的。

我們是這樣定義的:左側菜單是主菜單,顯示的內容比較多,所有左側菜單寬度我們是用屏幕寬度 - 右側邊距,而右側菜單是次菜單,就顯示一個按鈕。所以右側按鈕寬度就由用戶直接指定。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    if ((mMenuType & MENU_TYPE_LEFT) == MENU_TYPE_LEFT
            && (mMenuType & MENU_TYPE_RIGHT) == MENU_TYPE_RIGHT) {
        // mLeftMenuRightPadding是由用戶配置的
        mLeftMenuWidth = mScreenWidth - mLeftMenuRightPadding;
        mLeftMenu.getLayoutParams().width = mLeftMenuWidth;
        mRightMenuWidth = mRightMenu.getMeasuredWidth();
    } else if ((mMenuType & MENU_TYPE_LEFT) == MENU_TYPE_LEFT) {
        mLeftMenuWidth = mScreenWidth - mLeftMenuRightPadding;
        mLeftMenu.getLayoutParams().width = mLeftMenuWidth;
    } else {
        mRightMenuWidth = mRightMenu.getMeasuredWidth();
    }

    mContentWidth = mScreenWidth;
    mContent.getLayoutParams().width = mContentWidth;
    mContentHeight = mContent.getMeasuredHeight();
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

(4)初始展示中間主體佈局

這個就很簡單了,HorizontalScrollView 默認的滾動位置是 0,所以就會展示左側菜單,我們只要把滾動位置設置到左側菜單寬度就行。

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    super.onLayout(changed, l, t, r, b);
    if (changed) {
          if ((mMenuType & MENU_TYPE_LEFT) == MENU_TYPE_LEFT) {
               this.scrollTo(mLeftMenuWidth, 0);
           } else {
               this.scrollTo(0, 0);
           }
    }
}

(5)自動回彈

下面就是重點了,很重很重的點。我們在滾動時,鬆手後應該能自動根據當前滾動位置關閉或者打開菜單。通常就是以菜單的一半作爲分界線。

if(滾動距離 < 左側菜單寬度一半) {
    打開左側菜單
} else if(滾動距離 >= 左側菜單寬度一半) {
    關閉左側菜單
} else if(滾動距離 < 左側菜單寬度 + 右側菜單寬度一半) {
    關閉右側菜單
} else if(滾動距離 >= 左側菜單寬度 + 右側菜單寬度一半) {
    打開右側菜單
}

上面這段邏輯如果不明白的可以多看幾遍,明白這個邏輯後才能看下面的代碼實現。

@Override
public boolean onTouchEvent(MotionEvent e) {
    switch (e.getAction()) {
        case MotionEvent.ACTION_UP:
            int scrollX = getScrollX();
            if ((mMenuType & MENU_TYPE_LEFT) == MENU_TYPE_LEFT
                    && (mMenuType & MENU_TYPE_RIGHT) == MENU_TYPE_RIGHT) {
                if (scrollX <= mLeftMenuWidth / 2) {
                    this.smoothScrollTo(0, 0);
                    isOpen = true;
                    mState = STATE_OPEN_LEFT;
                } else if (scrollX > mLeftMenuWidth / 2 && scrollX <= mLeftMenuWidth){
                    this.smoothScrollTo(mLeftMenuWidth, 0);
                    isOpen = false;
                    mState = STATE_CLOSE;
                } else if (scrollX > mLeftMenuWidth && scrollX <= mLeftMenuWidth + mRightMenuWidth / 2) {
                    this.smoothScrollTo(mLeftMenuWidth, 0);
                    isOpen = false;
                    mState = STATE_CLOSE;
                } else {
                    this.smoothScrollTo(mLeftMenuWidth + mRightMenuWidth, 0);
                    isOpen = true;
                    mState = STATE_OPEN_RIGHT;
                }
            } else if ((mMenuType & MENU_TYPE_LEFT) == MENU_TYPE_LEFT) {
                if (scrollX > mLeftMenuWidth / 2) {
                    this.smoothScrollTo(mLeftMenuWidth, 0);
                    isOpen = false;
                    mState = STATE_CLOSE;
                } else {
                    this.smoothScrollTo(0, 0);
                    isOpen = true;
                    mState = STATE_OPEN_LEFT;
                }
            } else {
                if (scrollX > mRightMenuWidth / 2) {
                    this.smoothScrollTo(mRightMenuWidth, 0);
                    isOpen = true;
                    mState = STATE_OPEN_RIGHT;
                } else {
                    this.smoothScrollTo(0, 0);
                    isOpen = false;
                    mState = STATE_CLOSE;
                }
            }
            return true;
        }
    return super.onTouchEvent(e);
}

霍,怎麼這麼多代碼?原因是我們要考慮三種菜單類型,每種類型關閉菜單的滾動距離是不一樣的。所以實現起來要分開考慮,代碼自然就多了。

(6)事件分發

啊,終究逃不過這一關,自定義 ViewGroup 是一定要面對事件分發的。

我們的預期是這樣的:

a、當菜單關閉(左右菜單都關閉,中間主體全屏展示)的時候,不攔截事件,用戶可以點擊頁面元素,滑動列表。

b、當菜單打開(左右菜單都一樣)的時候,點擊中間主體區域時攔截事件,點擊其它地方不攔截事件。也就是說當菜單打開時,主體區域的頁面元素不可點擊,列表也不可滑動,但是菜單區域的元素可以點擊。

這裏需要兩個判斷條件:菜單是否打開、是否點擊在中間主體區域。

菜單是否打開很簡單,我們設置一個變量 isOpen,每次打開菜單置爲 true,關閉菜單置爲 false。

是否點擊在中間主體區域稍微複雜一點,我們首先要獲取手指點擊相對於屏幕的座標值。

int rawX = (int)e.getRawX();
int rawY = (int)e.getRawY();

然後我們要獲取中間主體 View 所佔的區域:

int[] location = new  int[2] ;
mContent.getLocationOnScreen(location);
int left = location[0];
int top = location[1];
int right = left + (int)(mContentWidth * SCALE_CONTENT);
int bottom = top + (int)(mContentHeight * SCALE_CONTENT);
Rect rect = new Rect(left, top, right, bottom);

這裏爲什麼要乘以一個 SCALE_CONTENT 呢?這個值是主體區域在動畫過程中的縮放比例,乘以這個縮放比例就可以得到縮放後的寬高。

有了這兩步,判斷是否點擊在中間主體區域就很簡單了

rect.contains(rawX, rawY);

完整代碼:

@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
    switch (e.getAction()) {
        case MotionEvent.ACTION_DOWN:
            if (!isOpen) {
                return super.onInterceptTouchEvent(e);
            }
            int rawX = (int)e.getRawX();
            int rawY = (int)e.getRawY();
            if (mFingerPoint == null) {
                mFingerPoint = new Point(rawX, rawY);
            } else {
                mFingerPoint.set(rawX, rawY);
            }
            int[] location = new  int[2] ;
            mContent.getLocationOnScreen(location);
            int left = location[0];
            int top = location[1];
            int right = left + (int)(mContentWidth * SCALE_CONTENT);
            int bottom = top + (int)(mContentHeight * SCALE_CONTENT);
            Rect rect = new Rect(left, top, right, bottom);
            mTapContains = rect.contains(rawX, rawY);
            return mTapContains || super.onInterceptTouchEvent(e);
    }
    return super.onInterceptTouchEvent(e);
}

(7)3D 動畫

這個菜單的效果全靠這個動畫撐起來的,看似複雜,其實動畫是最簡單的。

我們根據左右菜單拉出的百分比計算各個 View 的平移、縮放、alpha 動畫值,如圖在 3D 模式下,再加上一個旋轉。旋轉我們只針對左側菜單和中間主體,右側菜單不旋轉。

@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
    super.onScrollChanged(l, t, oldl, oldt);
    if ((mMenuType & MENU_TYPE_LEFT) == MENU_TYPE_LEFT
            && (mMenuType & MENU_TYPE_RIGHT) == MENU_TYPE_RIGHT) {
        if (l <= mLeftMenuWidth) {
            float openPercent = 1.0f - l * 1.0f / mLeftMenuWidth;
            animLeft(openPercent);
        } else {
            float openPercent = (l - mLeftMenuWidth) * 1.0f / mRightMenuWidth;
            animRight(openPercent);
        }
    } else if ((mMenuType & MENU_TYPE_LEFT) == MENU_TYPE_LEFT) {
        float openPercent = 1.0f - l * 1.0f / mLeftMenuWidth;
        animLeft(openPercent);
    } else {
        float openPercent = l * 1.0f / mRightMenuWidth;
        animRight(openPercent);
    }
}

private void animLeft(float openPercent) {
    if(openPercent < 0) {
        openPercent = 0;
    }
    if (openPercent > 1) {
        openPercent = 1;
    }

    float menuScale = SCALE_LEFT_MENU + (1 - SCALE_LEFT_MENU) * openPercent;
    float contentScale = 1 - openPercent * (1 - SCALE_CONTENT);

    mLeftMenu.setScaleX(menuScale);
    mLeftMenu.setScaleY(menuScale);
    mLeftMenu.setAlpha(openPercent);
    mLeftMenu.setTranslationX(mLeftMenuWidth * (1 - openPercent) * TRANS_LEFT_MENU);
    if (mWith3D) {
        mLeftMenu.setRotationY((1 - openPercent) * -mMenuRotate);
    } else if (mLeftMenu.getRotationY() != 0) {
        mLeftMenu.setRotationY(0);
    }

    mContent.setPivotX(0);
    mContent.setPivotY(mContent.getHeight() / 2);
    mContent.setScaleX(contentScale);
    mContent.setScaleY(contentScale);
    if (mWith3D) {
        mContent.setRotationY(openPercent * mContentRotate);
    } else if (mContent.getRotationY() != 0) {
        mContent.setRotationY(0);
    }

    if (mCurtainsListener != null) {
        mCurtainsListener.onLeftOpen(openPercent);
    }
}

private void animRight(float openPercent) {
    if (openPercent < 0) {
        openPercent = 0;
    }
    if (openPercent > 1) {
        openPercent = 1;
    }

    float menuScale = SCALE_RIGHT_MENU + (1 - SCALE_RIGHT_MENU) * openPercent;
    float contentScale = 1 - openPercent * (1 - SCALE_CONTENT);

    mRightMenu.setScaleX(menuScale);
    mRightMenu.setScaleY(menuScale);
    mRightMenu.setAlpha(openPercent);
    mRightMenu.setTranslationX(-1 * mRightMenuWidth * (1 - openPercent) * TRANS_RIGHT_MENU);

    mContent.setPivotX(mContentWidth);
    mContent.setPivotY(mContent.getHeight() / 2);
    mContent.setScaleX(contentScale);
    mContent.setScaleY(contentScale);

    if (mCurtainsListener != null) {
        mCurtainsListener.onRightOpen(openPercent);
    }
}

自定義屬性

好了,整個窗簾菜單基本已經實現了,但是要完善一下自定義屬性,方便用戶配置。

// attr.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    // 左側菜單的右邊距
    <attr name="rightPadding" format="dimension" />
    // 菜單類型
    <attr name="menuType" format="enum">
        <enum name="leftMenu" value="0x1" />
        <enum name="rightMenu" value="0x2" />
        <enum name="doubleMenu" value="0x3" />
    </attr>
    // 是否打開3D模式
    <attr name="with3D" format="boolean" />
    // 3D模式下菜單的旋轉角度
    <attr name="menuRotate" format="integer" />
    // 3D模式下內容區域的旋轉角度
    <attr name="contentRotate" format="integer" />

    <declare-styleable name="CurtainsLayout">
        <attr name="rightPadding" />
        <attr name="menuType" />
        <attr name="with3D" />
        <attr name="menuRotate" />
        <attr name="contentRotate" />
    </declare-styleable>
</resources>

使用

自定義封裝好了,當然就要給別人用啦,使用很簡單。

<com.makeunion.curtainslayout.CurtainsLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:curtains="http://schemas.android.com/apk/res-auto"
    android:id="@+id/id_menu"
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    android:background="@drawable/menu_bg"
    android:overScrollMode="never"
    android:scrollbars="none"
    curtains:rightPadding="100dp"
    curtains:menuType="doubleMenu"
    curtains:with3D="true"
    curtains:contentRotate="15"
    curtains:menuRotate="20">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="horizontal" >

        <include layout="@layout/layout_left_menu" />

        <include layout="@layout/layout_content" />

        <include layout="@layout/layout_right_menu" />
    </LinearLayout>
</com.makeunion.curtainslayout.CurtainsLayout>

總結

至此,自定義窗簾菜單我們就講完了,看完你可能還是覺得一臉懵逼。很正常,上面講的是思路和主要方法實現,除此之外還有很多邊緣性的東西,要想看完整的實現請移步源碼。如有錯誤或者疑問,請在討論區提出。

碼雲 git:
https://gitee.com/makeunion/CurtainsLayout

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