Android自定義View之仿QQ側滑選單實現
最近,由於正在做的一個應用中要用到側滑選單,所以通過查資料看視訊,學習了一下自定義View,實現一個類似於QQ的側滑選單,順便還將其封裝為自定義元件,可以實現類似QQ的側滑選單和抽屜式側滑選單兩種選單。
下面先放上效果圖:
我們這裡的側滑選單主要是利用HorizontalScrollView來實現的,基本的思路是,一個佈局中左邊是選單佈局,右邊是內容佈局,預設情況下,選單佈局隱藏,內容佈局顯示,當我們向右側滑,就會將選單拉出來,而將內容佈局的一部分隱藏,如下圖所示:
下面我們就一步步開始實現一個側滑選單。
一、定義一個類SlidingMenu繼承自HorizontalScrollView
我們後面所有的邏輯都會在這個類裡面來寫,我們先寫上其構造方法
public class SlidingMenu extends HorizontalScrollView {
/**
* 在程式碼中使用new時會呼叫此方法
* @param context
*/
public SlidingMenu(Context context) {
this(context, null);
}
/**
* 未使用自定義屬性時預設呼叫
* @param context
* @param attrs
*/
public SlidingMenu(Context context, AttributeSet attrs) {
//呼叫三個引數的構造方法
this(context, attrs, 0);
}
/**
* 當使用了自定義屬性時會呼叫此方法
* @param context
* @param attrs
* @param defStyleAttr
*/
public SlidingMenu(Context context, AttributeSet attrs, int defStyleAttr) {
super (context, attrs, defStyleAttr);
}
}
二、定義選單佈局檔案
left_menu.xml檔案程式碼如下
<?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">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="20dp"
android:layout_marginLeft="20dp"
android:gravity="left|center"
android:drawableLeft="@mipmap/ic_launcher"
android:text="第一個Item"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="20dp"
android:layout_marginLeft="20dp"
android:gravity="left|center"
android:drawableLeft="@mipmap/ic_launcher"
android:text="第二個Item"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="20dp"
android:layout_marginLeft="20dp"
android:gravity="left|center"
android:drawableLeft="@mipmap/ic_launcher"
android:text="第三個Item"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="20dp"
android:layout_marginLeft="20dp"
android:gravity="left|center"
android:drawableLeft="@mipmap/ic_launcher"
android:text="第四個Item"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="20dp"
android:layout_marginLeft="20dp"
android:gravity="left|center"
android:drawableLeft="@mipmap/ic_launcher"
android:text="第五個Item"/>
</LinearLayout>
</RelativeLayout>
上面其實就是定義了一列TextView來模仿選單的Item項
三、定義主佈局檔案,使用自定義的View
activity_main.xml檔案程式碼如下
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!--自定義View-->
<com.codekong.qq_50_slidingmenu.view.SlidingMenu
android:layout_width="match_parent"
android:layout_height="match_parent"
app:rightPadding="100dp"
app:drawerType="false"
android:scrollbars="none">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal">
<!--引入選單佈局-->
<include layout="@layout/left_menu"/>
<!--內容佈局-->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/qq_bg">
</LinearLayout>
</LinearLayout>
</com.codekong.qq_50_slidingmenu.view.SlidingMenu>
</RelativeLayout>
四、自定義成員變數
我們定義一些成員變數以便於後面使用
//自定義View佈局中內嵌的第一層的LinearLayout
private LinearLayout mWapper;
//選單佈局
private ViewGroup mMenu;
//內容佈局
private ViewGroup mContent;
//螢幕寬度
private int mScreenWidth;
//選單距螢幕右側的距離,單位dp
private int mMenuRightPadding = 50;
//選單的寬度
private int mMenuWidth;
//定義標誌,保證onMeasure只執行一次
private boolean once = false;
//選單是否是開啟狀態
private boolean isOpen = false;
五、拿到螢幕寬度的畫素值
因為目前為止,我們沒有使用自定義屬性,所以自定義View預設會呼叫兩個引數的構造方法,但因為我們第一步中寫構造方法時是在兩個引數的構造方法中呼叫了三個引數的構造方法,所以,我們將獲取螢幕寬度的程式碼寫在三個引數的構造方法中,後面我們自定義屬性後獲取屬性值也是在三個引數的構造方法中書寫相應的邏輯。
/**
* 當使用了自定義屬性時會呼叫此方法
* @param context
* @param attrs
* @param defStyleAttr
*/
public SlidingMenu(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//通過以下步驟拿到螢幕寬度的畫素值
WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics displayMetrics = new DisplayMetrics();
windowManager.getDefaultDisplay().getMetrics(displayMetrics);
mScreenWidth = displayMetrics.widthPixels;
}
六、實現onMeasure()方法
onMeasure()方法是自定義View的正式第一步,它用來決定內部View(子View)的寬和高,以及自身的寬和高,下面是具體的程式碼邏輯。
/**
* 設定子View的寬和高
* 設定自身的寬和高
* @param widthMeasureSpec
* @param heightMeasureSpec
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (!once){
once = true;
mWapper = (LinearLayout) getChildAt(0);
mMenu = (ViewGroup) mWapper.getChildAt(0);
mContent = (ViewGroup) mWapper.getChildAt(1);
//選單和內容區域的高度都可以保持預設match_parent
//選單寬度 = 螢幕寬度 - 選單距螢幕右側的間距
mMenuWidth = mMenu.getLayoutParams().width = mScreenWidth - mMenuRightPadding;
mContent.getLayoutParams().width = mScreenWidth;
//當設定了其中的選單的寬高和內容區域的寬高之後,最外層的LinearLayout的mWapper就自動設定好了
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
七、實現onLayout()方法
onLayout()方法中主要是確定自定義View中子View放置的位置。下面是具體的程式碼。
/**
* 通過設定偏移量將Menu隱藏
* @param changed
* @param l
* @param t
* @param r
* @param b
*/
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (changed){
//佈局發生變化時呼叫(水平滾動條向右移動menu的寬度,則正好將menu隱藏)
this.scrollTo(mMenuWidth, 0);
}
}
這個比較好理解,由於我們使用的是水平滾動佈局,我們預設情況下相當於將水平滾動條向右拖動選單寬度的的距離,這樣左邊佈局的選單就正好被隱藏了。
八、onTouchEvent()方法
該方法主要處理內部內部View的移動,我們可以在其中寫一些邏輯控制自定義View內部的滑動事件。
由於我們的自定義View是繼承自HorizontalScrollView,我們不再處理按下和移動事件,保持HorizontalScrollView預設的即可,但對於手指擡起事件,我們需要根據手指在水平X軸方向的位移來做出開啟選單或關閉選單的操作,所以我們的邏輯程式碼如下:
@Override
public boolean onTouchEvent(MotionEvent ev) {
int action = ev.getAction();
//按下和移動使用HorizontalScrollView的預設處理
switch (action){
case MotionEvent.ACTION_UP:
//隱藏在左邊的位置
int scrollX = getScrollX();
if (scrollX > mMenuWidth / 2){
//隱藏的部分較大, 平滑滾動不顯示選單
this.smoothScrollTo(mMenuWidth, 0);
isOpen = false;
}else{
//完全顯示選單
this.smoothScrollTo(0, 0);
isOpen = true;
}
return true;
}
return super.onTouchEvent(ev);
}
其實到這一步為止,一個基本的側滑選單已經做出來了,下面我們將使用屬性動畫對我們的自定義View進行擴充套件,使其實現最開始展示的抽屜式側滑選單。
九、屬性動畫實現抽屜式側滑
接下來我們實現抽屜式側滑,抽屜式側滑說白了就是,我們的選單不是一點點被拉出來,而是看起來選單就藏在頁面的背後,隨著我們向右滑動,一點點顯露出來。
實現的思路很簡單,當我們拖動時,我們讓選單佈局的偏移量等於getScrollX()的值,也就是時刻把選單隱藏在左邊的部分向右偏移出來,這樣我們看起來就像選單藏在頁面後面。如下圖:
當我們左右滑動時會觸發onScrollChanged()方法,我們在此處算出選單需要的實時的偏移量,然後呼叫屬性動畫即可。
下面說說具體實現程式碼:
/**
* 滾動發生時呼叫
* @param l getScrollX()
* @param t
* @param oldl
* @param oldt
*/
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
float scale = l * 1.0f / mMenuWidth; //1 ~ 0
//呼叫屬性動畫,設TranslationX
mMenu.setTranslationX(mMenuWidth * scale);
}
上面方法中的 l 就是上一步中提到的getScrollX()獲得的值。當我們沒有拉選單時,選單需要的偏移量就是整個選單的寬度,當我們將選單完全拉出時,選單就不需要偏移量了,此時偏移量為0。此時我們的抽屜式側滑就做好了。
注:此處的屬性動畫是在Android3.0之後引入的,如果需要相容更早的版本,可以用相關的相容庫。
十、自定義屬性實現靈活配置
自定義屬性主要是方便使用者可以根據具體的場景實現不同的效果。比如,我們可以通過在xml檔案中配置,實現選單是普通的側滑式還是抽屜式。在剛開始,我們在自定義View中將選單開啟時,選單右邊緣距離螢幕右邊緣的值設定為50dp,我們通過自定義屬性可以實現在xml檔案中自己配置合適的值。
自定義屬性按下面的步驟進行:
1 . 在 res/values 目錄下新建attr.xml檔案,檔案中寫入的內容如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr name="rightPadding" format="dimension"/>
<attr name="drawerType" format="boolean"/>
<declare-styleable name="SlidingMenu">
<attr name="rightPadding"/>
<attr name="drawerType"/>
</declare-styleable>
</resources>
上面的比較好理解,我們先在上面宣告兩個自定義的屬性的名稱及其對應的型別,然後再在下面的具體的自定義樣式中引用它們。上面兩個自定義的屬性分別是選單拉開時右邊緣距離螢幕右邊緣的距離,以及選單是否是抽屜式佈局。
2 . 在自定義View類中獲取到自定義的屬性值。如果使用者在xml檔案中自定義了屬性值,我們則獲取,如果沒有顯式設定,則使用預設值即可。
順便說一下,前面提到當我們使用自定義屬性時,會預設呼叫三個引數的構造方法,所以我們獲取自定義屬性值的程式碼也是寫在三個引數的構造方法中。
下面是獲取屬性值的程式碼:
/**
* 當使用了自定義屬性時會呼叫此方法
* @param context
* @param attrs
* @param defStyleAttr
*/
public SlidingMenu(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//獲取我們自定義的屬性
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs,
R.styleable.SlidingMenu, defStyleAttr, 0);
int n = typedArray.getIndexCount();
//遍歷每一個屬性
for (int i = 0; i < n; i++) {
int attr = typedArray.getIndex(i);
switch (attr){
//對我們自定義屬性的值進行讀取
case R.styleable.SlidingMenu_rightPadding:
//如果在應用樣式時沒有賦值則使用預設值50,如果有值則直接讀取
mMenuRightPadding = typedArray.getDimensionPixelSize(attr,
(int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, mMenuRightPadding, context.getResources().getDisplayMetrics()));
break;
case R.styleable.SlidingMenu_drawerType:
isDrawerType = typedArray.getBoolean(attr, false);
break;
default:
break;
}
}
//釋放,一定要釋放
typedArray.recycle();
}
3 . 上面的程式碼中我們已經可以讀取到設定的屬性值,我們可以如下面一樣設定自定義屬性值:
<com.codekong.qq_50_slidingmenu.view.SlidingMenu
android:layout_width="match_parent"
android:layout_height="match_parent"
app:rightPadding="100dp"
app:drawerType="true"
android:scrollbars="none">
</com.codekong.qq_50_slidingmenu.view.SlidingMenu>
4 . 使用屬性值控制具體的邏輯,我們的rightPadding一旦獲取到就會在onMeasure()方法中被設定,而drawerType被獲取到就可以控制是否會呼叫onScrollChanged()中的程式碼。程式碼如下:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (!once){
once = true;
mWapper = (LinearLayout) getChildAt(0);
mMenu = (ViewGroup) mWapper.getChildAt(0);
mContent = (ViewGroup) mWapper.getChildAt(1);
//選單和內容區域的高度都可以保持預設match_parent
//選單寬度 = 螢幕寬度 - 選單距螢幕右側的間距
mMenuWidth = mMenu.getLayoutParams().width = mScreenWidth - mMenuRightPadding;
mContent.getLayoutParams().width = mScreenWidth;
//當設定了其中的選單的寬高和內容區域的寬高之後,最外層的LinearLayout的mWapper就自動設定好了
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
if (isDrawerType){
float scale = l * 1.0f / mMenuWidth; //1 ~ 0
//呼叫屬性動畫,設TranslationX
mMenu.setTranslationX(mMenuWidth * scale);
}
}
十一、給自定義View設定方法
對於我們的滑動式選單,我們最常用的功能便是選單的開啟和關閉,所以我們可以在自定義View中定義這兩個方法,方便我們的使用,下面是具體的程式碼:
/**
* 開啟選單
*/
public void openMenu(){
if (!isOpen){
this.smoothScrollTo(0, 0);
isOpen = true;
}
}
/**
* 關閉選單
*/
public void closeMenu(){
if (isOpen){
this.smoothScrollTo(mMenuWidth, 0);
isOpen = false;
}
}
/**
* 切換選單
*/
public void toggleMenu(){
if (isOpen){
closeMenu();
}else{
openMenu();
}
}
當我們在Activity中使用時可以按下面的程式碼使用:
SlidingMenu slidingMenu = (SlidingMenu) findViewById(R.id.sliding_menu);
slidingMenu.toggleMenu();
最後面放上完整的自定義View的程式碼:
public class SlidingMenu extends HorizontalScrollView {
//自定義View佈局中內嵌的最外層的LinearLayout
private LinearLayout mWapper;
//選單佈局
private ViewGroup mMenu;
//內容佈局
private ViewGroup mContent;
//螢幕寬度
private int mScreenWidth;
//選單距螢幕右側的距離,單位dp
private int mMenuRightPadding = 50;
//選單的寬度
private int mMenuWidth;
//定義標誌,保證onMeasure只執行一次
private boolean once = false;
//選單是否是開啟狀態
private boolean isOpen = false;
//是否是抽屜式
private boolean isDrawerType = false;
/**
* 在程式碼中使用new時會呼叫此方法
* @param context
*/
public SlidingMenu(Context context) {
this(context, null);
}
/**
* 未使用自定義屬性時預設呼叫
* @param context
* @param attrs
*/
public SlidingMenu(Context context, AttributeSet attrs) {
//呼叫三個引數的構造方法
this(context, attrs, 0);
}
/**
* 當使用了自定義屬性時會呼叫此方法
* @param context
* @param attrs
* @param defStyleAttr
*/
public SlidingMenu(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//獲取我們自定義的屬性
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs,
R.styleable.SlidingMenu, defStyleAttr, 0);
int n = typedArray.getIndexCount();
//遍歷每一個屬性
for (int i = 0; i < n; i++) {
int attr = typedArray.getIndex(i);
switch (attr){
//對我們自定義屬性的值進行讀取
case R.styleable.SlidingMenu_rightPadding:
//如果在應用樣式時沒有賦值則使用預設值50,如果有值則直接讀取
mMenuRightPadding = typedArray.getDimensionPixelSize(attr,
(int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, mMenuRightPadding, context.getResources().getDisplayMetrics()));
break;
case R.styleable.SlidingMenu_drawerType:
isDrawerType = typedArray.getBoolean(attr, false);
break;
default:
break;
}
}
//釋放
typedArray.recycle();
//通過以下步驟拿到螢幕寬度的畫素值
WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics displayMetrics = new DisplayMetrics();
windowManager.getDefaultDisplay().getMetrics(displayMetrics);
mScreenWidth = displayMetrics.widthPixels;
}
/**
* 設定子View的寬和高
* 設定自身的寬和高
* @param widthMeasureSpec
* @param heightMeasureSpec
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (!once){
once = true;
mWapper = (LinearLayout) getChildAt(0);
mMenu = (ViewGroup) mWapper.getChildAt(0);
mContent = (ViewGroup) mWapper.getChildAt(1);
//選單和內容區域的高度都可以保持預設match_parent
//選單寬度 = 螢幕寬度 - 選單距螢幕右側的間距
mMenuWidth = mMenu.getLayoutParams().width = mScreenWidth - mMenuRightPadding;
mContent.getLayoutParams().width = mScreenWidth;
//當設定了其中的選單的寬高和內容區域的寬高之後,最外層的LinearLayout的mWapper就自動設定好了
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
/**
* 通過設定偏移量將Menu隱藏
* @param changed
* @param l
* @param t
* @param r
* @param b
*/
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (changed){
//佈局發生變化時呼叫(水平滾動條向右移動menu的寬度,則正好將menu隱藏)
this.scrollTo(mMenuWidth, 0);
}
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
int action = ev.getAction();
//按下和移動使用HorizontalScrollView的預設處理
switch (action){
case MotionEvent.ACTION_UP:
//隱藏在左邊的位置
int scrollX = getScrollX();
if (scrollX > mMenuWidth / 2){
//隱藏的部分較大, 平滑滾動不顯示選單
this.smoothScrollTo(mMenuWidth, 0);
isOpen = false;
}else{
//完全顯示選單
this.smoothScrollTo(0, 0);
isOpen = true;
}
return true;
}
return super.onTouchEvent(ev);
}
/**
* 開啟選單
*/
public void openMenu(){
if (!isOpen){
this.smoothScrollTo(0, 0);
isOpen = true;
}
}
/**
* 關閉選單
*/
public void closeMenu(){
if (isOpen){
this.smoothScrollTo(mMenuWidth, 0);
isOpen = false;
}
}
/**
* 切換選單
*/
public void toggleMenu(){
if (isOpen){
closeMenu();
}else{
openMenu();
}
}
/**
* 滾動發生時呼叫
* @param l getScrollX()
* @param t
* @param oldl
* @param oldt
*/
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
if (isDrawerType){
float scale = l * 1.0f / mMenuWidth; //1 ~ 0
//呼叫屬性動畫,設TranslationX
mMenu.setTranslationX(mMenuWidth * scale);
}
}
}