Android开发不归路——自定义侧滑菜单
一、前言
磕磕碰碰自学Android也有一年之余,虽说目前从事Java Web开发,但对Android的热情丝毫没有锐减。记得当时大三暑期帮同学做的第一个Android项目(也是唯一一个T T),本着学习的目标手写那个首页侧滑页面,废了我半条命,最终胡乱一通代码最终也达到了效果。在公司沉淀了一年,是时候总结下了。
仿QQ侧滑效果演示二、知识储备
1、Scroller类VelocityTracker类的基本使用;
2、Android属性动画;
3、自定义viewgroup;
4、事件分发机制;
三、实现原理
侧滑原理图示红色的方框为我们自定的侧滑菜单ViewGroup的内容,蓝色即为该ViewGroup的可视化界面(这里即是我们的设备屏幕)。然后就是将这个可视化的界面在这个内容上面来回移动,来实现侧滑菜单的显示和隐藏。
敲黑板,上面的初始化状态getScrollX()为0,然后下面的菜单显示状态是将蓝色的方框向左移了,所以此时的getScrollX()为-menuWidth,而不是menuWidth,这点尤其需要注意。
四、编码开发
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();
}