自定义SlideMenu侧滑菜单

效果图:


要实现上面的效果,首先需要理解2个概念,一个是内容视图,另一个是布局边框,看下图:


布局边框可以理解为手机的屏幕,内容视图就是用于展示到屏幕显示的内容,我们所能看到的区域就是布局边框里面的区域,如果内容视图比较大,那么超出布局边框的区域将不被我们所看到,这就需要通过移动布局边框来显示我们想要看到的内容视图了.需要注意的是布局边框的移动和内容视图的移动是相对的,也就是说布局边框向左移动的话,内容视图就是向右移动,反之,布局边框向右移动的话,内容视图将向左移动.这段话可以想象成汽车和窗外的风景的移动关系,把汽车当做布局边框,窗外的风景当做内容视图,我们所看到的风景总是和汽车的运动相反的.其实风景并没有移动,只是汽车在移动而已,这样理解起来就很形象了.

那么移动布局边框的方式有哪几种呢?

1.通过layout(left,top,right,bottom)方法传入左,上,右,下的坐标来改变布局的位置

2.通过offsetTopAndBottom(offset)和offsetLeftAndRight(offset)方法来设置偏移量,这个方法是View的方法

3.通过scrollTo(x,y)和scrollBy(x,y)方法来移动布局边框,这2个方法也是View的方法,有什么区别呢?

scrollTo方法是将View移动到(x,y)位置,而scrollBy则是将view在原来的基础上再移动(x,y)距离.其方法内部还是调用了scrollTo,源码如下:

/**
     * Move the scrolled position of your view. This will cause a call to
     * {@link #onScrollChanged(int, int, int, int)} and the view will be
     * invalidated.
     * @param x the amount of pixels to scroll by horizontally
     * @param y the amount of pixels to scroll by vertically
     */
    public void scrollBy(int x, int y) {
        scrollTo(mScrollX + x, mScrollY + y);
    }


实现侧滑菜单效果的大体步骤如下:

1.需要创建2套布局,分别是侧边栏布局和主体内容布局

2.自定义SlideMenu控件继承至ViewGroup类,因为SlideMenu需要作为容器来添加侧边栏menuView和主体布局mainView

3.在SlideMenu的onMeasure方法中分别测量出侧边栏和主体布局的宽高

4.在SlideMenu的onLayout方法中初始确定侧边栏和主体布局的位置,初始状态下,侧边栏是隐藏的,而主体布局是完全显示的,在onLayout方法中分别设置menuView和mainView的layout方法来定位

5.重写onTouchEvent方法来处理menuView和mainView的滑动效果

6.解决松手后的自动滑动问题,例如拉出menuView的宽度小于menuView宽度的1/2,松手后需要隐藏,否则需要显示.

7.重写onInterceptTouchEvent方法处理menuView和SlideMenu的滑动冲突问题


需要注意的的几点问题:

1.滑动侧边栏时的边界值问题,即侧边栏划出的时候是有最大距离的,换句话说就是布局边框向左移动的时候,是有最大X坐标的,这个值刚好等于侧边栏宽度的负值,同样主体布局显示的时候也是有最大距离的,即布局边框向右移动的时候最大X坐标不能超过原点0.这里的布局边框的移动坐标以其左上角的坐标作为参照点.

2.松手后的自动滑动处理,有3种方式实现,分别是:

1)通过View的scrollTo和scrollBy方法,此方法是瞬间完成的

2)通过自定义Animation动画,特点:可以让指定view在一段时间内做移动

3)通过Scroller类来模拟移动,特点:不是真的改变view的位置,需要配合重写computerScroll方法和Scroller的computeScrollOffset(),后者是用来判断当前动画是否已经结束.如果没有结束,则一直返回true,结束了则返回false

3.需要解决侧边栏menuView的ScrollView和SlideMenu的滑动冲突问题,如果不处理的话,手指在menuView上水平滑动的时候是没有效果的,因为被ScrollView处理了触摸事件了.

4.需要解决手指的移动方向和布局边框的移动方向的问题,默认情况下手指的移动方向就是布局边框的移动方向,而我们要实现的效果就是手指的移动方向和布局边框移动的方向是相反的,即,手指向左移动,布局边框就要向右移动,内容视图才能跟着手指的移动而向左移动了.同理,手指向右移动,布局边框就要向左移动,内容视图才能跟着手指向右移动.


好了,分析了理论之后,就来看例子吧.

1.menuView的布局文件:

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="240dp"
    android:layout_height="match_parent"
    android:background="@drawable/menu_bg3"
    android:orientation="vertical">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:paddingBottom="300dp">

        <TextView
            style="@style/MenuTabText"
            android:background="#33aa9900"
            android:drawableLeft="@drawable/tab_news"
            android:text="新闻" />

        <TextView
            style="@style/MenuTabText"
            android:drawableLeft="@drawable/tab_read"
            android:text="订阅" />

        <TextView
            style="@style/MenuTabText"
            android:drawableLeft="@drawable/tab_ties"
            android:text="跟帖" />

        <TextView
            style="@style/MenuTabText"
            android:drawableLeft="@drawable/tab_pics"
            android:text="图片" />

        <TextView
            style="@style/MenuTabText"
            android:drawableLeft="@drawable/tab_ugc"
            android:text="话题" />

        <TextView
            style="@style/MenuTabText"
            android:drawableLeft="@drawable/tab_vote"
            android:text="投票" />

        <TextView
            style="@style/MenuTabText"
            android:drawableLeft="@drawable/tab_focus"
            android:text="聚合阅读" />

    </LinearLayout>
</ScrollView>

2.mainView的布局文件:

<?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="#55666666"
    android:orientation="vertical" >
    <LinearLayout android:layout_width="match_parent"
       android:background="#0E2641"
        android:gravity="center_vertical"
        android:layout_height="60dp">
        
        <ImageView android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/btn_back"
            android:background="@drawable/main_back"/>
        
        <View android:layout_width="1dp"
            android:layout_height="match_parent"
            android:layout_marginTop="5dp"
            android:layout_marginBottom="5dp"
            android:background="@android:color/darker_gray"
           />
        
        <TextView android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textColor="#ffffff"
            android:textSize="22sp"
            android:layout_marginLeft="15dp"
            android:text="Chenys"/>
        
    </LinearLayout>
    
    <TextView android:layout_width="match_parent"
        android:text="Menu layout"
        android:textColor="#000000"
        android:textSize="30sp"
        android:gravity="center"
        android:layout_height="match_parent"/>

</LinearLayout>

3.自定义SlideMenu控件:

 * Created by mChenys on 2015/12/9.
 */
public class SlideMenu extends ViewGroup {
    private View menuView;//侧边栏子View
    private View mainView;//主体子View
    private int menuWidth;//侧边栏的宽度

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

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

    /**
     * 当ViewGroup的直接子控件的初始化完毕后回调
     * 可以在这里获取到直接子View的引用
     */
    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        menuView = getChildAt(0); //侧边栏布局
        mainView = getChildAt(1); //主体布局
        menuWidth = menuView.getLayoutParams().width;//侧边栏的宽度
    }

    /**
     * 测量SlideMenu
     * widthMeasureSpec和heightMeasureSpec是系统测量SlideMenu时传入的参数,
     * 2个参数测量出的宽高能让SlideMenu充满窗体,其实是正好等于屏幕宽高
     *
     * @param widthMeasureSpec
     * @param heightMeasureSpec
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        System.out.println("SlideMenu的宽高->width:" + MeasureSpec.getSize(widthMeasureSpec) +
                " height:" + MeasureSpec.getSize(heightMeasureSpec));//width:540 height:863
        //SlideMenu的宽高就是上面的System.out.println输出的log
        //测量侧边栏的宽高,宽度等于布局中写死的240dp,高度模式和ViewGroup的一样
        int menuWidthMeasureSpec = MeasureSpec.makeMeasureSpec(menuWidth, MeasureSpec.EXACTLY);
        menuView.measure(menuWidthMeasureSpec, heightMeasureSpec);
        //测量主布局的宽高,这里由于主布局是刚好显示在整个手机屏幕的,所以可以使用ViewGroup的宽高模式测量
        mainView.measure(widthMeasureSpec, heightMeasureSpec);
    }

    /**
     * 定位SlideMenu,在onMeasure方法之后执行
     *
     * @param changed 布局是否发生变化
     * @param left    SlideMenu的左边距离坐标原点在x轴方向的坐标
     * @param top     SlideMenu的顶部距离坐标原点在y轴方向的坐标
     * @param right   SlideMenu的右边距离坐标原点在x轴方向的坐标(left+SlideMenu的宽度)
     * @param bottom  SlideMenu的底部距离坐标原点在y轴方向的坐标(top+SlideMenu的高度)
     */
    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        System.out.println("SlideMenu的位置left:" + left + " top:" + top + " right:" + right + " bottom:" + bottom);// left:0 top:0 right:540 bottom:863
        //SlideMenu的位置就是上面的System.out.println输出的log
        //这里需要确定的是SlideMenu的子View的位置
        //侧边栏子View初始位置是隐藏在手机屏幕的左边的,注意,此时menuView经过measure之后,menuView.getMeasuredWidth()就等于了menuWidth
        menuView.layout(-menuWidth, 0, 0, menuView.getMeasuredHeight());
        //主布局子View的位置,初始是显示在整个屏幕中
        mainView.layout(0, 0, mainView.getMeasuredWidth(), mainView.getMeasuredHeight());

    }

    //手指按下屏幕时的x坐标
    private int downX;

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                //记录手指按下的位置
                downX = (int) ev.getX();
                break;
            case MotionEvent.ACTION_MOVE:
                //获取手指在屏幕移动的距离
                //获取移动时当前的X坐标
                int currX = (int) ev.getX();
                //计算2个坐标间的移动距离
                //正值表示手指在向右移动,负值表示手指在向左移动,手指移动的方向就代表布局边框的移动方向,内容视图的移动方向和布局视图的移动方向是相反的
                //类似汽车和窗外风景的相对移动,把汽车当做布局边框,窗外看到的风景就是视图的内容.
                int detalX = currX - downX;
                //如何让手指移动的方向和布局边框的移动方向相反呢?
                //因为手指的移动方向就是布局边框的移动方向,所以可以将手指移动的距离的值取反即可,即向右移动的时候是负数,向左移动的时候是正数
                //计算布局边框的移动距离,这里getScrollX是获取当前的X的坐标,-detalX是为了让布局视图的移动方向和手指的移动方向相反,这样就能达到内容视图的方向和手指移动的方向同步.
                int newScrollX = getScrollX() - detalX;//可理解为 getScrollX() + (-detalX);
                //限制布局边框向左移动的最大距离,刚好等于侧边栏宽度的负数,因为此时布局边框是从0变化到负数的;也就侧边栏完全拉出时的X坐标(参考矩形边框的左上角)
                if (newScrollX < -menuWidth) {
                    newScrollX = -menuWidth;
                }
                //限制布局边框向右移动的最大距离,刚好等于0,因为此时布局视图是从负数变化到0的;也就是侧边栏完全隐藏时的X坐标(参考矩形边框的左上角)
                if (newScrollX > 0) {
                    newScrollX = 0;
                }
                //通过scrollTo方法移动布局视图,记住:scrollTo和scrollBy移动的目标都是布局边框
                scrollTo(newScrollX, 0);
                //更新按下时的x坐标
                downX = currX;
                break;
            case MotionEvent.ACTION_UP:
                //当手指离开屏幕的时候还需要判断此时布局边框停留的位置,已决定松手后是要打开还是关闭侧边栏
                //以什么作为参考呢?
                //可以拿侧边栏的宽度作为参考,如果当前布局边框的X坐标>侧边栏宽度负数的1/2,那么松手后需要隐藏侧边栏,因为拉出的距离都不够侧边栏的一半.
                //相反,如果布局边框的X坐标<侧边栏宽度负数的1/2,那么松手后,需要将侧边栏打开,因为此时拉出的距离已经大于了侧边栏宽度的一半了.
                //如何控制打开和关闭呢?
                //通过移动SlidMenu的X坐标实现,有3种方式,分别是:
                //1.通过View的scrollTo和scrollBy方法,此方法是瞬间完成的
                //2.通过自定义Animation动画,特点:可以让指定view在一段时间内做移动
                //3.通过Scroller类来模拟移动,特点:不是真的改变view的位置,需要配合重写computerScroll方法
                if (getScrollX() > -menuWidth / 2) {
                    //关闭侧边栏
                    closeMenu();
                } else {
                    //打开侧边栏
                    openMenu();
                }
                break;
        }
        return true;
    }

    /**
     * 打开侧边栏
     */
    private void openMenu() {
        //方式1:
        //scrollTo(-menuWidth, 0);
        //方式2:
        //startAnimation(new ScrollAnimation(this, -menuWidth));
        //方式3:startScroll的4个参数含义:startX, startY为开始滚动的位置,dx,dy为滚动的偏移量, duration为完成滚动的时间
        //偏移量=终点坐标-起点坐标,其中getScrollX就是起点坐标
        mScroller.startScroll(getScrollX(), 0, -menuWidth - getScrollX(), 0);
        invalidate();//别忘记调用这个方法,调用该方法可以触发computeScroll
    }

    /**
     * 关闭侧边栏
     */
    private void closeMenu() {
        //方式1:
        //scrollTo(0, 0);
        //方式2:
        //startAnimation(new ScrollAnimation(this, 0));
        //方式3:需要创建Scroller对象
        mScroller.startScroll(getScrollX(), 0, 0 - getScrollX(), 0);
        invalidate();
    }

    //这个类封装了滚动相关
    private Scroller mScroller;

    private void init() {
        //结合第三种方式通过Scroller来滑动侧边栏
        mScroller = new Scroller(getContext());
    }

    /**
     * 重写View的computeScroll方法
     * Scroller不会主动调用该方法,而invalidate()方法可以调用这个方法
     * 原因:invalidate->draw->computeScroll
     */
    @Override
    public void computeScroll() {
        super.computeScroll();
        if (mScroller.computeScrollOffset()) {
            //返回true,表示动画没结束
            scrollTo(mScroller.getCurrX(), 0);//不断的移动
            invalidate();
        }
    }

    /**
     * 切换菜单的开和关,由外界的toggle button调用
     */
    public void switchMenu() {
        if (getScrollX() == 0) {
            //需要打开
            openMenu();
        } else {
            //需要关闭
            closeMenu();
        }
    }

    private int downY;//手指按下屏幕时的Y坐标

    /**
     * 重写次方法来解决menuView和SlideMenu的滑动冲突问题,因为默认情况下onInterceptTouchEvent是返回false的,即SlideMenu是不拦截事件的
     * 这样就会造成水平滑动menuView的时候滑动不了,因为menuView是一个ScrollView,而ScrollView的onTouchEvent方法中是有处理滑动事件的,
     * 查看ScrollView的onTouchEvent源码会发现是直接return true的.所以,如果想要水平滑动menuView的话,SlideMenu就必须要拦截水平滑动的事件,
     * 可以通过判断水平滑动的距离和垂直滑动的距离作对比,如果水平滑动的距离较大,则拦截事件,否则不拦截.
     * @param ev
     * @return
     */
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                downX = (int) ev.getX();
                downY = (int) ev.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = (int) (ev.getX() - downX);
                int deltaY = (int) (ev.getY() - downY);
                if (Math.abs(deltaX) > Math.abs(deltaY)) {
                    //如果水平滑动的距离大于垂直滑动的距离,则拦截事件
                    return true;
                }
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }
}

4.mainActivity测试类的布局文件

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <mchenys.net.csdn.blog.myslidmenu.view.SlideMenu
        android:id="@+id/slideMenu"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <!--引入侧边栏布局文件-->
        <include layout="@layout/layout_menu" />
        <!--引入主体布局-->
        <include layout="@layout/layout_main" />

    </mchenys.net.csdn.blog.myslidmenu.view.SlideMenu>
</RelativeLayout>

5.MainActivity测试类:

public class MainActivity extends AppCompatActivity {
    private ImageView btn_back;
    private SlideMenu slideMenu;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        btn_back = (ImageView) findViewById(R.id.btn_back);
        slideMenu = (SlideMenu) findViewById(R.id.slideMenu);

        btn_back.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                slideMenu.switchMenu();
            }
        });

    }
}

上面的MainActivity的主题是没有actionBar的.通过在清单文件中设置

 <activity android:name=".MainActivity" android:theme="@style/Theme.AppCompat.NoActionBar" >

最后补上自定义滚动动画的代码

/**
 * 让指定view在一段时间内scrollTo到指定位置的自定义动画
 * Animation是缩放,平移,透明度,旋转变化这4种补间动画的父类
 * Created by mChenys on 2015/12/14.
 */
public class ScrollAnimation extends Animation {
    private View view; //要滚动的目标View
    private int targetScrollX; //滚动到的目标X轴位置
    private int startScrollX;  //开始滚动目标的X轴位置
    private int totalValue;    //滚动的总距离

    public ScrollAnimation(View view, int targetScrollX) {
        super();
        this.view = view;
        this.targetScrollX = targetScrollX;
        startScrollX = view.getScrollX();
        totalValue = targetScrollX - startScrollX;
        //设置移动的完成时间,时间可以自定义
        int time = Math.abs(totalValue) * 100;
        setDuration(time);
    }

    /**
     * 在指定的时间内一直执行该方法,直到动画结束
     * interpolatedTime:0-1  标识动画执行的进度或者百分比
     * 当前的值 = 起始值 + 总的差值*interpolatedTime
     * 例如:
     * interpolatedTime :  0   - 0.5  - 0.7  -   1
     * 当前的值:           10  -  60  -  80  -  110
     */
    @Override
    protected void applyTransformation(float interpolatedTime, Transformation t) {
        super.applyTransformation(interpolatedTime, t);
        //计算当前移动的距离
        int currentScrollX = (int) (startScrollX + totalValue * interpolatedTime);
        //不断移动
        view.scrollTo(currentScrollX, 0);
    }
}


源码做了部分修改


下载源码





你可能感兴趣的:(自定义SlideMenu侧滑菜单)