1. 程式人生 > >自定義元件開發九 側邊欄

自定義元件開發九 側邊欄

概述

側邊欄是一種常見的 UI 結構,使用者使用手指左滑或者右滑,可以像抽屜一樣拉出隱藏在屏
幕邊界之外的內容,既能增加 App 的 UI 的內容,又能給使用者帶來更新鮮的使用者體驗,網易新聞、QQ(如圖所示)等主流 App 都有類似的設計。
這裡寫圖片描述

側邊欄和上一章節講的觸控滑屏有很多的類似之處。在側邊欄自定義容器中,定義兩個子容
器,一個用作側邊欄,另一個用作主介面,預設情況下隱藏側邊欄,使用者向右滑動手指後,側邊欄從左邊滑出,主要的功能如下:

Ø 手指從左側邊沿滑動手指,側邊欄滑出,如果在螢幕中間滑動不起作用;
Ø 在螢幕任意處向左滑動,可以關閉側邊欄;
Ø 側邊欄滑出後,鬆開手指,側邊欄繼續滑動到指定位置;
Ø 支援分割線;
Ø 支援側邊欄寬度設定;
Ø 支援手指滑動感應寬度設定;
Ø 提供直接開啟或關閉的功能介面。

從技術角度來說,其實也沒什麼新的技術點,主要還是自定義 ViewGroup 容器並利用
Scroller 實現慣性滾動。

我們閱讀 Android SDK 的原始碼時,大量應用了位操作,包括按位與(&)、按位或(|)、按位
取反(~)、左位移(<<)和右位移(>>)等等,位操作主要用於二進位制運算,在儲存一些標識資料時被大量使用,這也給我們理解程式碼帶來了一些困難。本章將使用位來儲存一些標識資料,希望能起到拋磚引玉的作用。

使用二進位制儲存標識資料

位運算子

對於那些“是”或“否”的資料,可以使用位來儲存,在 Java 中,一個 int 型別的資料佔 4 個字
節,也就是 32 位,一位就能代表一種資訊,前面學習到的呼叫 onMeasure()方法對自定義元件進行尺寸測量時,引數 widthMeasureSpec 和 heightMeasureSpec 中前 2 位儲存了尺寸模式,後 30位儲存了尺寸大小就是一種典型的位應用。再比如在本章的側邊欄元件中是否顯示側邊欄的分割線,為 1 表示為顯示,為 0 表示為不顯示;側邊欄的開啟狀態也可以用一位表示,為 1 時表示已開啟,為 0 時表示隱藏。我們使用最後一位來表示是否顯示分割線,使用倒數第二位來表示側邊欄的開啟和關閉狀態。示意圖如圖所示。
這裡寫圖片描述

所有程式語言的位操作運算子都大同小異,本章不打算將位操作講得多麼完整和透徹(但閱
讀後您將會有非常細緻的理解),只向您介紹將會用的位操作運算子,主要有:
Ø 按位與(&)
Ø 按位或(|)
Ø 按位取反(~)
Ø 左位移(<<)
Ø 右位移(>>、>>>)

按位與(&):兩個二進位制值按位與運算,逢 0 則 0。即:1 & 1 = 1、1 & 0 = 0、0 & 1 = 0、0
& 0 = 0;
按位或(|):兩個二進位制值按位或運算,逢 1 則 1。即:1 | 1 = 1、1 | 0 = 1、0 | 1 = 1、0 |
0 = 0;
按位取反(~):兩個二進位制值按位取反運算,0 取反為 1,1 取反為 0。即:~0 = 1、~1 =
0;
左位移(<<):將二進位制數向左移動若干位,最低位補 0。比如 0000 1000 向左移 2 位,變
成 0010 0000;
右位移(>>):帶符號右位移,負數最高位補 1,正數補 0。比如 0000 1000 向右移 2 位,
變成 0000 0010;1111 0000 向右移 2 位(以 1 開頭即為負數),變成 1111 1100;
右位移(>>>):不帶符號右位移,負數和正數高位都補 0,比如 1111 0000 向右移 2 位,
變成 0011 1100。

對於移位運算子來說,最好使用完整的位進行運算,這樣的結果才精確。同時,對於二進位制
來說,最高位代表符號,如果最高位為 1 則表示負數,為 0 則表示正數。
我們通過幾個案例來演示二進位制的運算規則,如果以前概念比較模糊請在這個小節中理解
透。

案例 1:10 & -4 = ?

進行位運算時,要先將十進位制轉換成二進位制。負數轉換成二進位制比較麻煩,原則是將負數的
絕對值轉換成二進位制,再取反加 1,有一個基本的判斷標準,負數轉換為二進位制後,一定是以 1開頭。

java 中 int 型佔 4 個位元組,共 32 位,數字 10 轉換成二進位制後如下:
0000 0000 0000 0000 0000 0000 0000 1010

下面整理出-4 轉換成二進位制的過程:
1. 先取-4 的絕對值,結果為 4
2. 得到 4 的 2 進位制值:0000 0000 0000 0000 0000 0000 0000 0100
3. 將 2 進位制的值取反:1111 1111 1111 1111 1111 1111 1111 1011
4. 將第 3)步的結果+1:1111 1111 1111 1111 1111 1111 1111 1100

上面 4 步計算出了-4 的二進位制值:1111 1111 1111 1111 1111 1111 1111 1100,再次強調,如
果是負數,轉換成二進位制後,一定是以 1 開頭。

10 和-4 都是十進位制,先將十進位制轉換成二進位制後,再進行 10 & -4 的計算,過程如下:
這裡寫圖片描述
根據上面的結果得知, 10 & -4 的結果為 8。可以寫一個 Java Application 對該結果進行驗證。Integer 有一個靜態方法 toBinaryString(int n)能將引數 n 以二進位制格式返回。

案例 2:10 | -4 = ?
為了簡單起見,我們不再使用新的數字,只是改了一下運算子,這次我們計算 10 和-4 按位
或運算之後的結果,前面已經知道了 10 和-4 的二進位制表示,我們通過公式計算如下:
這裡寫圖片描述
案例 1 的計算結果以 0 開頭,說明是正數,無需再計算換算。但案例 2 的計算結果是以 1 開
頭,說明是負數,負數的二進位制如何轉換成十進位制呢?運算規則其實是相反的:減 1 取反加負
號。我們的演算過程如下:

1111 1111 1111 1111 1111 1111 1111 1110
減 1-> 1111 1111 1111 1111 1111 1111 1111 1101
取反-> 0000 0000 0000 0000 0000 0000 0000 0010 -> 2

取反後結果為 2,加個負號,即為-2,也就是說,10 | -4 = -2。

案例 3:10 << 4 = ?
本案例要求將 10 向左移 4 位。10 的二進位制為:0000 0000 0000 0000 0000 0000 0000 1010,向左移 4 位變成: 0000 0000 0000 0000 0000 0000 1010 0000,轉換成二進位制為 160。所以,10<< 4 = 160。

案例 4:-4 >> 4 = ?
本案例演示帶符號右移位運算,-4 向右移 4 位,因為是負數,最高位補 1。即 1111 1111 1111
1111 1111 1111 1111 1100 向右移動 4 位後,結果為:1111 1111 1111 1111 1111 1111 1111 1111,每一位都是 1 了,轉換成 10 進位制又是多少呢?我們演算一下:

負數-> 1111 1111 1111 1111 1111 1111 1111 1111
減 1-> 1111 1111 1111 1111 1111 1111 1111 1110
取反-> 0000 0000 0000 0000 0000 0000 0000 0001 -> 1

取反後為 1,加負數即為-1,也就是說-4 >> 4 = -1。

通過上面 4 個案例,我們是不是都掌握了位運算的基本運算規則?位運算的難點主要是當
有負數參與運算時需要轉換,而轉換的規則也是如此容易。

位運算的常用功能

對於每一個“位”來說,主要的操作有置 1、置 0 和判斷是否為 1 等三個操作。如果使用一
個“位”表示“有”或“無”的概念,則一般來說,1 表示有,0 表示無。假設使用 int 型的最後一位
表示“是一位帥哥”,倒數第二位表示“還是一個小鮮肉”,則:

0000 0000 0000 0000 0000 0000 0000 0011 表示是小鮮肉又是帥哥
0000 0000 0000 0000 0000 0000 0000 0001 表示不是小鮮肉是帥哥
0000 0000 0000 0000 0000 0000 0000 0000 表示不是小鮮肉也不是帥哥
0000 0000 0000 0000 0000 0000 0000 0010 表示是小鮮肉但不是帥哥

上述案例中,程式在執行時,可能要對後面的兩位進行變值操作,換句話說,需要將某一位
進行置 0 或置 1 的操作,不同的“位”需要和不同的值進行位運算,如果是第 n 位(從低位算
起,n 從 0 開始),則該值為 2 的 n 次方,如圖所示。
這裡寫圖片描述
上述中的 2 的 n 次方其實是有章可循的,比如是 2 0 是 1,2 1 是 10,2 2 是 100,2 3 是 1000,2 4 是 10000……依此類推。

如果有個 int 型別的數 N,現針對第 n 位進行置 0、置 1 和判斷是否為 1 的操作,規則如下:
Ø 置 0:N & ~ 2 n
Ø 置 1:N | 2 n
Ø 判斷是否為 1:2 n == N & 2 n ,如果相等,表示為 1

我們通過一個場景來模擬試驗上面的 3 個操作。首先,我們定義三個變數或常量:
private static final int HANDSOME_MASK = 0x1; //帥哥掩碼,0001
private static final int FLESH_MEAT_MASK = HANDSOME_MASK << 1; //小鮮肉掩碼,向左移動 1位,二進位制就是 0010
private int me = 0;//初始化為 0,即不是小鮮肉,也不是帥哥

變數 me 是我最開始的特徵,初始為 0,表示即不是小鮮肉,也不是帥哥,現在我要變成小
鮮肉:me = me | FLESH_MEAT_MASK,也就是:me = 0000 | 0010 = 0010,此時,倒數第二位變成了 1,這就是置 1 操作。通過努力的健身,肌肉出來了,線條出來了,我又變成帥哥了,me = me| HANDSOME_MASK。上一步 me 的值為 0010,此時,me = 0010 | 0001 = 0011,倒數第 2 位和倒數第 1 位全成了 1,此時的我即是小鮮肉又是小帥哥,耶!

吃了睡睡了吃,現在的我不是小鮮肉也不是帥哥了,而是一頭大肥豬。需要
把後面兩個位的 1 全變成 0:me = me & ~ FLESH_MEAT_MASK 表示不再是小鮮肉,上面的運算中,me 為 0011,這時,me = 0011 & ~0010 = 0011 & 1101 = 0001,是的,倒數第二位變成了 0,已不再是小鮮肉,但還是帥哥哦,繼續運算:me = me & ~ HANDSOME_MASK,即:me = 0001 & ~0001= 0001 & 1110 = 0000,啊,全部置 0,已完全去除了小鮮肉和帥哥的身份,悲催!

如果要判斷當前的我是小鮮肉還是帥哥呢?假設 me 變數的值為 0001,判斷是不是小鮮肉:
me & FLESH_MEAT_MASK = 0001 & 0010 = 0000,結果為 0000,與 FLESH_MEAT_MASK 不相等,說明不是小鮮肉(相等才是小鮮肉)。那麼是不是帥哥呢?me & HANDSOME_MASK = 0001 & 0001 =0001,嗯,結果 0001 與 HANDSOME_MASK 是相等的,真憑實據證明就是帥哥。

分析到這裡差不多就結束了,其實二進位制還有其他的作用,大家可以多去分析和理解。最重
要的是,看二進位制要和看十二進位制一樣自然,這樣就不需要在腦海裡轉換來轉換去了。

繼承自ViewGroup的側邊欄

提供側邊欄的兩種實現方案。其一是繼承自 ViewGroup,其二是繼承自HorizontalScrollView。顯然,第一種實現會更加複雜,第二種要相對簡單些。顯然這兩種都是應該掌握的。

不管是哪一種方法,側邊欄的結構都是由兩部分構成:側邊欄和主介面
這裡寫圖片描述

首先,我們在 attrs.xml 檔案中定義 3 個屬性:分割線寬度、側邊欄寬度和手指滑動感應寬
度。

    <declare-styleable name="SliderMenu">
        <!-- 側邊欄寬度 -->
        <attr name="sliding_width" format="dimension"/>
        <!-- 分割線寬度 -->
        <attr name="separator" format="dimension"/>
        <!-- 手指滑動感應寬度 -->
        <attr name="touch_width" format="dimension"/>
    </declare-styleable>

定義一個名為 SliderMenu 的類,繼承自 ViewGroup,定義相關的常量、變數,其次定義構造
方法完成初始化操作:獲取屬性值、定義繪製分割線需要的 Paint 物件、例項化 Scroller 物件用於完成慣性滾動。

public class SliderMenu extends ViewGroup {
    private static final String TAG = "SliderMenu";
    private static final int DO_MOVING = 0x001;//可以滑動
    private static final int NOT_MOVING = 0x002;//不可以滑動
    private int moving = NOT_MOVING; //是否可以滑動,預設不能滑動
    private static final int FLAG_SEPARATOR = 0x1;//標記變數,是否有分割線,佔用最後一位

    private static final int FLAG_IS_OPEN = FLAG_SEPARATOR << 1;//標記變數,是否已開啟,佔用倒數第二位
    private int flags = FLAG_SEPARATOR >> 1;//儲存標記變數
    private int slidingWidth;//側邊欄寬度
    private float separator;//分割線寬度
    private int touchWidth;//感應寬度
    private int screenWidth;//螢幕寬度
    private Paint paint;
    private int preX, firstX;
    private Scroller scroller;

    public SliderMenu(Context context) {
        this(context, null, 0);
    }
    public SliderMenu(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }
    /**
     * 初始化
     * @param context
     * @param attrs
     * @param defStyleAttr
     */
    public SliderMenu(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        TypedArray a = context.obtainStyledAttributes(
                attrs, R.styleable.SliderMenu);
        slidingWidth = a.getDimensionPixelSize(
                R.styleable.SliderMenu_sliding_width,
                (int) TypedValue.applyDimension(
                        TypedValue.COMPLEX_UNIT_DIP, 150,
                        getResources().getDisplayMetrics()));
        separator = a.getDimensionPixelSize(R.styleable.SliderMenu_separator,
                (int)TypedValue.applyDimension(
                        TypedValue.COMPLEX_UNIT_DIP, 1
                        , getResources().getDisplayMetrics()));
        touchWidth = a.getDimensionPixelSize(
                R.styleable.SliderMenu_touch_width,
                (int) TypedValue.applyDimension(
                        TypedValue.COMPLEX_UNIT_DIP, 50,
                        getResources().getDisplayMetrics()));
        if(separator > 0)
            flags = flags | FLAG_SEPARATOR;
        a.recycle();
        screenWidth = getScreenWidth(context);
        setBackgroundColor(Color.alpha(255));
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        paint.setColor(Color.GRAY);
        paint.setStrokeWidth(separator);
        scroller = new Scroller(context);
    }
    /**
     * 子元素不能超過 2 個
     * @param child
     * @param index
     * @param params
     */
    @Override
    public void addView(View child, int index, LayoutParams params) {
        super.addView(child, index, params);
        if(getChildCount() > 2){
            throw new ArrayIndexOutOfBoundsException("Children count can't be more than 2.");
        }
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        System.out.println("SlideMenu的位置left:" + left + " top:" + top + " right:" + right + " bottom:" + bottom);// left:0 top:0 right:540 bottom:863
        //SlideMenu的位置就是上面的System.out.println輸出的log
        //這裡需要確定的是SlideMenu的子View的位置
        //側邊欄子View初始位置是隱藏在手機螢幕的左邊的,注意,此時menuView經過measure之後,menuView.getMeasuredWidth()就等於了menuWidth
        menuView.layout(-menuWidth, 0, 0, menuView.getMeasuredHeight());
        //主佈局子View的位置,初始是顯示在整個螢幕中
        mainView.layout(0, 0, mainView.getMeasuredWidth(), mainView.getMeasuredHeight());
    }

    private View menuView;//側邊欄子View
    private View mainView;//主體子View
    private int menuWidth;//側邊欄的寬度

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        menuView = getChildAt(0); //側邊欄佈局
        mainView = getChildAt(1); //主體佈局
        menuWidth = menuView.getLayoutParams().width;//側邊欄的寬度
    }
    /**
     * 獲取螢幕寬度
     * @param context
     * @return
     */
    private int getScreenWidth(Context context){
        WindowManager wm = (WindowManager) getContext()
                .getSystemService(Context.WINDOW_SERVICE);
        Point point = new Point();
        wm.getDefaultDisplay().getSize(point);
        return point.x;
    }
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        measureChildren(widthMeasureSpec, heightMeasureSpec);
        setMeasuredDimension(measureWidth(widthMeasureSpec),
                measureHeight(heightMeasureSpec));
    }

    /**
     * 總寬度 = 側邊欄寬度 + 主介面寬度 + 分割線寬度
     * @param widthMeasureSpec
     * @return
     */
    private int measureWidth(int widthMeasureSpec){
        int mode = MeasureSpec.getMode(widthMeasureSpec);
        int size = MeasureSpec.getSize(widthMeasureSpec);
        if(mode == MeasureSpec.AT_MOST){throw new IllegalStateException("layout_width can not be wrap_content.");
        }
        return (int) (screenWidth + slidingWidth + separator);
    }
    private int measureHeight(int heightMeasureSpec){
        int mode = MeasureSpec.getMode(heightMeasureSpec);
        int size = MeasureSpec.getSize(heightMeasureSpec);
        if(mode == MeasureSpec.AT_MOST){
            throw new IllegalStateException("layout_width can not be wrap_content");
        }
        int height = 0;
        if(mode == MeasureSpec.EXACTLY){
            height = size;
        }
        return height;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //畫分割線
        if((flags & FLAG_SEPARATOR) == FLAG_SEPARATOR){
            int left = (int) (slidingWidth + separator / 2);
            canvas.drawLine(left, 0, left, getMeasuredHeight(), paint);
        }
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {switch (ev.getAction()){
        case MotionEvent.ACTION_DOWN:
            Log.i(TAG, "X:" + ev.getX());
            if((flags & FLAG_IS_OPEN) == FLAG_IS_OPEN){
                moving = DO_MOVING;
            }else{
                if(ev.getX() > touchWidth)
                    moving = NOT_MOVING;
                else
                    moving = DO_MOVING;
            }
            break;
        case MotionEvent.ACTION_MOVE:
            break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            moving= NOT_MOVING;
            break;
    }
        return super.onInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if(moving == NOT_MOVING)
            return false;
        int x = (int) event.getX();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                preX = x;
                firstX = x;
                break;case MotionEvent.ACTION_MOVE:
                int dx = x - preX;
                this.scrollBy(-dx, 0);
                preX = x;
                break;
            case MotionEvent.ACTION_UP:
                dx = x - firstX;
                Log.i(TAG, "dx: " + dx);
                int remain = slidingWidth - Math.abs(dx);
//dx 右為正,左為負
                boolean isOpen = (flags & FLAG_IS_OPEN) == FLAG_IS_OPEN;
                if(dx > 0 && !isOpen) {
                    scroller.startScroll(getScrollX(), 0, -remain, 0);
                    flags = flags | FLAG_IS_OPEN;
                }else if (dx < 0 && isOpen){
                    scroller.startScroll(getScrollX(), 0, remain, 0);
                    flags = flags & ~FLAG_IS_OPEN;
                }else{
//校正(比如向右滑又向左滑)
                    scroller.startScroll(getScrollX(), 0, dx, 0);
                }
                invalidate();
                break;
        }
        return moving == DO_MOVING;
    }
    @Override
    public void computeScroll() {
        if(scroller.computeScrollOffset()){
            this.scrollTo(scroller.getCurrX(), 0);
            postInvalidate();
        }
    }

    /**
     * 開啟側邊欄
     */
    public void open(){
        boolean isOpen = (flags & FLAG_IS_OPEN) == FLAG_IS_OPEN;
        if(!isOpen){
            scroller.startScroll(slidingWidth, 0, -slidingWidth, 0);
            invalidate();
            flags = flags | FLAG_IS_OPEN;
        }
    }
    /**
     * 關閉側邊欄
     */
    public void close(){
        boolean isOpen = (flags & FLAG_IS_OPEN) == FLAG_IS_OPEN;
        if(isOpen){
            scroller.startScroll(0, 0, slidingWidth, 0);
            invalidate();
            flags = flags & ~FLAG_IS_OPEN;
        }
    }
    /**
     * 開啟/關閉側邊欄
     */
    public void toggle(){
        boolean isOpen = (flags & FLAG_IS_OPEN) == FLAG_IS_OPEN;
        if(isOpen)
            close();
        else
            open();
    }
}

上面程式碼中使用了位運算來儲存兩個標識資料:開啟狀態和是否需要繪製分割線。

下來我們定義一個名為 sliding_menu.xml 的佈局檔案,佈局中有一個 LinearLayout 佈局和
FrameLayout 佈局,分別用於顯示側邊欄內容和主介面內容。佈局檔案的內容如下:

<?xml version="1.0" encoding="utf-8"?>
<com.trkj.lizanhong.chapter9.SliderMenu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:trkj="http://schemas.android.com/apk/res-auto"
    android:id="@+id/sm"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    trkj:separator="0.5dp"
    trkj:sliding_width="200dp"
    trkj:touch_width="50dp">
    <!-- 側邊欄-->
    <LinearLayout
        android:id="@+id/sliding_menu"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <Button
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:onClick="test"
            android:text="測試" />
    </LinearLayout>
    <!-- 主介面 -->
    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@android:color/holo_green_light">

        <Button
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:onClick="toggle"
            android:text="顯示" />
    </FrameLayout>
</com.trkj.lizanhong.chapter9.SliderMenu>

定義繼承自 Activity 名為 MainActivity 類,該類中響應 FrameLayout 佈局中的按鈕的單擊事件,用於顯示或隱藏側邊欄。內容如下:

public class MainActivity extends ActionBarActivity {
private SliderMenu sm;
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.sliding_menu);
        sm = (SliderMenu) findViewById(R.id.sm);
        this.setTitle("側邊欄");
    }
    public void test(View view){
        Toast.makeText(this, "test", Toast.LENGTH_LONG).sho w();
    }
    public void toggle(View view){
        sm.toggle();
    }
}

繼承自HorizontalScrollView 的側邊欄

和上一側邊欄的實現方案相比,繼承自 HorizontalScrollView 顯然能減少程式碼量,降低開發難度,但因受制於 HorizontalScrollView 靈活性也隨之降低。本章我們將實現一個繼承自
HorizontalScrollView 類的側邊欄。

這次實現的側邊欄在功能上有了少許變化:
Ø 在任何地方滑動都可以開啟側邊欄;
Ø 滑動距離如果不超過側邊欄一半,則回退;
Ø 不再支援分割線。

HorizontalScrollView 作為水平滾動檢視,已經實現了平滑滾動的功能,這可以讓我們不再通過 Scroller 實現自動滑動。HorizontalScrollView 定義的平滑滾動方法原型為:

public final void smoothScrollTo(int x, int y)
該方法和 scrollTo()方法不同,支援平滑滾動,而不是直接跳到(x,y)位置。與之對應的還有 public final void smoothScrollBy(int dx, int dy)方法,該方法類似於 scrollBy()方法,但同樣支援平滑滾動。

另外,滾動檢視有一個規則,子元件只能有一個,所以,我們必須將側邊欄和主介面使用一個水平的 LinarLayout 包裹起來,因此,佈局檔案的結構與上一個側邊欄也會有所不同。

側邊欄支援指定寬度,在 attrs.xml 中新增如下的配置:

<declare-styleable name="SlidingMenu">
    <!-- 指定側邊欄的寬度 -->
    <attr name="left_padding_width" format="dimension"/>
</declare-styleable>

在 onMeasure()方法中測量元件寬度時,必須指定側邊欄和主介面的寬度,側邊欄寬度由
left_padding_width 屬性指定,並應用到元件的 LayoutParams 屬性中;主介面的寬度和螢幕的高度相同。HorizontalScrollView 已經為每個元件進行了定位,所以我們也不再在重寫 onLayout()方法中為每個子元件定位。

/**
* 指定側邊欄和主介面的寬
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    once = true;
    //水平滾動 View 只能有一個直接子元件
    LinearLayout subView = (LinearLayout) this.getChildAt(0);
    //指定側邊欄和主介面的寬
    //第 0 個子元件就是側邊欄,第 1 個元件就是主介面
    LinearLayout slidingMenu =
    (LinearLayout) subView.getChildAt(0);
    //第 1 個子元件是主介面
    ViewGroup content = (ViewGroup) subView.getChildAt(1);
    //設定側邊的寬度
    slidingMenu.getLayoutParams().width = leftPaddingWidth;
    //設定主介面的寬度
    content.getLayoutParams().width = this.getScreenWidth();
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

而在 onLayout()方法中,我們只需要在執行時將側邊欄隱藏起來。

/**
* 初始狀態下在該方法中隱藏側邊欄
*/
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    super.onLayout(changed, l, t, r, b);
    //下面的程式碼只調用一次
    if(once){
        //隱藏側邊欄
        this.scrollTo(leftPaddingWidth, 0);
    }
        once = false;
}

HorizontalScrollView 類中早已實現了子元件的平滑滾動,我們要做的就是當手指鬆開後的慣性滾動。當手指滑動距離超過側邊欄的一半則繼續向前滑動,否則回滾到初始狀態。

@Override
public boolean onTouchEvent(MotionEvent ev) {
    //當手指鬆開的時候,決定側邊欄是顯示還是隱藏
    //如果側邊欄滾出螢幕的寬度大於側邊欄的一半,則隱藏側邊欄
    //如果側邊欄滾出螢幕的寬度小於等於側邊欄的一半,則顯示側邊欄
    if(ev.getAction() == MotionEvent.ACTION_UP){
        int dx = this.getScrollX();
        int halfWidth = this.leftPaddingWidth / 2;
        Log.i("SlidingMenu", "dx:" + this.getScrollX() + " halfWidth:" + halfWidth);
            if(dx < halfWidth){
                //顯示側邊欄
                this.smoothScrollTo(0, 0);
                Log.i("SlidingMenu", "顯示");
                this.isOpen = true;
            }else{
                //隱藏側邊欄
                this.smoothScrollTo(leftPaddingWidth, 0);
                Log.i("SlidingMenu", "隱藏");
                this.isOpen = false;
            }
        return true;
    }
    return super.onTouchEvent(ev);
}

SlidingMenu 的全部原始碼如下(省略了前面已列出的程式碼):


public class SlidingMenu extends HorizontalScrollView {
    private int leftPaddingWidth; //側邊欄的寬度
    private boolean isOpen = false;//側邊欄是否開啟
    private boolean once = false;//預設只隱藏一次
    /**
     * 獲取螢幕寬度
     * @return
     */
    private int getScreenWidth(){
//請參考上一案例
    }
    /**
     * 指定側邊欄和主介面的寬
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        ……
    }
    /**
     * 預設情況下在該方法中隱藏側邊欄
     */
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        ……
    }
    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        ……
    }
    /**
     * 開啟
     */
    public void open(){
        if(!isOpen){
            this.smoothScrollTo(0, 0);
            isOpen = true;
        }
    }
    /**
     * 隱藏
     */
    public void hide(){
        if(isOpen){
                    this.smoothScrollTo(leftPaddingWidth, 0);
            isOpen = false;
        }
    }
    /**
     * 開啟隱藏
     */
    public void toggle(){
        if(isOpen){
            hide();
        }else{
            open();
        }
    }
    public SlidingMenu(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }
    public SlidingMenu(Context context, AttributeSet attrs) {
        super(context, attrs);
        TypedArray a = context.obtainStyledAttributes(attrs,
                R.styleable.SlidingMenu);
        leftPaddingWidth = a.getDimensionPixelSize(
                R.styleable.SlidingMenu_left_padding_width,
                (int) TypedValue.applyDimension(
                        TypedValue.COMPLEX_UNIT_DIP, 200,
                        context.getResources().getDisplayMetrics()));
        a.recycle();
    }
    public SlidingMenu(Context context) {
        super(context);
    }
}

定義 sliding_menu2.xml 佈局檔案,仔細比較該佈局檔案與上一解決方案中佈局檔案的區別。因為實現不同,所以二者可能會有一些不得已的限制。從 ViewGroup 上繼承也許是靈活性最好的。

<?xml version="1.0" encoding="utf-8"?>

<com.trkj.lizanhong.chapter9.SlidingMenu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:trkj="http://schemas.android.com/apk/res-auto"
    android:id="@+id/slidingMenu"
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    android:scrollbars="none"
    trkj:left_padding_width="200dp">

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:orientation="horizontal">
        <!-- 側邊欄 -->
        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:background="#FF0000"
            android:orientation="vertical"></LinearLayout>
        <!-- 主介面 -->
        <LinearLayout
            android:layout_width="wrap_content"

            android:layout_height="match_parent"
            android:background="#FFFFFF"
            android:orientation="vertical">

            <Button
                android:id="@+id/btn"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:onClick="toggle"
                android:text="側邊欄" />
        </LinearLayout>
    </LinearLayout>
</com.trkj.lizanhong.chapter9.SlidingMenu>

執行效果如圖所示。
這裡寫圖片描述

謝謝認真觀讀本文的每一位小夥伴,衷心歡迎小夥伴給我指出文中的錯誤,也歡迎小夥伴與我交流學習。
歡迎愛學習的小夥伴加群一起進步:230274309