Android开发不归路——自定义侧滑菜单

一、前言

磕磕碰碰自学Android也有一年之余,虽说目前从事Java Web开发,但对Android的热情丝毫没有锐减。记得当时大三暑期帮同学做的第一个Android项目(也是唯一一个T T),本着学习的目标手写那个首页侧滑页面,废了我半条命,最终胡乱一通代码最终也达到了效果。在公司沉淀了一年,是时候总结下了。

这里写图片描述

二、知识储备

1、Scroller类VelocityTracker类的基本使用;

2、Android属性动画;

3、自定义viewgroup;

4、事件分发机制;

三、编码开发

1、新建attrs.xml,编写自定义菜单的属性;

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="SlidingMenu">
        <!-- 菜单所占屏幕宽度的比例 -->
        <attr name="menu_width_rate" format="float"/>
        <!-- 侧滑菜单的滚动模式 -->
        <attr name="sliding_mode" format="enum">
            <enum name="normal" value="0" />
            <enum name="drawer" value="1" />
            <enum name="qq" value="2" />
        </attr>
        <!-- 主界面的最低透明度 -->
        <attr name="content_alpha" format="float" />
        <!-- 仿QQ侧滑时视图缩小的最低比例 -->
        <attr name="scale_rate" format="float" />
        <!-- 手指抬起视图滚动动画的持续时间 -->
        <attr name="animator_time" format="integer" />
    </declare-styleable>
</resources>

2、新建相关常量接口Constants.java,配置一些相关默认值常量;

public interface Constants {

    // 菜单侧滑模式
    int NORMAL = 0;
    int DRAWER = 1;
    int QQ = 2;

    // 最低触发菜单动画效果水平速率
    int MIN_VELOCITY = 500;

    // 默认主界面最低透明度
    float MIN_ALPHA = 0.7F;

    // 默认菜单占比
    float MENU_WIDTH_RATE = 0.7F;

    // 默认视图缩放最小比例
    float SCALE_RATE = 0.7F;

    // 默认滚动动画持续时间(ms)
    int ANIMATOR_TIME = 250;
}

3、新建SlidingMenu,继承自ViewGroup,实现前面的常量接口;

1)获取xml布局的自定义属性;
public SlidingMenu(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    // 获取xml定义的属性(如果存在的话)
    TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SlidingMenu);
    for (int i = 0; i < a.getIndexCount(); i++) {
        int attr = a.getIndex(i);
        if (attr == R.styleable.SlidingMenu_menu_width_rate){
            menuWidthRate = a.getFloat(attr, MENU_WIDTH_RATE);
        } else if (attr == R.styleable.SlidingMenu_sliding_mode){
            slidingMode = a.getInteger(attr, NORMAL);
        } else if (attr == R.styleable.SlidingMenu_content_alpha){
            minAlpha = a.getFloat(attr, MIN_ALPHA);
        } else if (attr == R.styleable.SlidingMenu_scale_rate){
            scaleRate = a.getFloat(attr, SCALE_RATE);
        } else if (attr == R.styleable.SlidingMenu_animator_time){
            animatorTime = a.getInt(attr, ANIMATOR_TIME);
        }
    }
    a.recycle();
    scroller = new Scroller(context);
    // 使得该自定义布局能够触发完整的事件过程
    setClickable(true);
}

可能你已经注意到了,这里并没有使用switch,而选择了if-else,这是因为我后面要将这个生成一个library,让其他module引用,但是library中attrs.xml中的属性值不是final类型的,因此不能使用switch,换种方式用if-else代替即能实现。

2)重写onMeasure方法,测量各布局的宽高大小;
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    if (!once) {
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        rightContent = (ViewGroup) getChildAt(0);
        leftMenu = (ViewGroup) getChildAt(1);
        // 计算左菜单的宽度
        int leftMenuWidth = (int) (widthSize * menuWidthRate);
        // 分别测量左菜单,右边内容布局的大小
        leftMenu.measure(MeasureSpec.makeMeasureSpec(leftMenuWidth, MeasureSpec.EXACTLY), heightMeasureSpec);
        rightContent.measure(widthMeasureSpec, heightMeasureSpec);
        // 避免在drawer情况事件穿透menu布局到达content
        leftMenu.setClickable(true);
        once = true;
    }
    setMeasuredDimension(widthMeasureSpec, heightMeasureSpec);
}

这里rightContent是getChildAt(0),而leftMenu是getChildAt(1)。我需要解释下,在drawer(抽屉)模式中,menu布局应该是在content布局上面的,这是一个布局的层级关系,所以得先放content,在将menu叠在上面,在xml中体现就是menu代码放在content代码的后面。

3)重写onLayout方法,布置好每一个布局的位置;
if (changed){
    // 布置布局位置
    layout(l, t, r, b);
    int leftWidth = leftMenu.getMeasuredWidth();
    leftMenu.layout(-leftWidth, t, l, b);
    rightContent.layout(l, t, r, b);
    setAnimateViewPivot();
}
4)设置menu和content布局的缩放中心;
public void setAnimateViewPivot() {
    leftMenu.setPivotX(leftMenu.getWidth());
    leftMenu.setPivotY(leftMenu.getHeight() / 2);
    rightContent.setPivotX(0);
    rightContent.setPivotY(rightContent.getHeight() / 2);
}
5)编写各种侧滑模式的动画效果;
public void animator(){
    int offsetX = -getScrollX();
    float offRate = offsetX * 1.0f / leftMenu.getWidth();
    float alphaDegree = (minAlpha - 1) * offRate + 1;
    switch (slidingMode){
        case NORMAL:
            rightContent.animate().alpha(alphaDegree).setDuration(0).start();
            break;
        case DRAWER:
            rightContent.animate().translationX(-offsetX).alpha(alphaDegree).setDuration(0).start();
            break;
        case QQ:
            // 菜单动画
            float menuDegree = (1 - scaleRate) * offRate + scaleRate;
            leftMenu.animate().scaleX(menuDegree).scaleY(menuDegree).setDuration(0).start();
            // 内容动画
            float contentDegree = (scaleRate - 1) * offRate + 1;
            rightContent.animate().scaleX(contentDegree).scaleY(contentDegree).alpha(alphaDegree).setDuration(0).start();
            break;
        default:
            break;
    }
}
6)编写好VelocityTracker各个方法;
/**
 * 初始化VelocityTracker对象,并将触摸滑动事件加入到VelocityTracker当中
 */
private void createVelocityTracker(MotionEvent event) {
    if (mVelocityTracker == null) {
        mVelocityTracker = VelocityTracker.obtain();
    }
    mVelocityTracker.addMovement(event);
}

/**
 * 获取手指在content界面滑动的速度
 */
private int getScrollVelocity() {
    mVelocityTracker.computeCurrentVelocity(1000);
    return (int) mVelocityTracker.getXVelocity();
}

/**
 * 回收VelocityTracker对象。
 */
private void recycleVelocityTracker() {
    mVelocityTracker.recycle();
    mVelocityTracker = null;
}
7)编写Scroller相关一些方法,便于调用;
public void smoothScrollTo(int tarX, int tarY) {
    int offsetX = tarX - getScrollX();
    int offsetY = tarY - getScrollY();
    smoothScrollBy(offsetX, offsetY);
}

//调用此方法设置滚动的相对偏移
public void smoothScrollBy(int offsetX, int offsetY) {
    //设置scroller的滚动偏移量
    scroller.startScroll(getScrollX(), getScrollY(), offsetX, offsetY, animatorTime);
    postInvalidate();
}

@Override
public void computeScroll() {
    //先判断scroller滚动是否完成
    if (scroller.computeScrollOffset()) {
        //这里调用View的scrollTo()完成实际的滚动
        scrollTo(scroller.getCurrX(), scroller.getCurrY());
        animator();
        postInvalidate();
    }
    super.computeScroll();
}
8)处理触摸事件,重写dispatchTouchEvent方法;
private float lastX;

@Override
public boolean dispatchTouchEvent(MotionEvent event) {
    createVelocityTracker(event);
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            lastX = event.getX();
            break;
        case MotionEvent.ACTION_MOVE:
            // 注意这里的偏移量是上一次的坐标减去当前的坐标值
            int offsetX = (int) (lastX - event.getX());
            // 越界判断处理
            if (getScrollX() + offsetX < -leftMenu.getMeasuredWidth() || getScrollX() + offsetX > 0){
                break;
            }
            scrollBy(offsetX, 0);
            animator();
            lastX = event.getX();
            break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            lastX = 0;
            int menuWidth = leftMenu.getWidth();
            int velocity = getScrollVelocity();
            if (-getScrollX() >= menuWidth / 2){
                if (velocity < -MIN_VELOCITY){
                    smoothScrollTo(0, 0);
                } else {
                    smoothScrollTo(-menuWidth, 0);
                }
            } else {
                if (velocity > MIN_VELOCITY){
                    smoothScrollTo(-menuWidth, 0);
                } else {
                    smoothScrollTo(0, 0);
                }
            }
            recycleVelocityTracker();
            break;
    }
    return super.dispatchTouchEvent(event);
}
9)添加content遮罩层,在菜单展示时展示消费掉触摸事件;
/**
 * 主界面遮罩,处理事件
 */
class Mask extends LinearLayout implements OnClickListener{

    public Mask(Context context) {
        super(context);
        setOnClickListener(this);
    }

    @Override
    public void onClick(View v) {
        ViewGroup parent = (ViewGroup)getParent();
        if (parent.getScrollX() == -leftMenu.getWidth()){
            smoothScrollTo(0, 0);
        }
    }
}

然后全局定义private Mask mask;记得在onMeasure和onLayout中分别加上去
mask.measure(widthMeasureSpec, heightMeasureSpec);addView(mask);
mask.layout(l, t, r, b);最主要的是在computeScroll方法中加上视图滚动时mask的状态:

@Override
public void computeScroll() {
    //先判断scroller滚动是否完成
    if (scroller.computeScrollOffset()) {
        //这里调用View的scrollTo()完成实际的滚动
        scrollTo(scroller.getCurrX(), scroller.getCurrY());
        animator();
        postInvalidate();
    } else {
        // 视图滚动完成触发,当菜单完全隐藏才隐藏遮罩
        if (getScrollX() == 0){
            if (mask.getVisibility() == VISIBLE){
                mask.setVisibility(GONE);
            }
        } else {
            if (mask.getVisibility() == GONE){
                mask.setVisibility(VISIBLE);
            }
        }
    }
    super.computeScroll();
}

四、问题总结

1、getScrollX之类的scrollx都是指的视图在内容上的移动,而不是内容在视图上的移动,两者参照物不一样;

2、层叠的兄弟布局,顶层如果具备消费能力则会消费掉触摸事件,导致下面的布局响应不到;

3、computeScroll只是用于提供插值计算,真正使得视图滚动是scrollTo(scrollBy本质还是scrollTo);

4、布局能够响应一个完整的触摸事件,最简单的办法就是setClickable(true);

五、开源地址

https://github.com/JessicaJayPaul/slide

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值