自定義控制元件?試試300行程式碼實現QQ側滑選單

Salama發表於2019-03-04

Android自定義控制元件並沒有什麼捷徑可走,需要不斷得模仿練習才能出師。這其中進行模仿練習的demo的選擇是至關重要的,最優選擇莫過於官方的控制元件了,但是官方控制元件動輒就是幾千行程式碼往往可能容易讓人望而卻步。本文介紹如何理解並實現Android端的QQ側滑選單,300行程式碼即可。
首先上完成的效果圖:

自定義控制元件?試試300行程式碼實現QQ側滑選單
側滑效果

大家可以對比自己手機上QQ的側滑選單,效果與之幾乎沒有什麼差別。

首先

本文並不會長篇大論的講解自定義控制元件所需要的從繪圖、螢幕座標系、滑動到動畫等原理,因為我相信無論您是否會自定義控制元件,這些原理您都已經從別處爛熟於心了。但是為了方便理解,會在實現的過程中進行穿插講解。

確定目標及方向

動手擼程式碼前,我們看一眼這個效果。首先確定我們的目標是需要自定義一個ViewGroup,需要控制它的兩個子View進行滑動變換。進一步觀察我們可以發現兩個子View是疊加再一起的,所以為了減少程式碼我們可以考慮直接繼承於ViewGroup的一個實現類:FrameLayout。底層的是選單檢視menu,疊加在上面的是主介面main
新建一個類:CoordinatorMenu,並在載入佈局後拿到兩個子View

public class CoordinatorMenu extends FrameLayout {
    private View mMenuView;
    private View mMainView;

    //載入完佈局檔案後呼叫
    @Override
    protected void onFinishInflate() {
        mMenuView = getChildAt(0);//第一個子View在底層,作為menu
        mMainView = getChildAt(1);//第二個子View在上層,作為main
    }複製程式碼

為滑動做準備

實現手指跟隨滑動,這其中有很多方法,最基本的莫過於重寫onTouchEvent方法並配合Scroller實現了,但是這也是最複雜的了。還好官方提供了一個ViewDragHelper類幫助我們去實現(本質上還是使用Scroller)。
在我們的構造方法中通過ViewDragHelper靜態方法進行其初始化:

mViewDragHelper = ViewDragHelper.create(
    this, 
    TOUCH_SLOP_SENSITIVITY, 
    new CoordinatorCallback());複製程式碼

三個引數的含義:

  • 需要監聽的View,這裡就是當前的控制元件
  • 開始觸控滑動的敏感度,值越大越敏感,1.0f是正常值
  • 一個Callback回撥,整個ViewDragHelper的核心邏輯所在,這裡自定義了一個它的實現類

然後攔截觸控事件,交給我們的主角ViewDragHelper處理:

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    return mViewDragHelper.shouldInterceptTouchEvent(ev);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
    //將觸控事件傳遞給ViewDragHelper,此操作必不可少
    mViewDragHelper.processTouchEvent(event);
    return true;
}複製程式碼

處理computeScroll方法:

//滑動過程中呼叫
@Override
public void computeScroll() {
    if (mViewDragHelper.continueSettling(true)) {
        ViewCompat.postInvalidateOnAnimation(this);//處理重新整理,實現平滑移動
    }
}複製程式碼

處理部分Callback回撥

//告訴ViewDragHelper對哪個子View進行拖動滑動
@Override
public boolean tryCaptureView(View child, int pointerId) {
    //側滑選單預設是關閉的
    //使用者必定只能先觸控的到上層的主介面
    return mMainView == child;
}

//進行水平方向滑動
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
    return left;//通常返回left即可,left指代此view的左邊緣的位置
}複製程式碼

main的滑動

這樣我們就能在水平方向上隨意拖動上層的子View--main了,接下來就是限制它水平滑動的範圍了,範圍如下圖所示:

自定義控制元件?試試300行程式碼實現QQ側滑選單
選單完全展開後main的位置

改寫上面的水平滑動方法,

@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
    if (left < 0) {
        left = 0;//初始位置是螢幕的左邊緣
    } else if (left > mMenuWidth) {
        left = mMenuWidth;//最遠的距離就是選單欄完全展開後的menu的寬度
    }
    return left;    
}複製程式碼

增加回彈效果:

  • 當選單關閉,從左向右滑動main的時候,小於一定距離鬆開手,需要讓它回彈到最左邊,否則直接開啟選單
  • 當選單完全開啟,從右向左滑動main的時候,小於一定距離鬆開手,需要讓它回彈到最右邊,否則直接關閉選單

首先判斷滑動的方向:

//當view位置改變時呼叫,也就是拖動的時候
@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
    //dx代表距離上一個滑動時間間隔後的滑動距離
    if (dx > 0) {//正
        mDragOrientation = LEFT_TO_RIGHT;//從左往右
    } else if (dx < 0) {//負
        mDragOrientation = RIGHT_TO_LEFT;//從右往左
    }
}複製程式碼

在鬆開手後:

//View釋放後呼叫
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
    super.onViewReleased(releasedChild, xvel, yvel);
    if (mDragOrientation == LEFT_TO_RIGHT) {//從左向右滑
        if (mMainView.getLeft() < mSpringBackDistance) {//小於設定的距離
            closeMenu();//關閉選單
        } else {
            openMenu();//否則開啟選單
        }
    } else if (mDragOrientation == RIGHT_TO_LEFT) {//從右向左滑
        if (mMainView.getLeft() < mMenuWidth - mSpringBackDistance){//小於設定的距離
            closeMenu();//關閉選單
        } else {
            openMenu();//否則開啟選單
        }
    }
}

public void openMenu() {
    mViewDragHelper.smoothSlideViewTo(mMainView, mMenuWidth, 0);
    ViewCompat.postInvalidateOnAnimation(CoordinatorMenu.this);
}

public void closeMenu() {
    mViewDragHelper.smoothSlideViewTo(mMainView, 0, 0);
    ViewCompat.postInvalidateOnAnimation(CoordinatorMenu.this);
}複製程式碼

展開後,我們就可以觸控到底層的menu檢視了,我們拽menu不能拖動它本身,也不能拖動main,因為我們在前面指定了觸控只作用於main。我們可以先思考一下,QQ的側滑選單底層是跟隨上層移動的(細心的您會發現不是完全跟隨的,它們之間的距離變化有個線性關係,這個稍後再說),這樣的話那我們就可以把menu完全託付給main處理,分兩步:1.menu託付給main;2.main滑動時管理menu的滑動。
首先我們要先確定menu的初始位置及大小,重寫layout方法,向左偏移一個mMenuOffset

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    super.onLayout(changed, left, top, right, bottom);
    MarginLayoutParams menuParams = (MarginLayoutParams) mMenuView.getLayoutParams();
    menuParams.width = mMenuWidth;
    mMenuView.setLayoutParams(menuParams);
    mMenuView.layout(-mMenuOffset, top, mMenuWidth - mMenuOffset, bottom);
    }複製程式碼

我們先實現第一步:觸控到menu,交給main處理。
在這之前改寫前面的回撥方法,讓menu能接受觸控事件

@Override
public boolean tryCaptureView(View child, int pointerId) {
    return mMainView == child || mMenuView == child;
}複製程式碼

然後

//觀察被觸控的view
@Override
public void onViewCaptured(View capturedChild, int activePointerId) {
    if (capturedChild == mMenuView) {//當觸控的view是menu
        mViewDragHelper.captureChildView(mMainView, activePointerId);//交給main處理
    }
}複製程式碼

在這一步後,我們就可以在手指觸控到menu的時候,拖動main
這個感覺就像是指桑罵槐,指著的是menu,罵的卻是main,哈哈。

接下來我們實現第二步,menu跟隨main滑動
先看下面menumain的位置關係圖

自定義控制元件?試試300行程式碼實現QQ側滑選單

很明顯我們能得出一個結論:

從menu關閉到menu的開啟:menu移動了它的初始向左偏移距離mMenuOffset,main移動了的距離正好是menu的寬度mMenuWidth

所以我們就可以用之前用到的回撥:onViewPositionChanged(View changedView, int left, int top, int dx, int dy),因為這裡的dx正是指代移動距離,只要main移動了一個dx,那我們就可以讓menu移動一個dx * mMenuOffset / mMenuWidth,不就行了嗎?
看起來十分美好,實踐起來卻是No!No!No!,因為需要對menu使用layout方法進行重新佈局以達到移動效果,而這個方法傳進去的值是int型,而我們上面的計算公式的結果很明顯是個float,況且很不巧的是這個dx是指代表距離上一個滑動時間間隔後的滑動距離,就是把你整個滑動過程分割成很多的小塊,每一小塊的時間很短,如果你滑動很慢的話,那麼在這很短的時間內dx=1,呵呵。所以這樣計算的話精度嚴重丟失,不能達到同步移動的效果。
所以我們只能換一種思維,使用它們之間的另一種關係:menu左邊緣和main左邊緣之間的距離是由mMenuOffset增加到mMenuWidth,此時main移動了mMenuWidth。可以認為這種增加是線性的,如下圖所示:

自定義控制元件?試試300行程式碼實現QQ側滑選單

根據圖及公式y = kx + d得出:

mainLeft - menuLeft = (mMenuWidth - mMenuOffset) / mMenuWidth * mainLeft 
+ mMenuOffset複製程式碼

所以這樣重寫回撥onViewPositionChanged即可使menu跟隨main進行滑動變換:

@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
    float scale = (float) (mMenuWidth - mMenuOffset) / (float) mMenuWidth;
    int menuLeft = left - ((int) (scale * left) + mMenuOffset);
    mMenuView.layout(menuLeft, mMenuView.getTop(),
            menuLeft + mMenuWidth, mMenuView.getBottom());
}複製程式碼

相信如果我沒有給出上面的數學關係解答,直接看程式碼,您可能會一臉懵逼,這也是很多自定義控制元件原始碼難讀的原因。

給main加個滑動漸變陰影

經過上面的操作,感覺總體已經有了模樣了,但還缺少一樣東西,就是main經過選單由關閉到完全開啟的過程中,會有一層透明到不透明變化的陰影,看下面動圖演示:

自定義控制元件?試試300行程式碼實現QQ側滑選單
陰影變化

實現這個功能我們需要知道ViewGroup通過呼叫其drawChild方法對子view按順序分別進行繪製,所以在繪製完menumain後,我們需要繪製一層左邊緣隨main變化且上邊緣、右邊緣和下邊緣不變的檢視,而且這個檢視的透明度也會變化。

@Override
protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
    boolean result = super.drawChild(canvas, child, drawingTime);//完成原有的子view:menu和main的繪製

    int shadowLeft = mMainView.getLeft();//陰影左邊緣位置
    final Paint shadowPaint = new Paint();//陰影畫筆
    shadowPaint.setColor(Color.parseColor("#" + mShadowOpacity + "777777"));//給畫筆設定透明度變化的顏色
    shadowPaint.setStyle(Paint.Style.FILL);//設定畫筆型別填充
    canvas.drawRect(shadowLeft, 0, mScreenWidth, mScreenHeight, shadowPaint);//畫出陰影

    return result;
}複製程式碼

其中這個mShadowOpacity是隨main的位置變化而變化的:

private String mShadowOpacity = "00"

@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
    float showing = (float) (mScreenWidth - left) / (float) mScreenWidth;
    int hex = 255 - Math.round(showing * 255);
    if (hex < 16) {
        mShadowOpacity = "0" + Integer.toHexString(hex);
    } else {
        mShadowOpacity = Integer.toHexString(hex);
    }
}複製程式碼

至此我們的選單可以說是完工了,but!

還需要一些優化

1.如果開啟選單,熄屏,再亮屏,此時選單就又恢復到關閉的狀態了,因為重新亮屏後,layout方法會重新呼叫,也就是說我們的子view會重新佈局,所以要改寫這個方法:

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    super.onLayout(changed, left, top, right, bottom);
    MarginLayoutParams menuParams = (MarginLayoutParams) mMenuView.getLayoutParams();
    menuParams.width = mMenuWidth;
    mMenuView.setLayoutParams(menuParams);
    if (mMenuState == MENU_OPENED) {//判斷選單的狀態為開啟的話
        //保持開啟的位置
        mMenuView.layout(0, 0, mMenuWidth, bottom);
        mMainView.layout(mMenuWidth, 0, mMenuWidth + mScreenWidth, bottom);
        return;
    }
    mMenuView.layout(-mMenuOffset, top, mMenuWidth - mMenuOffset, bottom);
}

//獲取選單的狀態
@Override
public void computeScroll() {
    if (mMainView.getLeft() == 0) {
        mMenuState = MENU_CLOSED;
    } else if (mMainView.getLeft() == mMenuWidth) {
        mMenuState = MENU_OPENED;
    }
}複製程式碼

2.旋轉螢幕也會出現上述的問題,這時就需要呼叫onSaveInstanceStateonRestoreInstanceState這兩個方法分別用來儲存和恢復我們選單的狀態。

protected static class SavedState extends AbsSavedState {
    int menuState;//記錄選單狀態的值

    SavedState(Parcel in, ClassLoader loader) {
        super(in, loader);
        menuState = in.readInt();
    }

    @Override
    public void writeToParcel(Parcel dest, int flags) {
        super.writeToParcel(dest, flags);
        dest.writeInt(menuState);
    }
    ...
    ...
    ...
}

@Override
protected Parcelable onSaveInstanceState() {
    final Parcelable superState = super.onSaveInstanceState();
    final CoordinatorMenu.SavedState ss = new CoordinatorMenu.SavedState(superState);
    ss.menuState = mMenuState;//儲存狀態
    return ss;
}

@Override
protected void onRestoreInstanceState(Parcelable state) {
    if (!(state instanceof CoordinatorMenu.SavedState)) {
        super.onRestoreInstanceState(state);
        return;
    }

    final CoordinatorMenu.SavedState ss = (CoordinatorMenu.SavedState) state;
    super.onRestoreInstanceState(ss.getSuperState());

    if (ss.menuState == MENU_OPENED) {//讀取到的狀態是開啟的話
        openMenu();//開啟選單
    }
}複製程式碼

2.避免過度繪製menumain在滑動過程中會有重疊部分,重疊部分也就是menu被遮蓋的部分,是不需要再繪製的,我們只需要繪製顯示出來的menu部分,如圖所示:

自定義控制元件?試試300行程式碼實現QQ側滑選單

drawChild方法中增加以下程式碼

 @Override
protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
    final int restoreCount = canvas.save();//儲存畫布當前的剪裁資訊

    final int height = getHeight();
    final int clipLeft = 0;
    int clipRight = mMainView.getLeft();
    if (child == mMenuView) {
        canvas.clipRect(clipLeft, 0, clipRight, height);//剪裁顯示的區域
    }

    boolean result = super.drawChild(canvas, child, drawingTime);//繪製當前view

    //恢復畫布之前儲存的剪裁資訊
    //以正常繪製之後的view
    canvas.restoreToCount(restoreCount);
}複製程式碼

寫在最後

至此,我們的側滑選單即實現了功能,又優化並處理了些細節。如果有時候遇到功能不知道怎麼實現,其實最好的解決方向就是先看看官方有沒有實現過這樣的功能,再去他們的原始碼裡尋找答案,比如說我這裡實現的陰影繪製以及過度繪製優化都是參照於官方控制元件DrawerLayout,閱讀官方原始碼不僅能讓你實現功能,還能激發你並改善你的程式碼質量,會有一種臥槽,程式碼原來這麼寫最好了的感嘆。

本文原始碼地址:github.com/bestTao/Coo…有問題歡迎提issue

你也可以直接在專案中引入這個控制元件:

  1. 先新增以下程式碼到你專案中的根目錄的build.gradle
    allprojects {
         repositories {
             ...
             maven { url 'https://jitpack.io' }
         }
    }複製程式碼
  2. 再引入依賴即可:
    dependencies {
             compile 'com.github.bestTao:CoordinatorMenu:v1.0.2'
    }複製程式碼
    詳細內容及最新版本可以參考[README.md]

相關文章