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

之前寫過(guò)一篇自定義View的文章,最近搬出來(lái)統(tǒng)一放在簡(jiǎn)書上。

介紹

不知道大家是否有印象,QQ 曾經(jīng)有個(gè)版本用到了一種雙向側(cè)拉菜單,就像窗簾一樣可以兩邊開(kāi)合,并且伴有 3D 旋轉(zhuǎn)效果,效果非??犰?,吸引很多人模仿實(shí)現(xiàn)。

Android 系統(tǒng)提供了一個(gè)側(cè)拉抽屜控件,叫 DrawerLayout,使用過(guò)的人都知道,效果不錯(cuò)并且有一定拓展性,基于 DrawerLayout 我們可以實(shí)現(xiàn) QQ 的效果,但是今天我們要介紹的是另一個(gè)思路:自定義 HorizontalScrollView。

這個(gè)思路非常簡(jiǎn)單,并且你可以很方便地拓展出任何你想要的效果,說(shuō)不定做的比 QQ 更酷炫哦。

效果

首先來(lái)看下最終實(shí)現(xiàn)的效果(gif 版)

2D 模式
enter image description here
3D 模式
enter image description here

自定義 View 基礎(chǔ)

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

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

自定義 View 和 ViewGroup 的區(qū)別就是 ViewGroup 除了負(fù)責(zé)自身的顯示效果外,里面還要包含其它的子 View,這必然帶來(lái)復(fù)雜性增加,表現(xiàn)在代碼里就是自定義 View 通常只需要復(fù)寫 onDraw 和 onTouch,而自定義 ViewGroup 還要考慮子 View 的測(cè)量、子 View 的布局、子 View 的事件分發(fā)等等,涉及到的方法了 onMeasure、onLayoutdispatchTouchEvent、onInterceptTouchEvent等。

其中事件分發(fā)是一個(gè)重點(diǎn),而在自定義 View 種很重要的 onDraw 反而不是最重要的。

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

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

當(dāng)然自定義 View 和自定義 ViewGroup 也有很多共通的,比如自定義屬性,繪制函數(shù)等。那我們閑言少敘,開(kāi)始動(dòng)手實(shí)現(xiàn)吧。

實(shí)現(xiàn)思路

我們看上面的效果挺酷炫的,感覺(jué)無(wú)從下手,但是仔細(xì)觀察你會(huì)發(fā)現(xiàn),其實(shí)整個(gè)界面分為三部分:左邊菜單、中間主布局、右邊菜單。它們的位置關(guān)系是從左到右依次排列。再仔細(xì)觀察菜單的切換你會(huì)發(fā)現(xiàn),忽略縮放、透明度等動(dòng)畫,其實(shí)菜單切換的過(guò)程就是三部分滾動(dòng)的過(guò)程,于是,我們就有了一個(gè)大體的思路:

用一個(gè) HorizontalScrollView 包裹三個(gè)部分的試圖,通過(guò)控制 HorizontalScrollView 的滾動(dòng)距離來(lái)實(shí)現(xiàn)展示不同的部分。 (如下圖)

enter image description here

當(dāng)然,這只是一個(gè)思路,距離最終效果還差一些,我們基于這個(gè)思路,要解決以下幾個(gè)問(wèn)題:

(1)初始的時(shí)候要展示中間主布局。
(2)左右菜單區(qū)域的寬度要客配置。
(3)松手后,不能停在菜單的一半處,要能自動(dòng)收起或打開(kāi)菜單。
(4)左右菜單要是可配置的,因?yàn)橛脩艨赡苤恍枰髠?cè)菜單或者只需要右側(cè)菜單。
(5)復(fù)雜的事件分發(fā)。
(6)菜單切換時(shí)的 3D 效果。

自定義 HorizontalScrollView

有了思路,我們就有了方向,廢話不多說(shuō),開(kāi)始擼代碼。

(1)首先新建一個(gè)類,集成自 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);
    }
}

架子出來(lái)了,現(xiàn)在往架子里填內(nèi)容,先來(lái)獲取 3 個(gè)子 View。

(2)獲取子 View

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

if (左右菜單都有) {
    第0個(gè)子View是左側(cè)菜單
    第1個(gè)子View是中間主體
    第2個(gè)子View是右側(cè)菜單
} else if (只有左側(cè)菜單) {
    第0個(gè)子View是左側(cè)菜單
    第1個(gè)子View是中間主體
} else if (只有右側(cè)菜單) {
    第0個(gè)子View是中間主體
    第1個(gè)子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 ;

然后根據(jù)菜單類型獲取子 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)菜單寬度

獲取到了三個(gè)子 View,下面就要設(shè)置子 View 的寬度。中間主體的寬度是屏幕寬度,這個(gè)沒(méi)啥好說(shuō)的。左右菜單的寬度是要窄一點(diǎn)的。

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

@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)初始展示中間主體布局

這個(gè)就很簡(jiǎn)單了,HorizontalScrollView 默認(rèn)的滾動(dòng)位置是 0,所以就會(huì)展示左側(cè)菜單,我們只要把滾動(dòng)位置設(shè)置到左側(cè)菜單寬度就行。

@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)自動(dòng)回彈

下面就是重點(diǎn)了,很重很重的點(diǎn)。我們?cè)跐L動(dòng)時(shí),松手后應(yīng)該能自動(dòng)根據(jù)當(dāng)前滾動(dòng)位置關(guān)閉或者打開(kāi)菜單。通常就是以菜單的一半作為分界線。

if(滾動(dòng)距離 < 左側(cè)菜單寬度一半) {
    打開(kāi)左側(cè)菜單
} else if(滾動(dòng)距離 >= 左側(cè)菜單寬度一半) {
    關(guān)閉左側(cè)菜單
} else if(滾動(dòng)距離 < 左側(cè)菜單寬度 + 右側(cè)菜單寬度一半) {
    關(guān)閉右側(cè)菜單
} else if(滾動(dòng)距離 >= 左側(cè)菜單寬度 + 右側(cè)菜單寬度一半) {
    打開(kāi)右側(cè)菜單
}

上面這段邏輯如果不明白的可以多看幾遍,明白這個(gè)邏輯后才能看下面的代碼實(shí)現(xiàn)。

@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);
}

霍,怎么這么多代碼?原因是我們要考慮三種菜單類型,每種類型關(guān)閉菜單的滾動(dòng)距離是不一樣的。所以實(shí)現(xiàn)起來(lái)要分開(kāi)考慮,代碼自然就多了。

enter image description here
(6)事件分發(fā)

啊,終究逃不過(guò)這一關(guān),自定義 ViewGroup 是一定要面對(duì)事件分發(fā)的。

我們的預(yù)期是這樣的:

a、當(dāng)菜單關(guān)閉(左右菜單都關(guān)閉,中間主體全屏展示)的時(shí)候,不攔截事件,用戶可以點(diǎn)擊頁(yè)面元素,滑動(dòng)列表。

b、當(dāng)菜單打開(kāi)(左右菜單都一樣)的時(shí)候,點(diǎn)擊中間主體區(qū)域時(shí)攔截事件,點(diǎn)擊其它地方不攔截事件。也就是說(shuō)當(dāng)菜單打開(kāi)時(shí),主體區(qū)域的頁(yè)面元素不可點(diǎn)擊,列表也不可滑動(dòng),但是菜單區(qū)域的元素可以點(diǎn)擊。

這里需要兩個(gè)判斷條件:菜單是否打開(kāi)、是否點(diǎn)擊在中間主體區(qū)域。

菜單是否打開(kāi)很簡(jiǎn)單,我們?cè)O(shè)置一個(gè)變量 isOpen,每次打開(kāi)菜單置為 true,關(guān)閉菜單置為 false。

是否點(diǎn)擊在中間主體區(qū)域稍微復(fù)雜一點(diǎn),我們首先要獲取手指點(diǎn)擊相對(duì)于屏幕的坐標(biāo)值。

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

然后我們要獲取中間主體 View 所占的區(qū)域:

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);

這里為什么要乘以一個(gè) SCALE_CONTENT 呢?這個(gè)值是主體區(qū)域在動(dòng)畫過(guò)程中的縮放比例,乘以這個(gè)縮放比例就可以得到縮放后的寬高。

有了這兩步,判斷是否點(diǎn)擊在中間主體區(qū)域就很簡(jiǎn)單了

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 動(dòng)畫

這個(gè)菜單的效果全靠這個(gè)動(dòng)畫撐起來(lái)的,看似復(fù)雜,其實(shí)動(dòng)畫是最簡(jiǎn)單的。

我們根據(jù)左右菜單拉出的百分比計(jì)算各個(gè) View 的平移、縮放、alpha 動(dòng)畫值,如圖在 3D 模式下,再加上一個(gè)旋轉(zhuǎn)。旋轉(zhuǎn)我們只針對(duì)左側(cè)菜單和中間主體,右側(cè)菜單不旋轉(zhuǎn)。

@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);
    }
}

自定義屬性

好了,整個(gè)窗簾菜單基本已經(jīng)實(shí)現(xiàn)了,但是要完善一下自定義屬性,方便用戶配置。

// attr.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    // 左側(cè)菜單的右邊距
    <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>
    // 是否打開(kāi)3D模式
    <attr name="with3D" format="boolean" />
    // 3D模式下菜單的旋轉(zhuǎn)角度
    <attr name="menuRotate" format="integer" />
    // 3D模式下內(nèi)容區(qū)域的旋轉(zhuǎn)角度
    <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>

使用

自定義封裝好了,當(dāng)然就要給別人用啦,使用很簡(jiǎn)單。

<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>

總結(jié)

至此,自定義窗簾菜單我們就講完了,看完你可能還是覺(jué)得一臉懵逼。很正常,上面講的是思路和主要方法實(shí)現(xiàn),除此之外還有很多邊緣性的東西,要想看完整的實(shí)現(xiàn)請(qǐng)移步源碼。如有錯(cuò)誤或者疑問(wèn),請(qǐng)?jiān)谟懻搮^(qū)提出。

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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容