Android開發技巧——實現設計師給出的視覺居中的佈局

weixin_33766168發表於2017-05-06

本篇主要是對自定義控制元件的測量方法(onMeasure(int widthMeasureSpec, int heightMeasureSpec))在實際場景中的運用。

在移動應用的設計中,經常有這樣的介面:某個介面的元素非常少,比如空列表介面,或者某某操作成功的介面,只有一兩個元素在中間。但是它們在某個佈局裡又不是數學上的那個居中,而是經過設計師調出來的“視覺居中”。這種“視覺居中”內部是怎麼計算的,我大致也不懂,反正結果就是設計師們看起來要顯示的資訊給人有感覺是在中間的(通常是比中間偏上一點)。
既是這樣,那我們在佈局中就不能用gravity="center"layout_gravity="center"等這樣的屬性來設定了。而使用固定的padding或margin來調整它的位置,也難以在不同的螢幕解析度中實現同樣的效果,那就只好按鈕設計圖的標註,按比例來計算它的位置了。

按比例來調整子view與layout中的距離,在約束佈局(ConstraintLayout)中是可以做到的,但是在我個人看來相對這樣簡單的需求,約束佈局有點重了,並且它的依賴在不同方式的編譯下總是很容易出問題(比如在自己電腦編譯通過,在travis-ci上編譯就提示找不到該庫的某個版本),還有拖拽生成的程式碼格式不是很整齊(我有程式碼潔癖),總需要自己再去格式化一下程式碼。那麼自定義實現一下也是可以的嘛。

首先像這樣簡單的介面,通常來說,使用LinearLayout就足夠了。我們所需要的只是按比例計算出padding然後設進去,那麼內容就能夠按我們想要的位置去顯示了。在這裡,我把這個佈局定義為PaddingWeightLinearLayout,即可以按權重來設計它的padding。它提供了四個屬性:

    <declare-styleable name="PaddingWeightLinearLayout">
        <attr name="ppLeftPadding" format="integer"/>
        <attr name="ppRightPadding" format="integer"/>
        <attr name="ppTopPadding" format="integer"/>
        <attr name="ppBottomPadding" format="integer"/>
    </declare-styleable>

分別代表每個方向上的padding的權重。
在構造方法裡獲取這些屬性的值:

    private final int mTopWeight;
    private final int mBottomWeight;
    private final int mLeftWeight;
    private final int mRightWeight;

    public PaddingWeightLinearLayout(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);

        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.PaddingWeightLinearLayout);
        mTopWeight = ta.getInteger(R.styleable.PaddingWeightLinearLayout_ppTopPadding, 0);
        mBottomWeight = ta.getInteger(R.styleable.PaddingWeightLinearLayout_ppBottomPadding, 0);
        mLeftWeight = ta.getInteger(R.styleable.PaddingWeightLinearLayout_ppLeftPadding, 0);
        mRightWeight = ta.getInteger(R.styleable.PaddingWeightLinearLayout_ppRightPadding, 0);
        ta.recycle();
    }

那麼接下來,我們只需要計算出所有子View所佔的空間,然後計算出水平和豎直方向上剩餘的空間,按比例分給這四個padding就可以了。之所以使用LinearLayout是因為它是線性佈局,子View按線性排列,比較利於計算。如下圖(電腦的圖畫不好,獻醜了):
LinearLayout內容示意圖
圖1表示的是水平方向的佈局,那麼它的內容所佔的大小是:

  • 寬度為所有子View的寬度加上其左右Margin的總和。
  • 高度為子View高度加上其上下Margin中最高的一個。

圖2 是豎直方向的佈局,它的內容所佔的大小是:

  • 寬度為子View寬度加上其左右Margin中最大的一個。
  • 高度為所有子View的高度加上其上下Margin的總和。

因此,我們要先測量出子View的大小,然後再進行計算。
測試是在onMeasure(int widthMeasureSpec, int heightMeasureSpec)方法中進行的。其中引數分別表示父佈局能夠提供給它的空間。這個int型別的引數分為兩部分,高2位表示的是模式(mode),後面的30位表示的是具體的大小。這裡的mode一共有三個:

  • UNSPECIFIED 父View對子View沒有任何約束,可以隨意指定大小
  • EXACTLY 父View給子View一個固定的大小,子View會被這些邊界限制,不管它自己想要多大
  • AT_MOST 子檢視的大小可以是自己想要的值,但是不能超過指定的值

當我們要計運算元View時,我們需要呼叫子View的measure(widthMeasureSpec, int heightMeasureSpec)方法,為了能夠得到子View的確定大小,我們需要將widthMeasureSpec mode指定為AT_MOST,程式碼如下(以下程式碼都是在onMeasure(int widthMeasureSpec, int heightMeasureSpec)方法內):

        int layoutWidth = MeasureSpec.getSize(widthMeasureSpec);
        int layoutHeight = MeasureSpec.getSize(heightMeasureSpec);

        int widthSpec = MeasureSpec.makeMeasureSpec(widthMeasureSpec, MeasureSpec.AT_MOST);
        int heightSpec = MeasureSpec.makeMeasureSpec(heightMeasureSpec, MeasureSpec.AT_MOST);

然後遍歷所有子View,計運算元View寬高的總和及最大值:

        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            child.measure(widthSpec, heightSpec);
            LayoutParams params = (LayoutParams) child.getLayoutParams();
            int width = child.getMeasuredWidth() + params.leftMargin + params.rightMargin;
            int height = child.getMeasuredHeight() + params.topMargin + params.bottomMargin;
            totalWidth += width;
            totalHeight += height;
            if (width > maxWidth) {
                maxWidth = width;
            }
            if (height > maxHeight) {
                maxHeight = height;
            }
        }

然後計算出在水平及豎直方向上剩餘的空間:

        int spaceHorizontal;
        int spaceVertical;
        if (getOrientation() == VERTICAL) {
            spaceHorizontal = layoutWidth - maxWidth;
            spaceVertical = layoutHeight - totalHeight;
        } else {
            spaceHorizontal = layoutWidth - totalWidth;
            spaceVertical = layoutHeight - maxHeight;
        }
        if (spaceHorizontal < 0) {
            spaceHorizontal = 0;
        }
        if (spaceVertical < 0) {
            spaceVertical = 0;
        }

最後算出各個方向的padding,設定進去,然後重新呼叫父類的onMeasure(int, int)方法:

        int paddingLeft = getPaddingLeft();
        int paddingRight = getPaddingRight();
        int paddingTop = getPaddingTop();
        int paddingBottom = getPaddingBottom();

        int horizontalWeight = mLeftWeight + mRightWeight;
        if (spaceHorizontal > 0 && horizontalWeight > 0) {
            paddingLeft = mLeftWeight * spaceHorizontal / horizontalWeight;
            paddingRight = spaceHorizontal - paddingLeft;
        }

        int verticalWeight = mTopWeight + mBottomWeight;
        if (spaceVertical > 0 && verticalWeight > 0) {
            paddingTop = mTopWeight * spaceVertical / verticalWeight;
            paddingBottom = spaceVertical - paddingTop;
        }

        setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom);

        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

下面我們只需要在寫佈局程式碼的時候,按照設計圖填入標註的padding值,就可以按比例計算出內邊距並設定,從而讓我們的內容按照比例位置顯示了:

<com.parkingwang.widget.PaddingWeightLinearLayout
    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"
    android:background="@android:color/white"
    android:orientation="vertical"
    android:paddingLeft="@dimen/screen_padding"
    android:paddingRight="@dimen/screen_padding"
    app:ppBottomPadding="287"
    app:ppTopPadding="85">
    ... 內容佈局程式碼
</com.parkingwang.widget.PaddingWeightLinearLayout>

下面分別是實現的效果以及設計圖,可以看到,在內容區域它們所在的位置是相同的(由於螢幕解析度的關係,大小會有微小差異)。
效果圖對比

完整程式碼請參見Github專案:https://github.com/msdx/androidsnippet

相關文章