1 Star 9 Fork 4

newki/CurtainLayout

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
贡献代码
同步代码
取消
提示: 由于 Git 不支持空文件夾,创建文件夹后会生成空的 .keep 文件
Loading...
README
Apache-2.0

自定义 ViewGroup 全屏选中效果

前言

事情是这个样子的,前几天产品丢给我一个视频,你觉得这个效果怎么样,我们的 App 也做一个这个效果吧!

128e747108c676d03a7458d2f3bb7d8c 00_00_00-00_00_30.gif

我当时的反应:

1d0bc9d5801e43b18a7b0c3a61097fc9 (1).jpeg

开什么玩笑!就没见过这么玩的,这不是坑人吗?

此时产品幽幽的回了一句,“别人都能做,你怎么不能做,并且iOS说可以做,还很简单。”

我心里一万个不信,糟老头子太坏了,想骗我?

cf5d42d629546989d113cd2d872ccdc2.gif

我立马和iOS同事统一战线,说不能做,实现不了吧。结果iOS同事幽幽的说了一句 “已经做了,四行代码完成”。

xZ9nT3MwgMeEAApHgsxlaJl8feP6khUJLCr1q5PTKaFXT1527396855674.jpeg

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

这也没办法了,群里问问大神有什么好的方案,“xdm,车先减个速,(图片)这个效果怎么实现?”

“做不了...”

“让产品滚...”

“没做过,也没见过...”

“性能不好,不推荐,换方案吧。”

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

“不理他,继续开车...”

...群里技术氛围果然没有让我失望,哎,看来还是得靠自己,抬头望了望天天,扣了扣脑阔,无语啊。

好了,说了这么多玩笑话,回归正题,其实关于标题的这种效果,确实是对性能的开销更大,且网上相关开源的项目也几乎没找到。

到底怎么做呢?相信跟着我一起复习的小伙伴们心里都有了一点雏形。自定义ViewGroup。

下面跟着我一起再次巩固一次 ViewGroup 的测量与布局,加上事件的处理,就能完成对应的功能。

话不多说,Let's go

300.png

一、布局的测量与布局

首先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的宽高都使用同样的大小,是勉强能看的,一旦高度不统一,就不能看了。

先不管那么多,先固定大小显示出来看看效果。

image.png

反正是能看了,一个寨版的 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);
    }
}

对于一些复杂的处理都做了相关的注释,接下来看看加了事件处理之后的效果:

viewGroup_001.gif

已经可以自由拖动与缩放了,但是目前的测量与布局是有问题的,加下来我们抽取与优化一下。

三、抽取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记录每一行的总宽度,在最后设置的时候取出最大的一行作为容器的宽度,记得要减去一个间距哦。

那么不同宽度的水平布局方式效果的实现就是这样:

viewGroup_002.gif

实现是实现了,但是这么计算是不是有问题?每一行的最高高度好像不是太准确,如果每一列都有一个最大高度,但是不是同一列,那么测量的高度就比实际高度要更高。

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

image.png

我们再优化一下,它应该是计算每一列的总共高度,然后选出最大高度才对:

    @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};
    }

再看看效果:

image.png

宽高真正的测量准确之后我们接下来就开始属性的抽取与封装了。

四、自定义属性

我们先前都是使用的成员变量来控制一些间距与逻辑的触发,这就跟业务耦合了,如果想做到通用的一个效果,肯定还是要抽取自定义属性,做到对应的配置开关,就可以适应更多的场景使用,也是开源项目的必备技能。

细数一下我们需要控制的属性:

  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

最终效果:

viewGroup_003.gif

后记

关于 ViewGroup 的测量与布局与事件,我们已经从易到难复习了四期了,相信同学应该是能掌握了。

话说到里就应该到了完结时刻,关于自定义View与自定义ViewGroup的复习与回顾就到此告一段落了,对于市面上能见到的一些布局效果,基本上能通过自定义ViewGroup与自定义View来实现。其实很早就想完结了,因为感觉这些东西有一点过于基础了,好像大家都不是很有兴趣看这些基础的东西,

自定义View可以很方便的做自定义的绘制与本身与内部的一些移动,而对于一些多View移动的特效,我们就算用自定义View难以实现或实现的比较复杂的话,也能使用Behivor或者MotionLayot 来实现,当然这就是另一个篇章了。

如果有兴趣也可以看看我之前的 Behivor 文章 【传送门】 或者 MotionLayot 的文章,【传送门】

同时也可以搜索与翻看之前的文章哦。

Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

简介

幕布全屏滚动容器 展开 收起
Java
Apache-2.0
取消

发行版 (1)

全部

贡献者

全部

近期动态

加载更多
不能加载更多了
马建仓 AI 助手
尝试更多
代码解读
代码找茬
代码优化
1
https://gitee.com/newki123456/CurtainLayout.git
git@gitee.com:newki123456/CurtainLayout.git
newki123456
CurtainLayout
CurtainLayout
master

搜索帮助

344bd9b3 5694891 D2dac590 5694891