可滑動的自動換行的ViewGroup-ScrollableFlowLayout

實現效果

1、可以根據子View的寬度自動換行
2、子View的高度超過layout的大小時可以滑動
3、根據需要設置子View的Gravity
4、如果需要,可以使用LayoutTransition設置子View添加刪除時的動畫效果

這裏寫圖片描述

實現自動換行以及可自定義Gravity

實現一個可以自動換行的Flowlayout。

1、onMeasure

遍歷子View測量大小,算出每一行的寬度以及總的高度和寬度。
當對齊方式不是 top|left 時需要根據總的高度和每一行的寬度來決定子View應該放置在哪裏;當layout的寬高設置是wrap_contant時可以根據總寬高設置大小。
累加子View的寬度,如果超過layout的寬度,判斷爲需要換行,將當前行中子View的最大高度記爲當前行的高度,總高度累加。
代碼:


    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

        mLineWidthList.clear();
        totalLineHeight = 0;
        int totalWidth = 0;

        int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
        int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);
        int modeWidth = MeasureSpec.getMode(widthMeasureSpec);
        int modeHeight = MeasureSpec.getMode(heightMeasureSpec);

        //去掉padding和margin後實際的寬和高
        int parentUsableWidth = sizeWidth - getPaddingLeft() - getPaddingRight();
        int parentUsableHeight = sizeHeight - getPaddingTop() - getPaddingBottom();
        Log.e(TAG, "parentUsableWidth: " + parentUsableWidth + ", parentUsableHeight: " + parentUsableHeight);

        int currentLineWidth = 0;   
        int currentLineHeight = 0;

        int childCount = getChildCount();
        if (childCount <= 0) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            return;
        }
        for (int i = 0; i < childCount; i++) {
            View childView = getChildAt(i);
            //測量子View
            measureChild(childView, widthMeasureSpec, heightMeasureSpec);
            Log.d(TAG, String.format("measureChildWithMargins(%s,%s,%s,%s)", widthMeasureSpec, currentLineWidth, heightMeasureSpec, totalLineHeight));

            //帶Margin的
            MarginLayoutParams lp = (MarginLayoutParams) childView.getLayoutParams();
            int childWidth = childView.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
            int childHeight = childView.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;

            Log.d(TAG, "childWidth: " + childWidth + ", childHeight: " + childHeight +
                    String.format(", child margin:%s,%s,%s,%s", lp.leftMargin, lp.rightMargin, lp.topMargin, lp.bottomMargin));
            if (currentLineWidth + childWidth > parentUsableWidth) {
                //換行處理
                Log.v(TAG, "換行," + (currentLineWidth + childWidth) + "," + parentUsableWidth);
                totalLineHeight += currentLineHeight;   //累加高度
                mLineWidthList.add(currentLineWidth);   //記錄當前行寬
                totalWidth = Math.max(currentLineWidth, totalWidth);
                currentLineWidth = childWidth;
                currentLineHeight = childHeight;
            } else {
                currentLineWidth += childWidth;
                currentLineHeight = Math.max(childHeight, currentLineHeight);
            }

            //遍歷到最後一個ChildView時未換行, 單獨處理
            if (i == childCount - 1) {
                Log.v(TAG, "換行," + (currentLineWidth + childWidth) + "," + parentUsableWidth);
                totalLineHeight += currentLineHeight;
                mLineWidthList.add(currentLineWidth);
                totalWidth = Math.max(currentLineWidth, totalWidth);
            }

            //根據需要,是否要將子View總的寬高設爲layout的寬高
            setMeasuredDimension(modeWidth == MeasureSpec.EXACTLY ? sizeWidth : totalWidth + getPaddingLeft() + getPaddingRight(),
                    modeHeight == MeasureSpec.EXACTLY ? sizeHeight : totalLineHeight + getPaddingTop() + getPaddingBottom());
        }
    }

2、onLayout

使用onMeasure的測量結果根據設定的對齊方式在正確的位置放置子View。

1)對齊方式

對齊方式在attrs.xml裏面使用枚舉類型,安卓的Gravity類中幾個值爲:

    CENTER = 17;           //0001 0001
    CENTER_VERTICAL = 16;  //0001 0000
    CENTER_HORIZONTAL = 1; //0000 0001
    LEFT = 3;              //0000 0011
    RIGHT = 5;             //0000 0101
    BOTTOM = 80;           //0101 0000
    TOP = 48;              //0011 0000

attrs.xml裏面這麼寫

    <declare-styleable name="flowlayout">
        <!--<attr name="child_gravity" format="integer"/>-->
        <attr name="child_gravity">
            <enum name="center" value="17" />
            <enum name="center_vertical" value="16" />
            <enum name="center_horizontal" value="1" />
            <enum name="left" value="3" />
            <enum name="right" value="5" />
            <enum name="top" value="48" />
            <enum name="bottom" value="80" />
        </attr>
    </declare-styleable>

可以看出,只要將在佈局xml的標籤屬性中的到的實際的gravity值與Gravity類中這些值相與,判斷結果和這些值相不相等就可以知道設置的是哪個值了。
從設計的值和實際道理上可以看出,CENTER_VERTICAL跟BOTTOM、TOP會衝突,而且CENTER的優先級肯定是要低的,只要設置了bottom或是top,垂直居中就會無效。
處理的代碼入下:

    /**
     * 根據對齊方式計算當前行最左上角那個點的座標
     * @param lineWidth 當前行寬
     * @return Point x代表當前行的起始的橫座標(left),y代表當前行頂部的座標(top)
     */
    private Point getCurrentTopLeft(int parentLeft, int parentTop, int parentHeight, int parentWidth, int lineWidth) {
        Log.d(TAG, String.format("getCurrentTopLeft(%s,%s,%s,%s,%s) ", parentLeft, parentTop, parentHeight, parentWidth, lineWidth) + "totalLineHeight:" + totalLineHeight);
        Point point = new Point(getPaddingLeft(), getPaddingTop());
        if ((mChildGravity & Gravity.CENTER) == Gravity.CENTER) {
            Log.d(TAG, "child gravity = CENTER");
            point.x = (parentWidth - lineWidth) / 2;
            point.y = (parentHeight - totalLineHeight) / 2;
        }

        if ((mChildGravity & Gravity.CENTER_VERTICAL) == Gravity.CENTER_VERTICAL) {
            Log.d(TAG, "child gravity = CENTER_VERTICAL");
            point.y = (parentHeight - totalLineHeight) / 2;
        }
        if ((mChildGravity & Gravity.CENTER_HORIZONTAL) == Gravity.CENTER_HORIZONTAL) {
            Log.d(TAG, "child gravity = CENTER_HORIZONTAL");
            point.x = (parentWidth - lineWidth) / 2;
        }
        if ((mChildGravity & Gravity.LEFT) == Gravity.LEFT) {
            Log.d(TAG, "child gravity = LEFT");
            point.x = getPaddingLeft();
        }
        if ((mChildGravity & Gravity.RIGHT) == Gravity.RIGHT) {
            Log.d(TAG, "child gravity = RIGHT");
            point.x = parentWidth - getPaddingLeft() - lineWidth;
        }
        if ((mChildGravity & Gravity.BOTTOM) == Gravity.BOTTOM) {
            Log.d(TAG, "child gravity = BOTTOM");
            point.y = parentHeight - totalLineHeight;
        }
        if ((mChildGravity & Gravity.TOP) == Gravity.TOP) {
            Log.d(TAG, "child gravity = TOP");
            point.y = getPaddingTop();
        }
        return point;
    }

默認對齊爲左上,所以point初始爲(getPaddingLeft(), getPaddingTop()),處理垂直方向上的對齊是設置point.y, 水平就point.x。最後可以得到子View們整體的左上角的起始點。dian

2)放置子View

得到了每一行最左上角的點之後,就可以根據那個點來從左到右放置子View了:

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

        //獲得在onMeasure方法中計算後得到的這個FlowLayout的寬度, 用的currentRight包含了getPaddingLeft,所以可用大小要加回去
        int parentUsableWidth = getWidth() - getPaddingLeft() - getPaddingRight() + getPaddingLeft();
        Log.e(TAG, "layout parentUsableWidth: " + parentUsableWidth);

        int childCount = getChildCount();
        if (childCount <= 0) {
            //super是個抽象方法。。
            return;
        }

        int currLine = 0; //當前行遊標
        Point point = getCurrentTopLeft(getLeft(), getTop(), getHeight(), getWidth(), mLineWidthList.get(currLine++));
        int currentRight = point.x; //代表左上角點的兩個座標
        int currentTop = point.y;

        int currentLineHeight = 0;

        for (int i = 0; i < childCount; i++) {
            View childView = getChildAt(i);
            MarginLayoutParams lp = (MarginLayoutParams) childView.getLayoutParams();
            int childWidth = childView.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
            int childHeight = childView.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;

            if (currentRight + childWidth > parentUsableWidth) {
                //換行處理
                currentTop += currentLineHeight;
                currentRight = getCurrentTopLeft(getLeft(), getTop(), getHeight(), getWidth(), mLineWidthList.get(currLine++)).x;
                currentLineHeight = 0;
            }

            Log.d(TAG, "child layout, top: " + currentTop + ", left: " + currentRight);
            childView.layout(currentRight + lp.leftMargin, currentTop + lp.topMargin,
                    currentRight + childWidth - lp.rightMargin, currentTop + childHeight - lp.bottomMargin);
            //爲下一個view的layout設置當前狀態
            currentLineHeight = Math.max(currentLineHeight, childHeight);
            currentRight += childWidth;
        }

        //糾正一下子View的整體位置,當有上下滑動過,糾正位移是必要的
        if (isViewAdded || isViewRemoved) {
            correctScrollY();
            isViewAdded = false;
            isViewRemoved = false;
        }
    }

實現可滑動 Scrollable

1、控制觸摸事件的分發

如果手指滑動的垂直方向超過一定距離的話判斷爲需要scrollY並且阻止傳遞給子View,如果不是,觸摸事件正常傳遞。滑動距離的閾值可以這樣獲得:int touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                Log.d(TAG, "onInterceptTouchEvent, ACTION_DOWN");
                mLastMotionY = ev.getY();
                scrollEnable = false;
            case MotionEvent.ACTION_MOVE:
                Log.d(TAG, "onInterceptTouchEvent, ACTION_MOVE");
                if (Math.abs(mLastMotionY - ev.getY()) > touchSlop) {
                    Log.d(TAG, "onInterceptTouchEvent, return true");
                    return true;    //不傳遞
                }
        }
        return false;
    }

2、滑動

滑動時,上下滑動不能無限制的任意上滑下滑,
當子View的總高度沒有超出layout的高度時不能滑動(或者可以滑,但是鬆手後要糾正scrollY),
當第一個子View的top比layout的top低時在鬆手後要糾正一下scrollY,最後一個子View的bottom也要注意。
onTouchEvent:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        super.onTouchEvent(event);

        float y = event.getY();
        int firstChildTop = getChildAt(0).getTop();
        int lastChildBottom = getChildAt(getChildCount() - 1).getBottom();
        if (firstChildTop > 0 && lastChildBottom < getHeight()) {   //子View沒有超出layout,不滑動
            return true;
        }
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                Log.d(TAG, "onTouchEvent, ACTION_DOWN");
                mLastMotionY = y; //記住開始落下的點
                break;
            case MotionEvent.ACTION_MOVE:
                int detaY = (int) (mLastMotionY - y);
                Log.d(TAG, "onTouchEvent ACTION_MOVE, detaY=" + detaY + ", touchSlop=" + touchSlop);
                if (scrollEnable || Math.abs(detaY) > touchSlop) {
                    scrollBy(0, detaY);
                    mLastMotionY = y;
                    scrollEnable = true;
                }
                break;
            case MotionEvent.ACTION_UP:
                Log.d(TAG, "onTouchEvent ACTION_UP, firstChildTop=" + firstChildTop +
                        ", lastChildBottom=" + lastChildBottom +
                        ", ScrollY=" + getScrollY());
                Point point = getCurrentTopLeft(getLeft(), getTop(), getHeight(), getWidth(), mLineWidthList.get(0));
                if (firstChildTop - getScrollY() > 0) { //第一個子View的頂部在layout的top下
                    scrollTo(0, point.y);
                } else if (lastChildBottom - getScrollY() < getHeight()) {  //最後一個子View的底部在layout的bottom上
                    scrollTo(0, totalLineHeight - getHeight() + point.y);
                }
        }
        return true;
    }

糾正ScrollY的方法:

    /**
     * 在滑動過之後,添加或者刪除View之後子View整體的Y位移需要調整
     *     要在onLayout之後用,不然拿到的top跟bottom是添加之前的值
     */
    void correctScrollY() {
        int firstChildTop = getChildAt(0).getTop();
        int lastChildBottom = getChildAt(getChildCount() - 1).getBottom();
        Log.d(TAG, "correctScrollY , firstChildTop=" + firstChildTop +
                ", lastChildBottom=" + lastChildBottom);
        if (firstChildTop > 0 && lastChildBottom < getHeight()) {
            Log.e(TAG, "correctScrollY");
            scrollTo(0, 0);
        } else {
            Point p = getCurrentTopLeft(getLeft(), getTop(), getHeight(), getWidth(), mLineWidthList.get(0));
            if (isViewAdded) {
                //添加View時滑動到底部(因爲新View總是在底部)
                scrollTo(0, totalLineHeight - getHeight() + p.y);
            } else if (isViewRemoved && lastChildBottom - getScrollY() < getHeight()) {
            //刪除了View後,如果最後一個子View的底部跟layout的底部之間有空隙,
            //就讓最後一個子View的底部跟layout的底部對齊
                scrollTo(0, totalLineHeight - getHeight() + p.y);
            }
        }
    }

添加刪除View時的動畫

在構造函數中這樣:

        mLayoutTransition = new LayoutTransition();
        this.setLayoutTransition(mLayoutTransition);

然後就通過LayoutTransition設置動畫。動畫應該簡潔,僅僅起到不要讓view出來的太突兀的作用就好。

源碼看https://github.com/xing2387/AndroidLearning

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章