之前寫過(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 模式
3D 模式
自定義 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、onLayout、dispatchTouchEvent、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)展示不同的部分。 (如下圖)
當(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)考慮,代碼自然就多了。
(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ū)提出。