Android自定义抽屉布局

340 阅读4分钟

 一、前言

Android 问世以来,抽屉布局在2016年之前一直是热度很高的效果,随着官方提供了DrawerLayout、SlidingTabLayout 等组件之后,一度引起开发者热捧。不过随着app开发技术的成熟,这类布局热度已经降下来了,不过作为技术方案,有很多值得我们学习的地方。

要解决的问题:

  • 1、事件传递
  • 2、事件重新分发
  • 3、Scroller 使用

二、代码实现

2.1 核心逻辑

在Android 中,滑动分为2类,一类以ScrollView为代表布局,通过子View实现布局超出视区(ViewPort)之后,进行Scroll操作的,另一类事以修改Offset为代表的Recycler类,前者实时保持最大高度。形像的理解为前者是“齿轮传动派”,后者是“滑板派”,两派都有过出风头的时候,即便是个派弟子如NestedScrollView和RecyclerView争的你死我活,不过总体上齿轮传动派占在弱势地位。不过android的改版,让他们做了很多和平相处的事情,不如NestedScrolling机制的支持,让他们想传动就传动,想滑翔就滑翔。

之前有篇文章我们了解了滑板派,这次我们来了解下齿轮传动派。

齿轮传动派是滑动父View本身,主要调用scrollXXX方法

难点实际上并不多,主要难点是Scroller和computeScroll的相互参考关系。我们知道Scroller本身和View没有直接的关系,但是他能提供一些滑动的阻尼计算,让我们的滑动没有那么呆板,唯一强调的是computeScroll种必须要触发draw方法(利用invalidate或者postInvalidate),这样才能相互带动调用关系,Scroller类似参考对象,为滑动提供顾问服务。

    @Override
    public void computeScroll() {

        if (mScroller.computeScrollOffset()) {
            int oldX = getScrollX();
            int oldY = getScrollY();
            int x = mScroller.getCurrX();
            int y = mScroller.getCurrY();
            if (oldX != x || oldY != y) {
                scrollTo(x,y); //scroll x,y相同时,无法invalidate的问题
            }else {
                postInvalidate();  //如果位移一致,强制刷新,才能再次调用computeScroll
            }
            Log.d("computeScroll","computeScrollX="+mScroller.getCurrX()+",mScroller="+mScroller.isFinished());
        }
    }

2.2  事件处理

事件是此类View定义的最重要的部分,这里我们用dispatchTouchEvent去处理事件,一个重要的原因是,RecyclerView和ListView默认会捕获事件,因此dispatchTouchEvent 是必要的,除非你再自定义一个RecyclerView去兼容。

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {

        if(mContentChildView==null || mMenuChildView==null){
            return super.dispatchTouchEvent(ev);
        }

        int actionMasked = ev.getActionMasked();

        switch (actionMasked){

            case MotionEvent.ACTION_DOWN:

                if(!mScroller.isFinished()){
                    break;
                }
                isSlideMoving = false;
                mPoint.x = ev.getX();
                mPoint.y =  ev.getY();
                Log.d("onTouchEvent","Action_Down");
                break;
            case MotionEvent.ACTION_MOVE:
                getParent().requestDisallowInterceptTouchEvent(true);

                float  cx = ev.getX();
                float  cy = ev.getY();

                float dx = Math.abs(mPoint.x - cx);
                float dy = Math.abs(mPoint.y - cy);

                if(dy>dx && dy>=mTouchSlop){
                    mPoint.x = cx;
                    mPoint.y = cy;
                    isSlideMoving = false;
                }else if (dx >= mTouchSlop && !isSlideMoving) {
                    isSlideMoving = true;
                    //这个判断很关键,不然会有事件冲突问题
                }

                if(isSlideMoving) {
                    offsetX = (int) (getScrollX() + (mPoint.x - cx));
                    if (offsetX < 0) {
                        offsetX = Math.max(offsetX, 0);
                    } else {
                        offsetX = Math.min(offsetX, mMenuChildView.getWidth());
                    }
                    mPoint.x = cx;
                    mPoint.y = cy;

                    scrollTo(offsetX, 0);
                    return true;  //防止纵向滑动被传递给子View

                }
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                Log.e("onTouchEvent","ACTION_UP isSlideMoving=false");
                int scrollX = getScrollX();
                 if(scrollX>=mMenuChildView.getWidth()/2){
                        smoothScrollTo(mMenuChildView.getWidth(),0);
                 }else{
                        smoothScrollTo(0,0);
                 }
                getParent().requestDisallowInterceptTouchEvent(false);
                if( isSlideMoving) {
                    isSlideMoving = false;
                  
                    return true;
                    //一旦出现了滑动,不在传递dispatch事件,否则造成事件传递异常
                }
                break;
        }


        return super.dispatchTouchEvent(ev);
    }

2.3 核心代码

下面是完整代码,我们这里支持两种滑动,参见文章开头的效果。

public class SlindingDrawer extends ViewGroup {

    PointF mPoint = new PointF();
    boolean isSlideMoving = false;

    private View mMenuChildView = null;
    private View mContentChildView = null;
    private int offsetX = Integer.MIN_VALUE;

    private int mTouchSlop = 0;

    static final int ANIMATED_SCROLL_GAP = 250;

    private Scroller mScroller; //用于平滑scroll

    private boolean isDrawerOpen = false;

    private int STYLE_MODE_SLIDE = 1;
    private int STYLE_MODE_PULL = 0;

    private  int mStyleModel = STYLE_MODE_SLIDE;

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

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

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


        if(attrs==null) return;

        final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SlindingDrawer);
        mStyleModel = a.getInt(R.styleable.SlindingDrawer_drawer_style, STYLE_MODE_SLIDE);
        a.recycle();
    }

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

        if(mTouchSlop==0){
            mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
        }
        if(mScroller==null) {
            mScroller = new Scroller(getContext());
        }

        int childCount = getChildCount();
        if(childCount==0) return;


        if(childCount!=2) throw new RuntimeException("the child count must be 2");

        DisplayMetrics displayMetrics = getResources().getDisplayMetrics();

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);

        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        int width  = 0;
        int height = 0;


        View menuChildView = null;
        View contentChildView = null;


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

            View child = getChildAt(i);
            LayoutParams lp = (LayoutParams) child.getLayoutParams();
            if(lp.layoutType== LayoutParams.LAYOUT_MENU) {
                menuChildView = child;
            }
            if(lp.layoutType== LayoutParams.LAYOUT_CONTENT) {
                contentChildView = child;
            }
        }

        if(menuChildView==null){
            throw new RuntimeException("unspecified menu child");
        }
        if(contentChildView==null){
            throw new RuntimeException("unspecified content child");
        }

        ViewGroup.LayoutParams menuParams = menuChildView.getLayoutParams();
        if(menuParams!=null){
            menuParams.width = Math.min(menuParams.width,displayMetrics.widthPixels);
        }

        ViewGroup.LayoutParams contentParams = contentChildView.getLayoutParams();
        if(contentParams!=null) {
            if(widthMode==MeasureSpec.EXACTLY){
                contentParams.width = Math.min(widthSize, displayMetrics.widthPixels);
            }else{
                //本布局如果宽度不确定,那么尽量让content等于屏幕宽度
                contentParams.width = Math.min(contentParams.width, displayMetrics.widthPixels);
            }
        }

        measureChildren(widthMeasureSpec,heightMeasureSpec);

        if(menuChildView.getMeasuredWidth()>contentChildView.getMeasuredWidth()){
            throw new RuntimeException("menu max width should less than content");
        }

        //本布局最大宽度不能超过屏幕宽度
        width = Math.min(displayMetrics.widthPixels,contentChildView.getMeasuredWidth());

        if(heightMode!=MeasureSpec.EXACTLY){
             for(int i=0;i<childCount;i++){
                View child = getChildAt(i);
                height = Math.max(height,Math.min(displayMetrics.heightPixels,child.getMeasuredHeight()));
                //每个面不能超过屏幕最大宽度
             }
        }else{
            height = heightSize;
        }

        for (int i=0;i<childCount;i++){
            View child = getChildAt(i);
            if(child.getMeasuredHeight()==height) continue;

            ViewGroup.LayoutParams layoutParams = child.getLayoutParams();
            if(layoutParams!=null){
                layoutParams.height = height;
            }
            //重新测量child,使得其高度一致
            int childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(child.getMeasuredWidth(), MeasureSpec.EXACTLY);
            int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(child.getMeasuredHeight(), MeasureSpec.EXACTLY);
            child.measure(childWidthMeasureSpec,childHeightMeasureSpec);
        }

        setMeasuredDimension(width,height);

    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int count = getChildCount();
        if(count==0 || !changed) return;

        for (int i=0;i<count;i++) {

            View child = getChildAt(i);
            LayoutParams lp = (LayoutParams) child.getLayoutParams();
            if(lp.layoutType== LayoutParams.LAYOUT_MENU) {
                mMenuChildView = child;
            }
            if(lp.layoutType== LayoutParams.LAYOUT_CONTENT) {
                mContentChildView = child;
            }
        }

        int menuWidth = mMenuChildView.getMeasuredWidth();
        int menuHeight = mMenuChildView.getMeasuredHeight();

        mMenuChildView.layout(0,0,menuWidth,menuHeight);

        int left = menuWidth;
        int right = menuWidth +mContentChildView.getMeasuredWidth();

        mContentChildView.layout(left,0,right,mContentChildView.getMeasuredHeight());

        if(mStyleModel==STYLE_MODE_PULL){
            bringChildToFront(mMenuChildView);
        }else{
            bringChildToFront(mContentChildView);
        }

        if(offsetX==Integer.MIN_VALUE) {
            offsetX = left;
        }
        scrollTo(offsetX,0);
    }


    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {


        if(mContentChildView==null || mMenuChildView==null){
            return super.dispatchTouchEvent(ev);
        }

        int actionMasked = ev.getActionMasked();

        switch (actionMasked){

            case MotionEvent.ACTION_DOWN:

                if(!mScroller.isFinished()){
                    break;
                }
                isSlideMoving = false;
                mPoint.x = ev.getX();
                mPoint.y =  ev.getY();
                Log.d("onTouchEvent","Action_Down");
                break;
            case MotionEvent.ACTION_MOVE:
                getParent().requestDisallowInterceptTouchEvent(true);

                float  cx = ev.getX();
                float  cy = ev.getY();

                float dx = Math.abs(mPoint.x - cx);
                float dy = Math.abs(mPoint.y - cy);

                if(dy>dx && dy>=mTouchSlop){
                    mPoint.x = cx;
                    mPoint.y = cy;
                    isSlideMoving = false;
                }else if (dx >= mTouchSlop && !isSlideMoving) {
                    isSlideMoving = true;
                    //这个判断很关键,不然会有事件冲突问题
                }

                if(isSlideMoving) {
                    offsetX = (int) (getScrollX() + (mPoint.x - cx));
                    if (offsetX < 0) {
                        offsetX = Math.max(offsetX, 0);
                    } else {
                        offsetX = Math.min(offsetX, mMenuChildView.getWidth());
                    }
                    mPoint.x = cx;
                    mPoint.y = cy;

                    scrollTo(offsetX, 0);
                    return true;  //防止纵向滑动被传递给子View

                }
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                Log.e("onTouchEvent","ACTION_UP isSlideMoving=false");
                int scrollX = getScrollX();
                 if(scrollX>=mMenuChildView.getWidth()/2){
                        smoothScrollTo(mMenuChildView.getWidth(),0);
                 }else{
                        smoothScrollTo(0,0);
                 }
                getParent().requestDisallowInterceptTouchEvent(false);
                if( isSlideMoving) {
                    isSlideMoving = false;
                  
                    return true;
                    //一旦出现了滑动,不在传递dispatch事件,否则造成事件传递异常
                }
                break;
        }


        return super.dispatchTouchEvent(ev);
    }



    public final void smoothScrollTo(int x, int y) {
        smoothScrollBy(x - getScrollX(), y - getScrollY());
    }

    public final void smoothScrollBy(int dx, int dy) {
            if (getChildCount() == 0) {
                return;
            }
            final int height = getHeight() - getPaddingBottom() - getPaddingTop();
            final int bottom = getChildAt(0).getHeight();
            final int maxY = Math.max(0, bottom - height);
            final int scrollY = getScrollY();
            final int scrollX = getScrollX();

            dy = Math.max(0, Math.min(scrollY + dy, maxY)) - scrollY;
            if(!mScroller.isFinished()){
                mScroller.abortAnimation();
            }
            mScroller.startScroll(scrollX, scrollY, dx, dy,ANIMATED_SCROLL_GAP);
            postInvalidateOnAnimation();

    }




    @Override
    public void computeScroll() {

        if (mScroller.computeScrollOffset()) {
            int oldX = getScrollX();
            int oldY = getScrollY();
            int x = mScroller.getCurrX();
            int y = mScroller.getCurrY();
            if (oldX != x || oldY != y) {
                scrollTo(x,y); //scroll x,y相同时,无法invalidate的问题
            }else {
                postInvalidate();  //如果位移一致,强制刷新,才能再次调用computeScroll
            }
            Log.d("computeScroll","computeScrollX="+mScroller.getCurrX()+",mScroller="+mScroller.isFinished());
        }
    }

    public void postInvalidateOnAnimation() {
        if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.JELLY_BEAN) {
            super.postInvalidateOnAnimation();
        }else{
            postInvalidate();
        }
    }

    @Override
    public void scrollTo(@Px int sx, @Px int sy) {
        super.scrollTo(sx, sy);
        if(mMenuChildView==null || mContentChildView==null) return;
        int maxScrollSize = mMenuChildView.getWidth();
        if(sx==0){
            isDrawerOpen = true;
        }else if(sx==maxScrollSize){
            isDrawerOpen = false;
        }

        float delta = sx * 0.5f / maxScrollSize;
        mContentChildView.setAlpha(0.5f+delta);

        Log.d("XLeft",""+sx);
        if(mStyleModel==STYLE_MODE_PULL){
            mContentChildView.setTranslationX(sx-maxScrollSize);
        }
    }

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

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

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

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

    public boolean isDrawerOpen() {
        return isDrawerOpen;
    }

    public void openDrawer() {
        smoothScrollTo(0, 0);

    }

    public void closeDrawer() {
        if(mMenuChildView==null) return;
        smoothScrollTo(mMenuChildView.getWidth(), 0);
    }

    public static class LayoutParams extends  ViewGroup.LayoutParams{


        public static final int LAYOUT_MENU = 0;
        public static final int LAYOUT_CONTENT = 1;
        public int layoutType = LAYOUT_CONTENT;


        public LayoutParams(@NonNull Context c, @Nullable AttributeSet attrs) {
            super(c, attrs);

            final TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.SlindingDrawer);
            layoutType = a.getInt(R.styleable.SlindingDrawer_drawer_layoutType, LAYOUT_CONTENT);
            a.recycle();
        }

        public LayoutParams(int width, int height,int gravity) {
            super(width, height);

        }

        public LayoutParams(ViewGroup.LayoutParams source) {
            super(source);
            if(source instanceof  LayoutParams){
                layoutType = ((LayoutParams) source).layoutType;
            }
        }
    }


}

2.4 attr.xml定义

定义layout_type和drawer_style,前者用于标记child类型,后者用于标记布局滑动效果

<declare-styleable name="SlindingDrawer">
        <attr name="drawer_layoutType" format="enum">
            <enum name="content" value="1" />
            <enum name="menu" value="0" />
        </attr>
        <attr name="drawer_style">
            <enum name="slide" value="1" />
            <enum name="pull" value="0" />
        </attr>
    </declare-styleable>

三、用法

3.1 布局文件

layout_type和drawer_style 是需要明确标记的,不同的View扮演不同的角色,不同的style会展示不同的效果。

<com.bgm.view.SlindingDrawer xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:id="@+id/slindingDrawer"
    app:drawer_style="pull"
    >

    <LinearLayout
        android:id="@+id/menu"
        android:layout_width="200dp"
        android:layout_height="match_parent"
        app:drawer_layoutType="menu"
        android:background="@color/color_d3ddff"
        android:orientation="vertical"
        >
        <Button
            android:id="@+id/left"
            android:text="关闭"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />
    </LinearLayout>
    <LinearLayout
        android:id="@+id/content"
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="100dp"
        app:drawer_layoutType="content"
        android:gravity="center"
        >

        <Button
            android:id="@+id/right"
            android:text="打开"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />

        <ImageView
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1"
            android:src="@drawable/pic_rule"
            />

    </LinearLayout>

</com.bgm.view.SlindingDrawer>

3.2 注意事项

content的宽度需要大于menu,否则可能展示出奇怪的效果。

四、总结

自定义布局其实难点主要在滑动和事件处理上,相比Canvas的绘制,这里对算法的要求其实并不高,但是我们要掌握滑动方向处理,fling事件、scroller事件、volecity、scrolling机制,也需要一定的练习才行。