Hacks布局篇-Hack5 創建定制的ViewGroup

自定義ViewGroup

作者:李旺成

時間:2016年5月7日


該 Hack 將介紹如何自定義 ViewGroup。

顯示撲克牌布局

假設現在要開發一款撲克牌游戲,需要創建類似下圖的布局來顯示玩家手里的牌。應該如何創建這樣的布局呢?

撲克牌游戲中的玩家手牌

要創建上面的布局并不困難,使用 margin 屬性便足以實現上述效果。例如,直接在 merge 標簽下排布 View 即可。

使用 <merge /> 標簽實現撲克牌布局

效果如下:

使用margin實現撲克牌布局1

看代碼:

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context="com.diygreen.androidhackslayout.Hack5Activity">
    <View
        android:layout_width="100dp"
        android:layout_height="150dp"
        android:background="#FF0000" />
    <View
        android:layout_width="100dp"
        android:layout_height="150dp"
        android:layout_marginLeft="30dp"
        android:layout_marginTop="20dp"
        android:background="#00FF00" />
    <View
        android:layout_width="100dp"
        android:layout_height="150dp"
        android:layout_marginLeft="60dp"
        android:layout_marginTop="40dp"
        android:background="#0000FF" />
</merge>

使用 RelativeLayout 實現撲克牌布局

使用 RelativeLayout 那更沒有問題了,效果如下:

使用margin實現撲克牌布局2

代碼:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context="com.diygreen.androidhackslayout.Hack5Activity">
    <View
        android:layout_width="100dp"
        android:layout_height="150dp"
        android:background="#FF0000" />
    <View
        android:layout_width="100dp"
        android:layout_height="150dp"
        android:layout_marginLeft="30dp"
        android:layout_marginTop="20dp"
        android:background="#00FF00" />
    <View
        android:layout_width="100dp"
        android:layout_height="150dp"
        android:layout_marginLeft="60dp"
        android:layout_marginTop="40dp"
        android:background="#0000FF" />
</RelativeLayout>

創建自定義 ViewGroup

使用 margin 并不是這個 Hack 要講解的內容,這里將介紹另一種方法:創建自定義 ViewGroup。

該方法相對于在 xml 文件中手工指定 margin 有如下優點:

  • 在不同 Activity 中復用該視圖時,更容易維護
  • 開發者可以使用自定義屬性來定制 ViewGroup 中子視圖的位置
  • 布局文件更簡明,更容易理解
  • 如果需要修改 margin,不必重新手動計算每個子視圖的 margin

(參考自:《50 Android Hacks》)

要自定義 ViewGroup 那需要先了解下 Android 中是怎么繪制 View 的。

View 的繪制流程

Android 的官方文檔上有一篇文章專門講解了 View 是怎么繪制的,有興趣的可以去看看:How Android Draws Views (Google 的官方文檔要翻墻,你也可以去這里看:How Android Draws Views,國內的 Android 在線文檔網站,在以前的文章中推薦過)。

可能沒有很多人喜歡看英文,我搬運一下《50 Android Hacks》上的介紹:
繪制布局由兩個遍歷過程組成:測量過程和布局過程。測量過程由 measure(int, int) 方法完成,該方法從上到下遍歷視圖樹。在遞歸遍歷過程中,每個視圖都會向下層傳遞尺寸和規格。當 measure 方法遍歷結束,每個視圖都保存了各自的尺寸信息。第二個過程由 layout(int, int, int) 方法完成,該方法也是由上而下遍歷視圖樹,在遍歷過程中,每個父視圖通過測量過程的結果定位所有子視圖的位置信息。(參考自:《50 Android Hacks》)

簡而言之:第一步,在 onMeasure() 方法中測量 ViewGroup 的尺寸;第二步,在 onLayout() 方法中利用第一步獲取的尺寸信息布局子視圖。

可以看一下流程圖:

View 的繪制流程

(參考了:Android應用層View繪制流程與源碼分析

說明:
關于 View 的繪制流程有不少結合源碼分析的文章,有興趣的可以去看看,如:
Android應用層View繪制流程與源碼分析
Android中View繪制流程
這些不是本文重點,就不展開了。

創建自定義 ViewGroup

上面已經演示過要實現的效果了,下面我們使用自定義 ViewGroup 來實現。效果如下:

自定義ViewGroup實現撲克牌布局

創建 CascadeLayout

將自定義 ViewGroup 命名為 CascadeLayout,繼承自 ViewGroup。根據 AndroidStudio 的提示,添加必要的方法:

public class CascadeLayout extends ViewGroup {

    public CascadeLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        
    }

}

定義自定義屬性

我們要創建的 CascadeLayout 的實現思路是這樣的,使用自定義屬性來定制其內部子視圖的位置。在 Framelayout 或者 RelativeLayout 中實現撲克牌布局使用的是 margin,也就是間距;再結合上面的效果圖仔細分析一下,這完全可以通過規定視圖的水平方向間距和垂直方向間距來實現。所以,可以定義兩個屬性,來分別表示水平方向間距和垂直方向間距。

1、創建屬性文件
在 res/values 目錄下創建一個 attrs.xml 文件,如果有這個文件那就直接打開。添加如下內容:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CascadeLayout">
        <attr name="horizontal_spacing" format="dimension" />
        <attr name="vertical_spacing" format="dimension" />
    </declare-styleable>
</resources>

對于自定義屬性不是很清楚的可以去看看這篇文章:Android 深入理解Android中的自定義屬性,這里就不展開了。

2、在構造方法中獲取自定義屬性

public class CascadeLayout extends ViewGroup {

    private int mHorizontalSpacing;
    private int mVerticalSpacing;

    public CascadeLayout(Context context, AttributeSet attrs) {
        super(context, attrs);

        TypedArray a = context.obtainStyledAttributes(attrs,
                R.styleable.CascadeLayout);

        try {
            mHorizontalSpacing = a.getDimensionPixelSize(
                    R.styleable.CascadeLayout_horizontal_spacing,
                    getResources().getDimensionPixelSize(
                            R.dimen.cascade_horizontal_spacing));

            mVerticalSpacing = a.getDimensionPixelSize(
                    R.styleable.CascadeLayout_vertical_spacing, getResources()
                            .getDimensionPixelSize(R.dimen.cascade_vertical_spacing));
        } finally {
            a.recycle();
        }

    }

}

3、重寫 onMeasure() 方法
一般的布局 ViewGroup 中都會有一個取名為 LayoutParams 的靜態內部類,在這里我們的 CascadeLayout 中也需要創建自定義 LayoutParamas 類,該類的作用是保存每個子視圖的 x、y 軸位置。

先看看 LayoutParamas 類:

public static class LayoutParams extends ViewGroup.LayoutParams {
    int x;
    int y;
    public int verticalSpacing;

    public LayoutParams(Context context, AttributeSet attrs) {
        super(context, attrs);

        TypedArray a = context.obtainStyledAttributes(attrs,
                R.styleable.CascadeLayout_LayoutParams);
        try {
            verticalSpacing = a
                    .getDimensionPixelSize(
                            R.styleable.CascadeLayout_LayoutParams_layout_vertical_spacing,
                            -1);
        } finally {
            a.recycle();
        }
    }

    public LayoutParams(int w, int h) {
        super(w, h);
    }

}

在該類中使用了子視圖的自定義屬性,該屬性的作用是為特定子視圖指定垂直間距。

第一步,在 attrs.xml 中添加一個新屬性,如下:

<declare-styleable name="CascadeLayout_LayoutParams">
    <attr name="layout_vertical_spacing" format="dimension" />
</declare-styleable>

因為屬性名的前綴是 layout_,沒有包含一個視圖屬性,因此該屬性會被添加到 LayoutParamas 的屬性表中。(這是從《50 Android Hacks》上直接摘抄下來的,話說我感覺不好理解)

第二步,在 LayoutParamas 類的構造函數中讀取屬性值
注意,LayoutParamas 中的 verticalSpacing 是一個公共字段,該字段會在 CascadeLayout 類的 onMeasure() 方法中使用到。如果子視圖的 LayoutParamas 包含了 verticalSpacing,就可以使用它(也就是設置了 layout_vertical_spacing 屬性,CascadeLayout_LayoutParams 中只定義了這一個屬性)。

關于子視圖的自定義屬性就介紹到這,繼續往下閱讀。

要使用新定義的 CascadeLayout.LayoutParamas 類,還需要重寫下面這些方法:

  • checkLayoutParams(ViewGroup.LayoutParams p)
  • generateDefaultLayoutParams()
  • generateLayoutParams(AttributeSet attrs)
  • generateLayoutParams(ViewGroup.LayoutParams p)

這些方法在不同的 ViewGroup 之間往往差別不大,關鍵是使用自己的靜態內部類 LayoutParamas,這里給出簡單的實現,你也可以參考一下 LinearLayout 是如何實現的,看代碼:

@Override
protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
    return p instanceof LayoutParams;
}

@Override
protected LayoutParams generateDefaultLayoutParams() {
    return new LayoutParams(LayoutParams.WRAP_CONTENT,
            LayoutParams.WRAP_CONTENT);
}

@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
    return new LayoutParams(getContext(), attrs);
}

@Override
protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
    return new LayoutParams(p.width, p.height);
}

好了,到 onMeasure() 方法了,這是 CascadeLayout 類的核心,里面有注釋,直接看代碼:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    // 使用寬和高計算布局的最終大小以及子視圖的 x 與 y 軸位置
    int width = getPaddingLeft();
    int height = getPaddingTop();
    int verticalSpacing;

    final int count = getChildCount();
    for (int i = 0; i < count; i++) {
        verticalSpacing = mVerticalSpacing;

        View child = getChildAt(i);
        // 讓每個子視圖測量自身
        measureChild(child, widthMeasureSpec, heightMeasureSpec);

        LayoutParams lp = (LayoutParams) child.getLayoutParams();
        width = getPaddingLeft() + mHorizontalSpacing * i;

        // 在 LayoutParamas 中保存每個子視圖的 x 和 y 坐標
        lp.x = width;
        lp.y = height;

        if (lp.verticalSpacing >= 0) {
            verticalSpacing = lp.verticalSpacing;
        }

        width += child.getMeasuredWidth();
        height += verticalSpacing;
    }

    width += getPaddingRight();
    height += getChildAt(getChildCount() - 1).getMeasuredHeight()
            + getPaddingBottom();

    // 使用計算所得的寬和高設置整個布局的測量尺寸
    setMeasuredDimension(resolveSize(width, widthMeasureSpec),
            resolveSize(height, heightMeasureSpec));
}

4、重寫 onLayout() 方法

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    final int count = getChildCount();
    for (int i = 0; i < count; i++) {
        View child = getChildAt(i);
        LayoutParams lp = (LayoutParams) child.getLayoutParams();
        child.layout(lp.x, lp.y, lp.x + child.getMeasuredWidth(), lp.y
                + child.getMeasuredHeight());
    }
}

這個方法就很簡單了,遍歷所有的子視圖,以 onMeasure() 方法計算出的值為參數傳入子視圖的 layout() 方法中即可。

在布局中使用

直接看代碼:

<?xml version="1.0" encoding="utf-8"?>
<com.diygreen.androidhackslayout.view.CascadeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:cascade="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="@dimen/activity_vertical_margin"
    tools:context="com.diygreen.androidhackslayout.Hack5Activity">
    <View
        android:layout_width="100dp"
        android:layout_height="150dp"
        cascade:layout_vertical_spacing="90dp"
        android:background="#FF0000" />
    <View
        android:layout_width="100dp"
        android:layout_height="150dp"
        android:background="#00FF00" />
    <View
        android:layout_width="100dp"
        android:layout_height="150dp"
        android:background="#0000FF" />
</com.diygreen.androidhackslayout.view.CascadeLayout>

小結

使用自定義 View 和 ViewGroup 組織應用程序布局是一個好方法。建議大家多多模仿系統提供的 View 和 Layout,不僅可了解 Android 的設計思路,也可以加深對 Android 視圖機制的理解。

提供我學習自定義 View 的思路:

  1. 了解自定義 View 的基本流程
  2. 學習簡單的自定義 View 的實現
  3. 看一些開源項目

說白了就是從易到難,循序漸進。當然我認為先了解流程是比較重要的,你會知道閱讀源碼的時候從何處著手。

項目地址

AndroidHacks合集
布局篇

示例用到代碼見:
Hack5Activity.java
CascadeLayout.java
attrs.xml
activity_hack5.xml
activity_hack5_1.xml
activity_hack5_2.xml

參考

Android應用層View繪制流程與源碼分析
Android中View繪制流程
Android 深入理解Android中的自定義屬性

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容