之前寫過一篇自定義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 的事件分發等等,涉及到的方法了 onMeasure
、onLayout
、dispatchTouchEvent
、onInterceptTouchEvent
等。
其中事件分發是一個重點,而在自定義 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>
總結
至此,自定義窗簾菜單我們就講完了,看完你可能還是覺得一臉懵逼。很正常,上面講的是思路和主要方法實現,除此之外還有很多邊緣性的東西,要想看完整的實現請移步源碼。如有錯誤或者疑問,請在討論區提出。