天天看點

Android自定義ViewGroup嵌套與互動實戰,幕布全屏滾動效果

作者:xTech
Android自定義ViewGroup嵌套與互動實戰,幕布全屏滾動效果

自定義 ViewGroup 全屏選中效果

前言

事情是這個樣子的,前幾天産品丢給我一個視訊,你覺得這個效果怎麼樣?我們的 App 也做一個這個效果吧!

Android自定義ViewGroup嵌套與互動實戰,幕布全屏滾動效果

我當時的反應:

Android自定義ViewGroup嵌套與互動實戰,幕布全屏滾動效果

開什麼玩笑!就沒見過這麼玩的,這不是坑人嗎?

此時産品幽幽的回了一句,“别人都能做,你怎麼不能做,并且iOS說可以做,還很簡單。”

我心裡一萬個不信,糟老頭子太壞了,想騙我?

Android自定義ViewGroup嵌套與互動實戰,幕布全屏滾動效果

我立馬和iOS同僚統一戰線,說不能做,實作不了吧。結果iOS同僚幽幽的說了一句 “已經做了,四行代碼完成”。

Android自定義ViewGroup嵌套與互動實戰,幕布全屏滾動效果

我勒個去,就指着我卷是吧。

這也沒辦法了,群裡問問大神有什麼好的方案,“xdm,車先減個速,(圖檔)這個效果怎麼實作?”

“做不了...”

“讓産品滾...”

“沒做過,也沒見過...”

“性能不好,不推薦,換方案吧。”

“GridView嵌套ScrollView , 要不RV嵌套RV?...”

“不理他,繼續開車...”

...群裡技術氛圍果然沒有讓我失望,哎,看來還是得靠自己,擡頭望了望天天,扣了扣腦闊,無語啊。

好了,說了這麼多玩笑話,回歸正題,其實關于标題的這種效果,确實是對性能的開銷更大,且網上相關開源的項目也幾乎沒找到。

到底怎麼做呢?相信跟着我一起複習的小夥伴們心裡都有了一點雛形。自定義ViewGroup。

下面跟着我一起再次鞏固一次 ViewGroup 的測量與布局,加上事件的處理,就能完成對應的功能。

話不多說,Let's go

Android自定義ViewGroup嵌套與互動實戰,幕布全屏滾動效果

一、布局的測量與布局

首先GridView嵌套ScrollView,RV 嵌套 RV 什麼的,就寬度就限制死了,其次滾動方向也固定死了,不好做。

肯定是選用自定義 ViewGroup 的方案,自己測量,自己布局,自己實作滾動與縮放邏輯。

從産品發的競品App的視訊來看,我們需要先明确三個變量,一行顯示多少個Item、垂直距離每一個Item的間距,水準距離每一個Item的間距。

然後我們測量每一個ItemView的寬度,每一個Item的寬度加起來就是ViewGroup的寬度,每一個Item的高度加起來就是ViewGroup的高度。

我們目前先不限定Item的寬高,先試着測量一下:

class CurtainViewContrainer extends ViewGroup {

    private int horizontalSpacing = 20;  //每一個Item的左右間距
    private int verticalSpacing = 20;  //每一個Item的上下間距
    private int mRowCount = 6;   // 一行多少個Item

    private Adapter mAdapter;

    public CurtainViewContrainer(Context context) {
        this(context, null);
    }

    public CurtainViewContrainer(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CurtainViewContrainer(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        init();
    }

    private void init() {
        setClipChildren(false);
        setClipToPadding(false);
    }

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

        final int sizeWidth = MeasureSpec.getSize(widthMeasureSpec) - this.getPaddingRight() - this.getPaddingLeft();
        final int modeWidth = MeasureSpec.getMode(widthMeasureSpec);

        final int sizeHeight = MeasureSpec.getSize(heightMeasureSpec) - this.getPaddingTop() - this.getPaddingBottom();
        final int modeHeight = MeasureSpec.getMode(heightMeasureSpec);


        int childCount = getChildCount();

        if (mAdapter == null || mAdapter.getItemCount() == 0 || childCount == 0) {
            setMeasuredDimension(sizeWidth, 0);
            return;
        }

        int curCount = 1;
        int totalControlHeight = 0;
        int totalControlWidth = 0;
        int layoutChildViewCurX = this.getPaddingLeft();
        int curRow = 0;
        int curColumn = 0;
        SparseArray<Integer> rowWidth = new SparseArray<>(); //全部行的寬度

        //開始周遊
        for (int i = 0; i < childCount; i++) {
            View childView = getChildAt(i);

            int row = curCount / mRowCount;    //目前子View是第幾行
            int column = curCount % mRowCount; //目前子View是第幾列

            //測量每一個子View寬度
            measureChild(childView, widthMeasureSpec, heightMeasureSpec);

            int width = childView.getMeasuredWidth();
            int height = childView.getMeasuredHeight();

            boolean isLast = (curCount + 1) % mRowCount == 0;

            if (row == curRow) {
                layoutChildViewCurX += width + horizontalSpacing;
                totalControlWidth += width + horizontalSpacing;

                rowWidth.put(row, totalControlWidth);


            } else {
                //已經換行了
                layoutChildViewCurX = this.getPaddingLeft();
                totalControlWidth = width + horizontalSpacing;

                rowWidth.put(row, totalControlWidth);

                //添加高度
                totalControlHeight += height + verticalSpacing;
            }

            //最多隻擺放9個
            curCount++;
            curRow = row;
            curColumn = column;
        }

        //循環結束之後開始計算真正的寬度
        List<Integer> widthList = new ArrayList<>(rowWidth.size());
        for (int i = 0; i < rowWidth.size(); i++) {
            Integer integer = rowWidth.get(i);
            widthList.add(integer);
        }

        Integer maxWidth = Collections.max(widthList);

        setMeasuredDimension(maxWidth, totalControlHeight);

    }

複制代碼           

當遇到高度不統一的情況下,就會遇到問題,是以我們記錄一下每一行的最高高度,用于計算控件的測量高度。

雖然這樣測量是沒有問題的,但是布局還是有坑,姑且先這麼測量:

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

        int childCount = getChildCount();


        int curCount = 1;
        int layoutChildViewCurX = l;
        int layoutChildViewCurY = t;

        int curRow = 0;
        int curColumn = 0;
        SparseArray<Integer> rowWidth = new SparseArray<>(); //全部行的寬度

        //開始周遊
        for (int i = 0; i < childCount; i++) {
            View childView = getChildAt(i);

            int row = curCount / mRowCount;    //目前子View是第幾行
            int column = curCount % mRowCount; //目前子View是第幾列

            //每一個子View寬度

            int width = childView.getMeasuredWidth();
            int height = childView.getMeasuredHeight();


            childView.layout(layoutChildViewCurX, layoutChildViewCurY, layoutChildViewCurX + width, layoutChildViewCurY + height);

            if (row == curRow) {
                //同一行
                layoutChildViewCurX += width + horizontalSpacing;

            } else {
                //換行了
                layoutChildViewCurX = l;
                layoutChildViewCurY += height + verticalSpacing;
            }

            //最多隻擺放9個
            curCount++;
            curRow = row;
            curColumn = column;
        }

        performBindData();
    }
複制代碼           

這樣做并沒有緊挨着頭上的Item,目前我們把Item的寬高都使用同樣的大小,是勉強能看的,一旦高度不統一,就不能看了。

先不管那麼多,先固定大小顯示出來看看效果。

Android自定義ViewGroup嵌套與互動實戰,幕布全屏滾動效果

反正是能看了,一個寨版的 GridView ,但是超出了寬度的限制。接下來我們先做事件的處理,讓他動起來。

二、全屏滾動邏輯

首先我們需要把顯示的 ViewGroup 控件封裝為一個類,讓此ViewGroup在另一個ViewGroup内部移動,不然還能讓内部的每一個子View單獨移動嗎?肯定是整體一起移動更友善一點。

然後我們觸摸容器 ViewGroup 中控制子 ViewGroup 移動即可,那怎麼移動呢?

我知道,用 MotionEvent + Scroller 就可以滾動啦!

可以!又不可以,Scroller确實是可以動起來,但是在我們拖動與縮放之後,不能影響到内部的點選事件。

那可以不可以用 ViewDragHelper 來實作動作效果?

也不行,雖然 ViewDragHelper 是ViewGroup專門用于移動的幫助類,但是它内部其實還是封裝的 MotionEvent + Scroller。

而 Scroller 為什麼不行?

這種效果我們不能使用 Canvas 的移動,不能使用 Sroller 去移動,因為它們不能記錄移動後的 View 變化矩陣,我們需要使用基本的 setTranslation 來實作,自己控制矩陣的變化進而控制整個視圖樹。

我們把觸摸的攔截與事件的處理放到一個公用的事件處理類中:

public class TouchEventHandler {

    private static final float MAX_SCALE = 1.5f;  //最大能縮放值
    private static final float MIN_SCALE = 0.8f;  //最小能縮放值
    //目前的觸摸事件類型
    private static final int TOUCH_MODE_UNSET = -1;
    private static final int TOUCH_MODE_RELEASE = 0;
    private static final int TOUCH_MODE_SINGLE = 1;
    private static final int TOUCH_MODE_DOUBLE = 2;

    private View mView;
    private int mode = 0;
    private float scaleFactor = 1.0f;
    private float scaleBaseR;
    private GestureDetector mGestureDetector;
    private float mTouchSlop;
    private MotionEvent preMovingTouchEvent = null;
    private MotionEvent preInterceptTouchEvent = null;
    private boolean mIsMoving;
    private float minScale = MIN_SCALE;
    private FlingAnimation flingY = null;
    private FlingAnimation flingX = null;

    private ViewBox layoutLocationInParent = new ViewBox();  //移動中不斷變化的盒模型
    private final ViewBox viewportBox = new ViewBox();   //初始化的盒模型
    private PointF preFocusCenter = new PointF();
    private PointF postFocusCenter = new PointF();
    private PointF preTranslate = new PointF();
    private float preScaleFactor = 1f;
    private final DynamicAnimation.OnAnimationUpdateListener flingAnimateListener;
    private boolean isKeepInViewport = false;
    private TouchEventListener controlListener = null;
    private int scalePercentOnlyForControlListener = 0;

    public TouchEventHandler(Context context, View view) {
        this.mView = view;
        flingAnimateListener = (animation, value, velocity) -> keepWithinBoundaries();

        mGestureDetector = new GestureDetector(context,
                new GestureDetector.SimpleOnGestureListener() {
                    @Override
                    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
                        flingX = new FlingAnimation(mView, DynamicAnimation.TRANSLATION_X);
                        flingX.setStartVelocity(velocityX)
                                .addUpdateListener(flingAnimateListener)
                                .start();

                        flingY = new FlingAnimation(mView, DynamicAnimation.TRANSLATION_Y);
                        flingY.setStartVelocity(velocityY)
                                .addUpdateListener(flingAnimateListener)
                                .start();
                        return false;
                    }
                });
        ViewConfiguration vc = ViewConfiguration.get(view.getContext());
        mTouchSlop = vc.getScaledTouchSlop() * 0.8f;
    }

    /**
     * 設定内部布局視圖視窗高度和寬度
     */
    public void setViewport(int winWidth, int winHeight) {
        viewportBox.setValues(0, 0, winWidth, winHeight);
    }

    /**
     * 暴露的方法,内部處理事件并判斷是否攔截事件
     */
    public boolean detectInterceptTouchEvent(MotionEvent event) {
        final int action = event.getAction() & MotionEvent.ACTION_MASK;
        onTouchEvent(event);
        if (action == MotionEvent.ACTION_DOWN) {
            preInterceptTouchEvent = MotionEvent.obtain(event);
            mIsMoving = false;
        }
        if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
            mIsMoving = false;
        }
        if (action == MotionEvent.ACTION_MOVE && mTouchSlop < calculateMoveDistance(event, preInterceptTouchEvent)) {
            mIsMoving = true;
        }
        return mIsMoving;
    }

    /**
     * 目前事件的真正處理邏輯
     */
    public boolean onTouchEvent(MotionEvent event) {
        mGestureDetector.onTouchEvent(event);

        int action = event.getAction() & MotionEvent.ACTION_MASK;
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mode = TOUCH_MODE_SINGLE;
                preMovingTouchEvent = MotionEvent.obtain(event);

                if (flingX != null) {
                    flingX.cancel();
                }
                if (flingY != null) {
                    flingY.cancel();
                }
                break;
            case MotionEvent.ACTION_UP:
                mode = TOUCH_MODE_RELEASE;
                break;
            case MotionEvent.ACTION_POINTER_UP:
            case MotionEvent.ACTION_CANCEL:
                mode = TOUCH_MODE_UNSET;
                break;
            case MotionEvent.ACTION_POINTER_DOWN:
                mode++;
                if (mode >= TOUCH_MODE_DOUBLE) {
                    scaleFactor = preScaleFactor = mView.getScaleX();
                    preTranslate.set(mView.getTranslationX(), mView.getTranslationY());
                    scaleBaseR = (float) distanceBetweenFingers(event);
                    centerPointBetweenFingers(event, preFocusCenter);
                    centerPointBetweenFingers(event, postFocusCenter);
                }
                break;

            case MotionEvent.ACTION_MOVE:
                if (mode >= TOUCH_MODE_DOUBLE) {
                    //雙指縮放
                    float scaleNewR = (float) distanceBetweenFingers(event);
                    centerPointBetweenFingers(event, postFocusCenter);
                    if (scaleBaseR <= 0) {
                        break;
                    }
                    scaleFactor = (scaleNewR / scaleBaseR) * preScaleFactor * 0.15f + scaleFactor * 0.85f;
                    int scaleState = TouchEventListener.FREE_SCALE;
                    float finalMinScale = isKeepInViewport ? minScale : minScale * 0.8f;
                    if (scaleFactor >= MAX_SCALE) {
                        scaleFactor = MAX_SCALE;
                        scaleState = TouchEventListener.MAX_SCALE;
                    } else if (scaleFactor <= finalMinScale) {
                        scaleFactor = finalMinScale;
                        scaleState = TouchEventListener.MIN_SCALE;
                    }
                    if (controlListener != null) {
                        int current = (int) (scaleFactor * 100);
                        //回調
                        if (scalePercentOnlyForControlListener != current) {
                            scalePercentOnlyForControlListener = current;
                            controlListener.onScaling(scaleState, scalePercentOnlyForControlListener);
                        }
                    }
                    mView.setPivotX(0);
                    mView.setPivotY(0);
                    mView.setScaleX(scaleFactor);
                    mView.setScaleY(scaleFactor);
                    float tx = postFocusCenter.x - (preFocusCenter.x - preTranslate.x) * scaleFactor / preScaleFactor;
                    float ty = postFocusCenter.y - (preFocusCenter.y - preTranslate.y) * scaleFactor / preScaleFactor;
                    mView.setTranslationX(tx);
                    mView.setTranslationY(ty);
                    keepWithinBoundaries();
                } else if (mode == TOUCH_MODE_SINGLE) {
                    //單指移動
                    float deltaX = event.getRawX() - preMovingTouchEvent.getRawX();
                    float deltaY = event.getRawY() - preMovingTouchEvent.getRawY();
                    onSinglePointMoving(deltaX, deltaY);
                }
                break;
            case MotionEvent.ACTION_OUTSIDE:
                //外界的事件
                break;
        }
        preMovingTouchEvent = MotionEvent.obtain(event);
        return true;
    }

    /**
     * 計算兩個事件的移動距離
     */
    private float calculateMoveDistance(MotionEvent event1, MotionEvent event2) {
        if (event1 == null || event2 == null) {
            return 0f;
        }
        float disX = Math.abs(event1.getRawX() - event2.getRawX());
        float disY = Math.abs(event1.getRawX() - event2.getRawX());
        return (float) Math.sqrt(disX * disX + disY * disY);
    }

    /**
     * 單指移動
     */
    private void onSinglePointMoving(float deltaX, float deltaY) {
        float translationX = mView.getTranslationX() + deltaX;
        mView.setTranslationX(translationX);
        float translationY = mView.getTranslationY() + deltaY;
        mView.setTranslationY(translationY);
        keepWithinBoundaries();
    }

    /**
     * 需要保持在界限之内
     */
    private void keepWithinBoundaries() {
        //預設不在界限内,不做限制,直接傳回
        if (!isKeepInViewport) {
            return;
        }
        calculateBound();
        int dBottom = layoutLocationInParent.bottom - viewportBox.bottom;
        int dTop = layoutLocationInParent.top - viewportBox.top;
        int dLeft = layoutLocationInParent.left - viewportBox.left;
        int dRight = layoutLocationInParent.right - viewportBox.right;
        float translationX = mView.getTranslationX();
        float translationY = mView.getTranslationY();
        //邊界限制
        if (dLeft > 0) {
            mView.setTranslationX(translationX - dLeft);
        }
        if (dRight < 0) {
            mView.setTranslationX(translationX - dRight);
        }
        if (dBottom < 0) {
            mView.setTranslationY(translationY - dBottom);
        }
        if (dTop > 0) {
            mView.setTranslationY(translationY - dTop);
        }
    }

    /**
     * 移動時計算邊界,指派給本地的視圖
     */
    private void calculateBound() {
        View v = mView;
        float left = v.getLeft() * v.getScaleX() + v.getTranslationX();
        float top = v.getTop() * v.getScaleY() + v.getTranslationY();
        float right = v.getRight() * v.getScaleX() + v.getTranslationX();
        float bottom = v.getBottom() * v.getScaleY() + v.getTranslationY();
        layoutLocationInParent.setValues((int) top, (int) left, (int) right, (int) bottom);
    }

    /**
     * 計算兩個手指之間的距離
     */
    private double distanceBetweenFingers(MotionEvent event) {
        if (event.getPointerCount() > 1) {
            float disX = Math.abs(event.getX(0) - event.getX(1));
            float disY = Math.abs(event.getY(0) - event.getY(1));
            return Math.sqrt(disX * disX + disY * disY);
        }
        return 1;
    }

    /**
     * 計算兩個手指之間的中心點
     */
    private void centerPointBetweenFingers(MotionEvent event, PointF point) {
        float xPoint0 = event.getX(0);
        float yPoint0 = event.getY(0);
        float xPoint1 = event.getX(1);
        float yPoint1 = event.getY(1);
        point.set((xPoint0 + xPoint1) / 2f, (yPoint0 + yPoint1) / 2f);
    }

    /**
     * 設定視圖是否要保持在視窗中
     */
    public void setKeepInViewport(boolean keepInViewport) {
        isKeepInViewport = keepInViewport;
    }

    /**
     * 設定控制的監聽回調
     */
    public void setControlListener(TouchEventListener controlListener) {
        this.controlListener = controlListener;
    }
}
複制代碼           

由于内部封裝了移動與縮放的處理,是以我們隻需要在事件容器内部調用這個方法即可:

public class CurtainLayout extends FrameLayout {

    private final TouchEventHandler mGestureHandler;
    private CurtainViewContrainer mCurtainViewContrainer;
    private boolean disallowIntercept = false;

    public CurtainLayout(@NonNull Context context) {
        this(context, null);
    }

    public CurtainLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CurtainLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        setClipChildren(false);
        setClipToPadding(false);

        mCurtainViewContrainer = new CurtainViewContrainer(getContext());
        addView(mCurtainViewContrainer);

        mGestureHandler = new TouchEventHandler(getContext(), mCurtainViewContrainer);

        //設定是否在視窗内移動
        mGestureHandler.setKeepInViewport(false);
    }

    @Override
    public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
        super.requestDisallowInterceptTouchEvent(disallowIntercept);
        this.disallowIntercept = disallowIntercept;
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        return (!disallowIntercept && mGestureHandler.detectInterceptTouchEvent(event)) || super.onInterceptTouchEvent(event);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        return !disallowIntercept && mGestureHandler.onTouchEvent(event);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        mGestureHandler.setViewport(w, h);
    }
}
複制代碼           

對于一些複雜的處理都做了相關的注釋,接下來看看加了事件處理之後的效果:

Android自定義ViewGroup嵌套與互動實戰,幕布全屏滾動效果

已經可以自由拖動與縮放了,但是目前的測量與布局是有問題的,加下來我們抽取與優化一下。

三、抽取Adapter與LayoutManager

首先,内部的子View肯定是不能直接寫在 xml 中的,太不優雅了,加下來我們定義一個Adapter,用于填充資料,順便做一個多類型的布局。

public abstract class CurtainAdapter {

    //傳回總共子View的數量
    public abstract int getItemCount();

    //根據索引建立不同的布局類型,如果都是一樣的布局則不需要重寫
    public int getItemViewType(int position) {
        return 0;
    }

    //根據類型建立對應的View布局
    public abstract View onCreateItemView(@NonNull Context context, @NonNull ViewGroup parent, int itemType);

    //可以根據類型或索引綁定資料
    public abstract void onBindItemView(@NonNull View itemView, int itemType, int position);

}
複制代碼           

然後就是在繪制布局中通過設定 Apdater 來實作布局的添加與綁定邏輯。

public void setAdapter(CurtainAdapter adapter) {
        mAdapter = adapter;
        inflateAllViews();
    }

    public CurtainAdapter getAdapter() {
        return mAdapter;
    }

    //填充Adapter布局
    private void inflateAllViews() {
        removeAllViewsInLayout();

        if (mAdapter == null || mAdapter.getItemCount() == 0) {
            return;
        }

        //添加布局
        for (int i = 0; i < mAdapter.getItemCount(); i++) {

            int itemType = mAdapter.getItemViewType(i);

            View view = mAdapter.onCreateItemView(getContext(), this, itemType);

            addView(view);
        }

        requestLayout();
    }

    //綁定布局中的資料
    private void performBindData() {
        if (mAdapter == null || mAdapter.getItemCount() == 0) {
            return;
        }

        post(() -> {

            for (int i = 0; i < mAdapter.getItemCount(); i++) {
                int itemType = mAdapter.getItemViewType(i);
                View view = getChildAt(i);

                mAdapter.onBindItemView(view, itemType, i);
            }

        });

    }
複制代碼           

當然需要在指定的地方調用了,測量與布局中都需要處理。

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

        int childCount = getChildCount();

        if (mAdapter == null || mAdapter.getItemCount() == 0 || childCount == 0) {
            setMeasuredDimension(0, 0);
            return;
        }

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

        if (mAdapter == null || mAdapter.getItemCount() == 0) {
            return;
        }

         performLayout();

        performBindData();
       
    }
複制代碼           

接下來的重點就是我們對布局的方式進行抽象化,最簡單的肯定是上面這種寬高固定的,如果是垂直的排列,我們設定一個垂直的瀑布流管理器,設定寬度固定,高度自适應,如果寬度不固定,那麼是無法到達瀑布流的效果的。

同理對另一種水準排列的瀑布流我們設定高度固定,寬度自适應。

是以必須要設定 LayoutManager,如果不設定就抛異常。

接下來就是 LayoutManager 的接口與具體調用:

public interface ILayoutManager {

    public static final int DIRECTION_VERITICAL = 0;
    public static final int DIRECTION_HORIZONTAL = 1;

    public abstract int[] performMeasure(ViewGroup viewGroup, int rowCount, int horizontalSpacing, int verticalSpacing, int fixedValue);

    public abstract void performLayout(ViewGroup viewGroup, int rowCount, int horizontalSpacing, int verticalSpacing, int fixedValue);

    public abstract int getLayoutDirection();

}
複制代碼           

有了接口之後我們就可以先寫調用了:

class CurtainViewContrainer extends ViewGroup {

    private ILayoutManager mLayoutManager;
    private int horizontalSpacing = 20;  //每一個Item的左右間距
    private int verticalSpacing = 20;  //每一個Item的上下間距
    private int mRowCount = 6;   // 一行多少個Item
    private int fixedWidth = CommUtils.dip2px(150);  //如果是垂直瀑布流,需要設定寬度固定
    private int fixedHeight = CommUtils.dip2px(180); //先寫死,後期在抽取屬性

    private CurtainAdapter mAdapter;

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

        int childCount = getChildCount();

        if (mAdapter == null || mAdapter.getItemCount() == 0 || childCount == 0) {
            setMeasuredDimension(0, 0);
            return;
        }

        measureChildren(widthMeasureSpec, heightMeasureSpec);

        if (mLayoutManager != null && (fixedWidth > 0 || fixedHeight > 0)) {

            for (int i = 0; i < childCount; i++) {
                View childView = getChildAt(i);

                if (mLayoutManager.getLayoutDirection() == ILayoutManager.DIRECTION_VERITICAL) {
                    measureChild(childView,
                            MeasureSpec.makeMeasureSpec(fixedWidth, MeasureSpec.EXACTLY),
                            heightMeasureSpec);
                } else {
                    measureChild(childView,
                            widthMeasureSpec,
                            MeasureSpec.makeMeasureSpec(fixedHeight, MeasureSpec.EXACTLY));
                }
            }

            int[] dimensions = mLayoutManager.performMeasure(this, mRowCount, horizontalSpacing, verticalSpacing,
                    mLayoutManager.getLayoutDirection() == ILayoutManager.DIRECTION_VERITICAL ? fixedWidth : fixedHeight);
            setMeasuredDimension(dimensions[0], dimensions[1]);

        } else {
            throw new RuntimeException("You need to set the layoutManager first");
        }

    }

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

        if (mAdapter == null || mAdapter.getItemCount() == 0) {
            return;
        }

        if (mLayoutManager != null && (fixedWidth > 0 || fixedHeight > 0)) {
            mLayoutManager.performLayout(this, mRowCount, horizontalSpacing, verticalSpacing,
                    mLayoutManager.getLayoutDirection() == ILayoutManager.DIRECTION_VERITICAL ? fixedWidth : fixedHeight);

            performBindData();
        } else {
            throw new RuntimeException("You need to set the layoutManager first");
        }

    }
複制代碼           

那麼我們先來水準的LayoutManager,相對簡單一些,看看如何具體實作:

public class HorizontalLayoutManager implements ILayoutManager {

    @Override
    public int[] performMeasure(ViewGroup viewGroup, int rowCount, int horizontalSpacing, int verticalSpacing, int fixedHeight) {

        int childCount = viewGroup.getChildCount();
        int curCount = 0;
        int totalControlHeight = 0;
        int totalControlWidth = 0;
        int curRow = 0;
        SparseArray<Integer> rowTotalWidth = new SparseArray<>();  //每一行的總寬度

        //開始周遊
        for (int i = 0; i < childCount; i++) {
            View childView = viewGroup.getChildAt(i);

            int row = curCount / rowCount;    //目前子View是第幾行

            //已經測量過了,直接取寬高
            int width = childView.getMeasuredWidth();

            if (row == curRow) {
                //目前行
                totalControlWidth += width + horizontalSpacing;

            } else {
                //換行了
                totalControlWidth = width + horizontalSpacing;
            }

            rowTotalWidth.put(row, totalControlWidth);

            //指派
            curCount++;
            curRow = row;
        }

        //循環結束之後開始計算真正的寬高
        totalControlHeight = (rowCount * (fixedHeight + verticalSpacing)) - verticalSpacing +
                viewGroup.getPaddingTop() + viewGroup.getPaddingBottom();

        List<Integer> widthList = new ArrayList<>();
        for (int i = 0; i < rowTotalWidth.size(); i++) {
            Integer width = rowTotalWidth.get(i);
            widthList.add(width);
        }
        totalControlWidth = Collections.max(widthList);

        rowTotalWidth.clear();
        rowTotalWidth = null;

        return new int[]{totalControlWidth - horizontalSpacing, totalControlHeight - verticalSpacing};
    }

    @Override
    public void performLayout(ViewGroup viewGroup, int rowCount, int horizontalSpacing, int verticalSpacing, int fixedHeight) {
        int childCount = viewGroup.getChildCount();

        int curCount = 1;
        int layoutChildViewCurX = viewGroup.getPaddingLeft();
        int layoutChildViewCurY = viewGroup.getPaddingTop();

        int curRow = 0;

        //開始周遊
        for (int i = 0; i < childCount; i++) {
            View childView = viewGroup.getChildAt(i);

            int row = curCount / rowCount;    //目前子View是第幾行

            //每一個子View寬度
            int width = childView.getMeasuredWidth();

            childView.layout(layoutChildViewCurX, layoutChildViewCurY, layoutChildViewCurX + width, layoutChildViewCurY + fixedHeight);

            if (row == curRow) {
                //同一行
                layoutChildViewCurX += width + horizontalSpacing;

            } else {
                //換行了
                layoutChildViewCurX = childView.getPaddingLeft();
                layoutChildViewCurY += fixedHeight + verticalSpacing;
            }

            //指派
            curCount++;
            curRow = row;

        }
    }

    @Override
    public int getLayoutDirection() {
        return DIRECTION_HORIZONTAL;
    }
}

複制代碼           

對于水準的布局方式來說,高度是固定的,我們很容易的就能計算出來,但是寬度每一行的可能都不一樣,我們用一個List記錄每一行的總寬度,在最後設定的時候取出最大的一行作為容器的寬度,記得要減去一個間距哦。

那麼不同寬度的水準布局方式效果的實作就是這樣:

Android自定義ViewGroup嵌套與互動實戰,幕布全屏滾動效果

實作是實作了,但是這麼計算是不是有問題?每一行的最高高度好像不是太準确,如果每一列都有一個最大高度,但是不是同一列,那麼測量的高度就比實際高度要更高。

加一個灰色背景就可以看到效果:

Android自定義ViewGroup嵌套與互動實戰,幕布全屏滾動效果

我們再優化一下,它應該是計算每一列的總共高度,然後選出最大高度才對:

@Override
    public int[] performMeasure(ViewGroup viewGroup, int rowCount, int horizontalSpacing, int verticalSpacing, int fixedWidth) {

        int childCount = viewGroup.getChildCount();
        int curPosition = 0;
        int totalControlHeight = 0;
        int totalControlWidth = 0;
        SparseArray<List<Integer>> columnAllHeight = new SparseArray<>(); //每一列的全部高度

        //開始周遊
        for (int i = 0; i < childCount; i++) {
            View childView = viewGroup.getChildAt(i);

            int row = curPosition / rowCount;    //目前子View是第幾行
            int column = curPosition % rowCount;    //目前子View是第幾列

            //已經測量過了,直接取寬高
            int height = childView.getMeasuredHeight();

            List<Integer> integers = columnAllHeight.get(column);
            if (integers == null || integers.isEmpty()) {
                integers = new ArrayList<>();
            }
            integers.add(height + verticalSpacing);
            columnAllHeight.put(column, integers);

            //指派
            curPosition++;
        }

        //循環結束之後開始計算真正的寬高
        totalControlWidth = (rowCount *
                (fixedWidth + horizontalSpacing) + viewGroup.getPaddingLeft() + viewGroup.getPaddingRight());

        List<Integer> totalHeights = new ArrayList<>();
        for (int i = 0; i < columnAllHeight.size(); i++) {
            List<Integer> heights = columnAllHeight.get(i);
            int totalHeight = 0;
            for (int j = 0; j < heights.size(); j++) {
                totalHeight += heights.get(j);
            }
            totalHeights.add(totalHeight);
        }
        totalControlHeight = Collections.max(totalHeights);

        columnAllHeight.clear();
        columnAllHeight = null;

        return new int[]{totalControlWidth - horizontalSpacing, totalControlHeight - verticalSpacing};
    }
複制代碼           

再看看效果:

Android自定義ViewGroup嵌套與互動實戰,幕布全屏滾動效果

寬高真正的測量準确之後我們接下來就開始屬性的抽取與封裝了。

四、自定義屬性

我們先前都是使用的成員變量來控制一些間距與邏輯的觸發,這就跟業務耦合了,如果想做到通用的一個效果,肯定還是要抽取自定義屬性,做到對應的配置開關,就可以适應更多的場景使用,也是開源項目的必備技能。

細數一下我們需要控制的屬性:

  1. enableScale 是否支援縮放
  2. maxScale 縮放的最大比例
  3. minScale 縮放的最小比例
  4. moveInViewport 是否隻能在布局内部移動
  5. horizontalSpacing item的水準間距
  6. verticalSpacing item的垂直間距
  7. fixed_width 豎向的排列 - 寬度定死 并設定對應的LayoutManager
  8. fixed_height 橫向的排列 - 高度定死 并設定對應的LayoutManager

定義屬性如下:

<!--  全螢幕布布局自定義屬性  -->
    <declare-styleable name="CurtainLayout">
        <!--Item的橫向間距-->
        <attr name="horizontalSpacing" format="dimension" />
        <!--Item的垂直間距-->
        <attr name="verticalSpacing" format="dimension" />
        <!--每行需要展示多少數量的Item-->
        <attr name="rowCount" format="integer" />
        <!--垂直方向瀑布流布局,固定寬度為多少-->
        <attr name="fixedWidth" format="dimension" />
        <!--水準方向瀑布流布局,固定高度為多少-->
        <attr name="fixedHeight" format="dimension" />
        <!--是否隻能在布局内部移動 當為false時候為自由移動-->
        <attr name="moveInViewport" format="boolean" />
        <!--是否可以縮放-->
        <attr name="enableScale" format="boolean" />
        <!--最大與最小的縮放比例-->
        <attr name="maxScale" format="float" />
        <attr name="minScale" format="float" />
    </declare-styleable>
複制代碼           

取出屬性并對容器布局與觸摸處理器做指派的操作:

public class CurtainLayout extends FrameLayout {

    private int horizontalSpacing;
    private int verticalSpacing;
    private int rowCount;
    private int fixedWidth;
    private int fixedHeight;
    private boolean moveInViewport;
    private boolean enableScale;
    private float maxScale;
    private float minScale;

    public CurtainLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        setClipChildren(false);
        setClipToPadding(false);

        mCurtainViewContrainer = new CurtainViewContrainer(getContext());
        addView(mCurtainViewContrainer);

        initAttr(context, attrs);

        mGestureHandler = new TouchEventHandler(getContext(), mCurtainViewContrainer);

        //設定是否在視窗内移動
        mGestureHandler.setKeepInViewport(moveInViewport);
        mGestureHandler.setEnableScale(enableScale);
        mGestureHandler.setMinScale(minScale);
        mGestureHandler.setMaxScale(maxScale);

        mCurtainViewContrainer.setHorizontalSpacing(horizontalSpacing);
        mCurtainViewContrainer.setVerticalSpacing(verticalSpacing);
        mCurtainViewContrainer.setRowCount(rowCount);
        mCurtainViewContrainer.setFixedWidth(fixedWidth);
        mCurtainViewContrainer.setFixedHeight(fixedHeight);

        if (fixedWidth > 0 || fixedHeight > 0) {
            if (fixedWidth > 0) {
                mCurtainViewContrainer.setLayoutDirectionVertical(fixedWidth);
            } else {
                mCurtainViewContrainer.setLayoutDirectionHorizontal(fixedHeight);
            }
        }
    }

    /**
     * 擷取自定義屬性
     */
    private void initAttr(Context context, AttributeSet attrs) {

        TypedArray mTypedArray = context.obtainStyledAttributes(attrs, R.styleable.CurtainLayout);
        this.horizontalSpacing = mTypedArray.getDimensionPixelSize(R.styleable.CurtainLayout_horizontalSpacing, 20);
        this.verticalSpacing = mTypedArray.getDimensionPixelSize(R.styleable.CurtainLayout_verticalSpacing, 20);
        this.rowCount = mTypedArray.getInteger(R.styleable.CurtainLayout_rowCount, 6);
        this.fixedWidth = mTypedArray.getDimensionPixelOffset(R.styleable.CurtainLayout_fixedWidth, 150);
        this.fixedHeight = mTypedArray.getDimensionPixelSize(R.styleable.CurtainLayout_fixedHeight, 180);
        this.moveInViewport = mTypedArray.getBoolean(R.styleable.CurtainLayout_moveInViewport, false);
        this.enableScale = mTypedArray.getBoolean(R.styleable.CurtainLayout_enableScale, true);
        this.minScale = mTypedArray.getFloat(R.styleable.CurtainLayout_minScale, 0.7f);
        this.maxScale = mTypedArray.getFloat(R.styleable.CurtainLayout_maxScale, 1.5f);

        mTypedArray.recycle();
    }
    ...

    public void setMoveInViewportInViewport(boolean moveInViewport) {
        this.moveInViewport = moveInViewport;
        mGestureHandler.setKeepInViewport(moveInViewport);
    }

    public void setEnableScale(boolean enableScale) {
        this.enableScale = enableScale;
        mGestureHandler.setEnableScale(enableScale);
    }

    public void setMinScale(float minScale) {
        this.minScale = minScale;
        mGestureHandler.setMinScale(minScale);
    }

    public void setMaxScale(float maxScale) {
        this.maxScale = maxScale;
        mGestureHandler.setMaxScale(maxScale);
    }

    public void setHorizontalSpacing(int horizontalSpacing) {
        mCurtainViewContrainer.setHorizontalSpacing(horizontalSpacing);
    }

    public void setVerticalSpacing(int verticalSpacing) {
        mCurtainViewContrainer.setVerticalSpacing(verticalSpacing);
    }

    public void setRowCount(int rowCount) {
        mCurtainViewContrainer.setRowCount(rowCount);
    }

    public void setFixedWidth(int fixedWidth) {
        mCurtainViewContrainer.setLayoutDirectionVertical(fixedWidth);
    }

    public void setFixedHeight(int fixedHeight) {
        mCurtainViewContrainer.setLayoutDirectionHorizontal(fixedHeight);
    }

複制代碼           

然後在布局容器與事件處理類中做對應的指派操作即可。

如何使用?

<CurtainLayout
        android:id="@+id/curtain_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"

        app:enableScale="true"
        app:fixedWidth="150dp"
        app:horizontalSpacing="10dp"
        app:maxScale="1.5"
        app:minScale="0.8"
        app:moveInViewport="true"
        app:rowCount="6"
        app:verticalSpacing="10dp">

    </CurtainLayout>
複制代碼           

如果在xml中設定過 fixedWidth 或者 fixedHeight ,那麼在 Activity 中也可以不設定 LayoutManager 了。

val list = listOf<String>( ... )

    val adapter = Viewgroup6Adapter(list)

    val curtainView = findViewById<CurtainLayout>(R.id.curtain_view)

    curtainView.adapter = adapter

複制代碼           

最終效果:

Android自定義ViewGroup嵌套與互動實戰,幕布全屏滾動效果

後記

關于 ViewGroup 的測量與布局與事件,我們已經從易到難複習了四期了,相信同學應該是能掌握了。

話說到裡就應該到了完結時刻,關于自定義View與自定義ViewGroup的複習與回顧就到此告一段落了,對于市面上能見到的一些布局效果,基本上能通過自定義ViewGroup與自定義View來實作。其實很早就想完結了,因為感覺這些東西有一點過于基礎了,好像大家都不是很有興趣看這些基礎的東西,

自定義View可以很友善的做自定義的繪制與本身與内部的一些移動,而對于一些多View移動的特效,我們就算用自定義View難以實作或實作的比較複雜的話,也能使用Behivor或者MotionLayot 來實作,當然這就是另一個篇章了。

如果有興趣也可以看看我之前的 Behivor 文章 【傳送門】 或者 MotionLayot 的文章,【傳送門】。

同時也可以搜尋與翻看之前的文章哦。

本文的代碼均可以在我的Kotlin測試項目中看到,【傳送門】。你也可以關注我的這個Kotlin項目,我有時間都會持續更新。

關于本文的全屏滑動效果,我也會開源傳到 MavenCentral 供大家依賴使用,【傳送門】

使用:Gradle中直接依賴即可:

implementation "com.gitee.newki123456:curtain_layout:1.0.0"

好了,如果類似的效果有更多的更好的其他方式,也希望大家能評論區交流一下。

慣例,我如有講解不到位或錯漏的地方,希望同學們可以指出。

如果感覺本文對你有一點點的幫助,還望你能點贊支援一下,你的支援是我最大的動力。

哎,找圖檔都找了接近一個小時,如果大家想要對應的圖檔也可以去項目中拿哦!

Ok,這一期就此完結。

Android自定義ViewGroup嵌套與互動實戰,幕布全屏滾動效果

繼續閱讀