[置顶] Android自定义LinearLayout实现左右侧滑菜单,完美兼容ListView、ScrollView、ViewPager等滑动控件

国际惯例,先来效果图



在阅读本文章之前,请确定熟悉【Scroller】相关的知识,如果不熟悉,请小伙伴儿先百度后再来吧。

假如你已经知道【Scroller】了,那么就接着往下看吧。

首先,我们把侧拉菜单的构造给解析出来。多次观看上面的效果图,我们可以得出以下的结论。

  • 整体可以看做是一个ViewGroup,这个ViewGroup包含了最多三个子View(分别是左菜单的红色View、中间正文内容的白色View、右菜单的蓝色View);
  • 三个子View(我称为UI界面,因为代码中的Java类就取名这个)的移动是在ViewGroup的onTouchEvent方法中控制;
  • 每个UI界面都拥有独特的东西,比如子控件布局,因此我们希望用R.layout.*的方式引入;
  • 每个UI界面又都拥有相同的属性,比如都有宽度属性,滑动临界值属性,那么就可以用一个超类来封装所有相似的东西;
  • 最最重要的地方,动态计算出scrollX的值,然后用Scroller来滑动。
理清楚了结构后,我们来开始第一步的设计,也就是封装超类,首先给出代码:
/**
 * Created by ccwxf on 2016/6/14.
 */
public abstract class UI {

    protected Context context;
    //当前UI界面的布局文件
    protected View contentView;
    //当前UI界面在父控件的起点X坐标
    protected int startX;
    //当前UI界面在父控件的终点X坐标
    protected int stopX;
    //当前UI界面的宽度
    protected int width;

    protected UI(Context context, View contentView){
        this.context = context;
        this.contentView = contentView;
    }

    protected abstract void calculate(float leftScale, float rightScale);

    protected void show(Scroller mScroller){
        if(mScroller != null){
            mScroller.startScroll(mScroller.getFinalX(), mScroller.getFinalY(), startX - mScroller.getFinalX(), 0);
        }
    }
}

这个UI超类就用于模拟每一个界面,其中主要封装了内容View的设置、跳转界面的逻辑代码,以及暴露出去需要子类实现的calculate方法,这个calculate方法主要是要计算startX、stopX、width以及各子类独有的属性。


接下来展示左菜单的实现类LeftMenuUI:
/**
 * Created by ccwxf on 2016/6/14.
 */
public class LeftMenuUI extends UI {
    // 是指要打开该UI界面所需要滚动的X坐标临界值
    public int openX;
    // 是指要关闭该UI界面所需要的滚动的X坐标临界值
    public int closeX;

    public LeftMenuUI(Context context, View contentView) {
        super(context, contentView);
    }

    @Override
    protected void calculate(float leftScale, float rightScale) {
        startX = 0;
        stopX = (int) (Util.getScreenWidth(context) * leftScale);
        this.width = stopX - startX;
        this.openX = (int) (startX + (1 - SideLayout.DEFAULT_SIDE) * this.width);
        this.closeX = (int) (startX + SideLayout.DEFAULT_SIDE * this.width);
    }

}

代码那是相当的简洁,在calculate方法中除了计算startX和stopX和width之外,还计算了openX和closeX的值。那么问题来了,此处的openX和closeX的什么东西呢?先看下图所示。


首先黑色框代表的是整个的布局,被分为了三个部分,分别是左菜单、正文内容、右菜单。红色框代表的是手机的屏幕,默认手机屏幕的宽高和正文内容的宽高都是一样的。因此图上所示是重合的。
那么问题来了,途中所示的绿色横线代表的openX和closeX分别是什么意思呢?我们假想一下,我们现在正处于正文的内容,此时手指向右滑屏,将滑出左菜单的部分,此时红框代表的屏幕就会向左移动(如果听不懂就真的需要先了解Scroller的使用哟),如果红色框移动到openX这个绿线的左边,我们就认为超出了滑动的临界值,判断为显示左菜单的操作,现在应该明白了openX的意思了吧,就是超过这个值就显示左菜单。
那么问题又来了,closeX怎么解释呢?我们再次假象一下,我们现在正处于左菜单,此时我们向左滑动屏幕,如果红色框从0开始向右移动,如果超出了closeX这个临界值,就代表我们要滑出左菜单进入正文内容,这就是closeX的意思。

好了,理解了左菜单这个类,那么正文内容和右菜单也同样好理解了。接下来给出正文类:ContentUI
public class ContentUI extends UI {

    public ContentUI(Context context, View contentView) {
        super(context, contentView);
    }

    @Override
    protected void calculate(float leftScale, float rightScale) {
        int width = Util.getScreenWidth(context);
        int leftWidth = (int) (width * leftScale);
        startX = leftWidth;
        stopX = leftWidth + width;
        this.width = stopX - startX;
    }

}

正文菜单更简单,没有openX和closeX的计算,为什么呢?因为左菜单和右菜单的划入划出判断我都放在对应的UI类里面。接下来是RightMenuUI
/**
 * Created by ccwxf on 2016/6/14.
 */
public class RightMenuUI extends UI {
    // 是指要打开该UI界面所需要滚动的X坐标临界值
    public int openX;
    // 是指要关闭该UI界面所需要的滚动的X坐标临界值
    public int closeX;

    public RightMenuUI(Context context, View contentView) {
        super(context, contentView);
    }

    @Override
    protected void calculate(float leftScale, float rightScale) {
        int width = Util.getScreenWidth(context);
        startX = (int) (width * (1 + leftScale));
        stopX = (int) (width * (1 + leftScale + rightScale));
        this.width = stopX - startX;
        this.openX = (int) (startX - width + SideLayout.DEFAULT_SIDE * this.width);
        this.closeX = (int) (startX - width + (1 - SideLayout.DEFAULT_SIDE) * this.width);
    }

    /**
     * 必须重载父类方法,因为滑动的起点是从0开始
     */
    protected void show(Scroller mScroller, int measureWidth){
        if(mScroller != null){
            mScroller.startScroll(mScroller.getFinalX(), mScroller.getFinalY(), measureWidth - Util.getScreenWidth(context) - mScroller.getFinalX(), 0);
        }
    }
}

这个类也同样有openX和closeX的计算, 但是大家要特别注意的一点是:右菜单的openX和closeX是在正文菜单的坐标内,要问为什么的话,大家需要了解Scroller的原理并向我一样画一个草图来理解。

最后就是SideLayout这个自定义控件了,其实就只是在onTouchEvent中做了滑动的逻辑判断操作。首先给出源代码:
/**
 * Created by ccwxf on 2016/6/14.
 */
public class SideLayout extends LinearLayout {
    //默认的菜单宽度与屏幕宽度的比值
    public static final float DEFAULT_SCALE = 0.66f;
    //默认的滑动切换阀值相对于菜单宽度的比值
    public static final float DEFAULT_SIDE = 0.25f;
    private Scroller mScroller;
    //三个UI界面
    private LeftMenuUI leftMenuUI;
    private ContentUI contentUI;
    private RightMenuUI rightMenuUI;
    //左菜单和右菜单相对于屏幕的比值
    private float leftScale = 0;
    private float rightScale = 0;
    //控件的测量宽度
    private float measureWidth = 0;
    //手指Touch时的X坐标和移动时的X坐标
    private float mTouchX;
    private float mMoveX;

    public SideLayout(Context context) {
        super(context);
        init();
    }

    public SideLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public SideLayout(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        init();
    }

    private void init() {
        mScroller = new Scroller(getContext());
        setOrientation(LinearLayout.HORIZONTAL);
    }

    /**
     *  设置左菜单的布局
     * @param view 左菜单布局
     * @return 返回当类
     */
    public SideLayout setLeftMenuView(View view){
        return setLeftMenuView(view, DEFAULT_SCALE);
    }

    public SideLayout setLeftMenuView(View view, float leftScale){
        leftMenuUI = new LeftMenuUI(getContext(), view);
        this.leftScale = leftScale;
        return this;
    }

    /**
     *  设置右菜单的布局
     * @param view 右菜单布局
     * @return 当类
     */
    public SideLayout setRightMenuView(View view){
        return setRightMenuView(view, DEFAULT_SCALE);
    }

    public SideLayout setRightMenuView(View view, float rightScale){
        rightMenuUI = new RightMenuUI(getContext(), view);
        this.rightScale = rightScale;
        return this;
    }

    /**
     *  设置正文布局
     * @param view 正文布局
     * @return 返回当类
     */
    public SideLayout setContentView(View view){
        contentUI = new ContentUI(getContext(), view);
        return this;
    }

    /**
     * 提交配置,必须调用
     */
    public void commit() {
        removeAllViews();
        if(leftMenuUI != null){
            leftMenuUI.calculate(leftScale, rightScale);
            measureWidth += leftMenuUI.width;
            addView(leftMenuUI.contentView, new LayoutParams(leftMenuUI.width, LayoutParams.MATCH_PARENT));
        }
        if(contentUI != null){
            contentUI.calculate(leftScale, rightScale);
            measureWidth += contentUI.width;
            addView(contentUI.contentView, new LayoutParams(contentUI.width, LayoutParams.MATCH_PARENT));
        }
        if(rightMenuUI != null){
            rightMenuUI.calculate(leftScale, rightScale);
            measureWidth += rightMenuUI.width;
            addView(rightMenuUI.contentView, new LayoutParams(rightMenuUI.width, LayoutParams.MATCH_PARENT));
        }

    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                mTouchX = event.getX();
                mMoveX = event.getX();
                return true;
            case MotionEvent.ACTION_MOVE:
                int dx = (int) (event.getX() - mMoveX);
                if(dx > 0){
                    //右滑
                    if(mScroller.getFinalX() > 0){
                        mScroller.startScroll(mScroller.getFinalX(), mScroller.getFinalY(), -dx, 0);
                    }else{
                        mScroller.setFinalX(0);
                    }
                }else{
                    //左滑
                    if(mScroller.getFinalX() + Util.getScreenWidth(getContext()) - dx < measureWidth){
                        mScroller.startScroll(mScroller.getFinalX(), mScroller.getFinalY(), -dx, 0);
                    }else{
                        mScroller.setFinalX((int) (measureWidth - Util.getScreenWidth(getContext())));
                    }
                }
                mMoveX = event.getX();
                invalidate();
                break;
            case MotionEvent.ACTION_UP:
                toTargetUI((int) (event.getX() - mTouchX));
                break;
        }
        return super.onTouchEvent(event);
    }

    /**
     *  滑动切换到目标的UI界面
     * @param dx 手指抬起时相比手指落下,滑动的距离
     */
    private void toTargetUI(int dx){
        int scrollX = mScroller.getFinalX();
        if(dx > 0){
            //右滑
            if(leftMenuUI != null){
                if(scrollX >= leftMenuUI.openX && scrollX < leftMenuUI.stopX){
                    contentUI.show(mScroller);
                }else if(scrollX >= leftMenuUI.startX && scrollX < leftMenuUI.openX){
                    leftMenuUI.show(mScroller);
                }
            }
            if(rightMenuUI != null){
                if(scrollX >= rightMenuUI.closeX){
                    rightMenuUI.show(mScroller, (int) measureWidth);
                }else if(scrollX >= contentUI.startX && scrollX < rightMenuUI.closeX){
                    contentUI.show(mScroller);
                }
            }
        }else{
            //左滑
            if(leftMenuUI != null){
                if(scrollX > leftMenuUI.startX && scrollX <= leftMenuUI.closeX){
                    leftMenuUI.show(mScroller);
                }else if(scrollX > leftMenuUI.closeX && scrollX < leftMenuUI.stopX){
                    contentUI.show(mScroller);
                }
            }
            if(rightMenuUI != null){
                if(scrollX > contentUI.startX && scrollX <= rightMenuUI.openX){
                    contentUI.show(mScroller);
                }else if(scrollX > rightMenuUI.openX){
                    rightMenuUI.show(mScroller, (int) measureWidth);
                }
            }
        }
    }

    @Override
    public void computeScroll(){
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            postInvalidate();
        }
        super.computeScroll();
    }
}

在ACTION_MOVE操作中,根据移动的偏移量来滑动控件, 这里需要特别注意左边的0临界值和右边的measureWidth测量宽度的临界值,不然会滑出屏幕之外哟。最重要的方法还是toTargetUI这个方法,我单独把这个方法剔出来讲解。
/**
     *  滑动切换到目标的UI界面
     * @param dx 手指抬起时相比手指落下,滑动的距离
     */
    private void toTargetUI(int dx){
        int scrollX = mScroller.getFinalX();
        if(dx > 0){
            //右滑
            if(leftMenuUI != null){
                if(scrollX >= leftMenuUI.openX && scrollX < leftMenuUI.stopX){
                    contentUI.show(mScroller);
                }else if(scrollX >= leftMenuUI.startX && scrollX < leftMenuUI.openX){
                    leftMenuUI.show(mScroller);
                }
            }
            if(rightMenuUI != null){
                if(scrollX >= rightMenuUI.closeX){
                    rightMenuUI.show(mScroller, (int) measureWidth);
                }else if(scrollX >= contentUI.startX && scrollX < rightMenuUI.closeX){
                    contentUI.show(mScroller);
                }
            }
        }else{
            //左滑
            if(leftMenuUI != null){
                if(scrollX > leftMenuUI.startX && scrollX <= leftMenuUI.closeX){
                    leftMenuUI.show(mScroller);
                }else if(scrollX > leftMenuUI.closeX && scrollX < leftMenuUI.stopX){
                    contentUI.show(mScroller);
                }
            }
            if(rightMenuUI != null){
                if(scrollX > contentUI.startX && scrollX <= rightMenuUI.openX){
                    contentUI.show(mScroller);
                }else if(scrollX > rightMenuUI.openX){
                    rightMenuUI.show(mScroller, (int) measureWidth);
                }
            }
        }
    }

首先,根据ACTION_UP传递进来dx参数进入滑动方向的判断,这个非常重要,不同的滑动方向对于处在不同scrollX的控件来说,操作目的是不一样。我们以右滑为例,如果leftMenuUI为空,就代表用户只想要左滑功能,rightMenuUI为空,就代表用户只想要右滑功能。leftMenuUI和rightMenuUI都不为空,表示用户同时想要左滑和右滑的功能。
然后对于不同的坐标区别进行判断,并显示出对应的UI界面。这里文字一时半会儿说不明白。大家把我上面的图拿来对比代码进行分析,一会儿就看明白啦。

还有一个工具方法,是为了得到屏幕宽度的:
/**
 * Created by ccwxf on 2016/6/14.
 */
public class Util {

    public static int getScreenWidth(Context context){
        return context.getResources().getDisplayMetrics().widthPixels;
    }
}


OK,自定义控件的源代码就上面这6个文件,接下来讲讲怎么使用。
首先,我们准备三个布局文件,分别代表左菜单布局、正文内容布局和右菜单布局。

left.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/holo_red_light"
    android:orientation="vertical">

    <ListView
        android:id="@+id/listView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:listSelector="@android:color/transparent"
        android:cacheColorHint="@android:color/transparent"
        android:dividerHeight="0dp"
        android:divider="@null"
        />
</LinearLayout>

content.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/white"
    android:orientation="vertical">

    <android.support.v4.view.ViewPager
        android:id="@+id/viewPager"
        android:layout_width="match_parent"
        android:layout_height="300dp"/>

</LinearLayout>

right.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/holo_blue_light"
    android:orientation="vertical">

    <HorizontalScrollView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        >

        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:orientation="horizontal"
            >
            <TextView
                android:layout_width="300dp"
                android:layout_height="300dp"
                android:background="@android:color/holo_green_light"
                />
            <TextView
                android:layout_width="300dp"
                android:layout_height="300dp"
                android:background="@android:color/darker_gray"
                />
            <TextView
                android:layout_width="300dp"
                android:layout_height="300dp"
                android:background="@android:color/holo_orange_light"
                />
        </LinearLayout>

    </HorizontalScrollView>
</LinearLayout>

然后我们需要一个activity_main布局用户呈现Activity(这不废话么。。。)
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <cc.wxf.side.SideLayout
        android:id="@+id/sideLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
</RelativeLayout>

最后一步了,在代码中调用:如果你只想要左菜单,那么就只调用setLeftMenuView,如果只想要右菜单,那么就只调用setRightMenuView,如果左菜单和右菜单都想要,那么就一起调用。(setContentView必须调用哈,别问我为什么 难过
public class MainActivity extends Activity {

    private View leftView;
    private View contentView;
    private View rightView;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        initUI();

        SideLayout sideLayout = (SideLayout) findViewById(R.id.sideLayout);
        sideLayout.setLeftMenuView(leftView).setContentView(contentView).setRightMenuView(rightView).commit();
    }

    private void initUI(){
        leftView = View.inflate(this, R.layout.left, null);
        contentView = View.inflate(this, R.layout.content, null);
        rightView = View.inflate(this, R.layout.right, null);
        //初始化左边菜单
        ListView listView = (ListView) leftView.findViewById(R.id.listView);
        listView.setAdapter(new ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, new String[]{
                "123","456","789","101","112","123","456","789","101","112","123","456","789","101","112","123","456","789","101","112"
        }));
        //初始化正文内容
        ViewPager viewPager = (ViewPager) contentView.findViewById(R.id.viewPager);
        viewPager.setAdapter(new TestDemoAdapter());
    }

    public class TestDemoAdapter extends PagerAdapter{

        private ImageView[] imageViews = new ImageView[5];

        public TestDemoAdapter() {
            for(int i = 0; i < imageViews.length; i++){
                imageViews[i] = new ImageView(MainActivity.this);
                imageViews[i].setImageResource(R.mipmap.ic_launcher);
            }
        }

        @Override
        public int getCount() {
            return 5;
        }

        @Override
        public boolean isViewFromObject(View view, Object object) {
            return view == object;
        }

        @Override
        public Object instantiateItem(ViewGroup container, int position) {
            container.addView(imageViews[position]);
            return imageViews[position];
        }

        @Override
        public void destroyItem(ViewGroup container, int position, Object object) {
            container.removeView(imageViews[position]);
        }
    }

}

好了,讲解完了,接下来是激动人心的时候了,Demo地址~~~
点我去下载Demo哟

你可能感兴趣的:(android,自定义控件,侧拉菜单,兼容滑动控件)