SlidingDrawerLayout上下滑动的菜单控件

前言

	有一种控件需求,通过上下滑动来打开上下菜单。这个控件要求自动打开上下两个菜单,而且还要随着手势(注意:多触点)上下滑动菜单。之前Android系统有提供一个叫SlidingDrawer(完整路径:android.widget.SlidingDrawer)的控件,似乎有类似的效果,但是它不仅过时了,而且远远达不到我提出的要求。为了满足这个需求,我设计了一款自名为SlidingDrawerLayout的控件。
	我们可以先来看看我做的这个控件的效果,读者再考虑是否需要往下看(没能展示多触点功能,有兴趣的童鞋可以下载源码自行编译运行)。

        效果图

	接下来,我将采用自定义ViewGroup的方式来实现这一控件。先说思路,再解说关键代码,最后描述使用方法,并给出github源码地址。另外,我们都知道自定义控件,计算的话大都不会太简单,有些甚至很繁琐,这个控件也有比较大的计算量,我也就不在博客中过多的介绍计算细节了(感兴趣的童鞋可以查看源码)。

实现思路

1、控件结构

	SlidingDrawerLayout直接继承于ViewGroup,它有3个直接子控件。分别是内容子控件,顶部菜单子控件和底部菜单子控件。顶部子菜单的底部和底部子菜单的顶部都有一个Tab块,方便用户按住后滑动,也可以在Tab中适当添加些与当前子菜单相关的信息。
	
	如上图所示,红线区域的高度为SlidingDrawerLayout控件内容叠加的总高度(看懂的同学可以无视接下来的这段话)。
	原始模型中,有3个与SlidingDrawerLayout等宽等高的矩形(假设高为H),深蓝色矩形代表顶部子菜单,灰色矩形代表内容子控件,浅蓝色矩形代表底部子菜单。假设把他们按“顶部、内容、底部”的顺序放在一个类似垂直的线性布局中,并且把内容子控件上下移动,调整到内容子控件完全显示的位置,那么顶部子菜单和底部子菜单均不可见。为了实现我们的需求,即顶部、底部菜单都可以按住滑动,我们可以将顶部子菜单往下移动一段距离TopTab_H,将底部子菜单往上移动一段距离BottomTab_H,内容子菜单先保持不动。现在每个子控件的高度仍然没有发生变化,细心的读者会发现,此时的内容子控件的顶部和底部都被遮住了一部分,这会使得显示在内容子控件顶部和底部的内容被遮住。那怎么办呢?这时候我们要改变内容子控件的高度了,即Content_H = H - TopTab_H - BottomTab_H,很明显这样子内容子控件就不会被遮住了。还没完,如果按照现在的样子,我们打开顶部菜单,顶部菜单下滑而填满了整个屏幕,底部菜单也是如此。这看起来好像不是很爽是吧?如果我打开了顶部菜单,又想打开底部菜单的话,非得先把顶部菜单关闭才行,因为底部菜单的Tab被遮住了,我们无法直接按住滑动。为了解决这个问题,我们把顶部子菜单的高度设置为Top_H = H - BottomTab_H,这样打开顶部子菜单的时候,底部Tab恰好完全显示出来。而底部子菜单的高度也是同理,即Bottom_H = H - TopTab_H

2、事件处理

	事件处理是实现这个控件最关键的地方之一,SlidingDrawerLayout事件处理大致上比较简单,但是计算和判断类操作的实现上确实比较让人头疼。下面我从2个方面来简单表述下处理思路,详细情况请看代码解析部分或者源码。
	一方面是事件拦截处理,即对SlidingDrawerLayout的onInterceptTouchEvent方法的处理(对事件拦截没有了解的童鞋请自行脑补)。我们先用2个ArrayMap分别存储每一个触点“上一次”的事件触发位置的(x, y)坐标,在move事件中循环遍历每一个触点,先判断每一个触点的“上一个”位置是否落在顶部或底部Tab里边,如果是的话,再看比较垂直方向和水平方向的偏移量大小,当垂直方向的偏移量明显大于水平方向的偏移量时,我们认为当前这个触点的move事件应该交由SlidingDrawerLayout来处理,因此我们要拦截这个事件。当然,我们还要标记好到底是选中了顶部还是底部,这个就确定了滑动对象。这就是SlidingDrawerLayout事件拦截处理的思路。
	另一方面是对触摸事件的响应,即对SlidingDrawerLayout的onTouchEvent方法的处理。对于触摸事件的处理,我们主要关注在move事件上。在这里我也用ArrayMap来存储了每一个触点“上一次”的纵坐标,方便计算滑动的偏移量。当move事件到来时,循环遍历所有触点,选取一个触点作为参考,用于计算偏移量和调用随手势滑动的方法。这里要注意,即便是选取了一个触点作为参考,仍然要记录每一个触点“上一次”的纵坐标,因为我们做的是多触点事件处理,在任意一次滑动中选取的参考触点可能不一样。当全部手指松开时,就要启动松开滑动机制,这是下一小节的内容。

3、松开自动滑动

	这部分内容指的是,当我们触碰SlidingDrawerLayout松开后onTouchEvent的up事件要处理的事情,这个也是实现SlidingDrawerLayout很关键的一步,它直接影响了用户体验。松开滑动集成的方法在这个控件中有2个用途,一个是为控件内部的松开滑动提供调用,另一个是给使用者外部调用,比我你点击一个按钮,菜单会打开或者关闭等。
	松开滑动的实现思路非常简单,无非就是当onTouchEvent方法的up事件到来时,调用自动打开或者关闭菜单的方法,这些方法负责根据手势的速度平滑地滑动菜单。不过,在实现的细节上也跟事件处理一样,计算会比较多。

关键代码解析

	代码解析这部分,我分为四个部分,分别是外部参数的传入,布局初始化,事件处理和对用户开放的方法。其中最需要注意的是事件处理部分,其次是布局的初始化,弄懂这两个部分就掌握了这个控件的功能运行机制了。

1、外部参数的传入

	这个控件目前开放的外部参数传入方法只有3个。其中2个是设置上下子菜单高度的方法,就是给用户按住滑动的那2片区域的高度。另外一个是对SlidingDrawerLayout子控件的传入,总共有3个子控件可以传入,分别是上、下子菜单布局和内容布局,而这个几个参数按照我的设计是写在布局文件中的。
	设置上下子菜单高度的方法,如下所示。这两个方法都有一个isPx的参数,询问用户是否以像素为单位传入,如果不是则内部会认为这是以dp为单位的值,然后对传入的值做换算。
	/**
	 * Set the top tab height.
	 * 
	 * @param value
	 * @param isPx
	 *            Whether using pixel for unit.
	 */
	public void setTopTabHeight(int value, boolean isPx) {
		if (isPx) {
			mTopTabHeight = value;
		} else {
			mTopTabHeight = getTabHeight(value);
		}
	}

	/**
	 * Set the bottom tab height.
	 * 
	 * @param value
	 * @param isPx
	 *            Whether using pixel for unit.
	 */
	public void setBottomTabHeight(int value, boolean isPx) {
		if (isPx) {
			mBottomTabHeight = value;
		} else {
			mBottomTabHeight = getTabHeight(value);
		}
	}

2、布局的初始化

	首先把子布局布局赋值给SlidingDrawerLayout里的成员变量,这部分在onFinishInflate方法完成。
	@Override
	protected void onFinishInflate() {
		super.onFinishInflate();
		// Find child.
		View topView = findView("topView");
		View contentView = findView("contentView");
		View bottomView = findView("bottomView");
		if (topView != null) {
			topView.setClickable(true);
			// Default size.
			setTopTabHeight(50, false);
			mTopView = topView;
		}
		if (contentView != null) {
			mContentView = contentView;
		}
		if (bottomView != null) {
			bottomView.setClickable(true);
			setBottomTabHeight(50, false);
			mBottomView = bottomView;
		}
	}
	然后,是要测量出上下子菜单和内容的高度,这部分在onMeasure方法完成。我们只需要根据实现思路的控件结构部分描述的那样,计算出各个子控件高度即可。
	@Override
	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		int specHeight = MeasureSpec.getSize(heightMeasureSpec);
		// Initialise top height.
		if (mTopView != null) {
			int topHeightMeasureSpec = MeasureSpec.makeMeasureSpec(specHeight
					- mBottomTabHeight, MeasureSpec.EXACTLY);
			measureChild(mTopView, widthMeasureSpec, topHeightMeasureSpec);
		}
		// Initialise content height.
		if (mContentView != null) {
			int contentHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
					specHeight - mTopTabHeight - mBottomTabHeight,
					MeasureSpec.EXACTLY);
			measureChild(mContentView, widthMeasureSpec,
					contentHeightMeasureSpec);
		}
		// Initialise bottom height
		if (mBottomView != null) {
			int bottomHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
					specHeight - mTopTabHeight, MeasureSpec.EXACTLY);
			measureChild(mBottomView, widthMeasureSpec, bottomHeightMeasureSpec);
		}
		super.onMeasure(widthMeasureSpec, heightMeasureSpec);
	}
	最后,是要把3个子控件安放在SlidingDrawerLayout的哪个位置,这是在onLayout方法中完成的,也是按照控件结构部分思路来实现的。
	@Override
	protected void onLayout(boolean changed, int left, int top, int right,
			int bottom) {
		// Initialise top position.
		if (mTopView != null) {
			int t = -(getMeasuredHeight() - mTopTabHeight - mBottomTabHeight);
			int b = mTopTabHeight;
			mTopView.layout(0, t, getMeasuredWidth(), b);
		}
		// Initialise content position.
		if (mContentView != null) {
			int t = mTopTabHeight;
			int b = getMeasuredHeight() - mBottomTabHeight;
			mContentView.layout(0, t, getMeasuredWidth(), b);
		}
		// Initialise bottom position.
		if (mBottomView != null) {
			int t = getMeasuredHeight() - mBottomTabHeight;
			int b = getMeasuredHeight() + (getMeasuredHeight() - mTopTabHeight);
			mBottomView.layout(0, t, getMeasuredWidth(), b);
		}
	}

3、事件处理

	事件处理的处理逻辑主要体现在onInterceptTouchEvent方法和onTouchEvent方法上,事件拦截是为了使用户在选中菜单Tab的时候把事件交于SlidingDrawerLayout处理,触摸方法就是处理分发下来的事件,并作出相应的动作。所以,下面我主要说一下这两个方法的代码实现。
	用于事件拦截onInterceptTouchEvent方法中,当down和pointer_down事件触发时,收集好所有触点按下的(x, y)坐标。接着,move事件触发时,遍历所有触点,先获取每一触点的当前坐标和“上一次”坐标,然后求出偏移量,最后看“上一次”坐标是否落在某个Tab区域和垂直偏移量是否明显大于水平偏移量(代码中指dx < dy - 5),如果同时满足这2个条件的话,那么这个事件就要拦截,也说明选中了哪一个菜单,并且做好标记。最后,up事件触发时,我在这里做了一些标记的重置操作。

	@Override
	public boolean onInterceptTouchEvent(MotionEvent event) {
		int action = event.getAction();
		switch (action & MotionEvent.ACTION_MASK) {
		case MotionEvent.ACTION_DOWN:
			mLastXForIntercept.clear();
			mLastYForIntercept.clear();
			// Record the first point.
			mLastXForIntercept.put(event.getPointerId(0), event.getX());
			mLastYForIntercept.put(event.getPointerId(0), event.getY());
			break;
		case MotionEvent.ACTION_POINTER_DOWN:
			mLastXForIntercept.clear();
			mLastYForIntercept.clear();
			// Record all points.
			for (int i = 0; i < event.getPointerCount(); i++) {
				int id = event.getPointerId(i);
				float x = event.getX(i);
				float y = event.getY(i);
				mLastXForIntercept.put(id, x);
				mLastYForIntercept.put(id, y);
			}
			break;
		case MotionEvent.ACTION_MOVE:
			// Check all points.
			for (int i = 0; i < event.getPointerCount(); i++) {
				int id = event.getPointerId(i);
				float x = event.getX(i);
				float y = event.getY(i);
				float lastX = mLastXForIntercept.get(id);
				float lastY = mLastYForIntercept.get(id);
				float dx = Math.abs(x - lastX);
				float dy = Math.abs(y - lastY);
				// Check top view.
				if (mTopView != null) {
					float topY = mTopView.getY();
					float topTabY = topY + mTopView.getHeight() - mTopTabHeight;
					if (lastY >= topTabY && lastY <= topTabY + mTopTabHeight) {
						if (dx < dy - 5) {
							mLastY.clear();
							mLastY.put(id, y);
							mSelectedTop = true;
							// Judge again.
							if (!shouldIntercept(false, true)) {
								return false;
							}
							return true;
						}
					}
				}
				// Check Bottom view.
				if (mBottomView != null) {
					float bottomY = mBottomView.getY();
					if (lastY >= bottomY && lastY <= bottomY + mBottomTabHeight) {
						if (dx < dy - 5) {
							mLastY.clear();
							mLastY.put(id, y);
							mSelectedBottom = true;
							// Judge again.
							if (!shouldIntercept(true, false)) {
								return false;
							}
							return true;
						}
					}
				}
				// Record last values.
				mLastXForIntercept.put(id, x);
				mLastYForIntercept.put(id, y);
			}
			break;
		case MotionEvent.ACTION_UP:
			mIsInBackEvent = false;
			// Reset
			mSelectedTop = false;
			mSelectedBottom = false;
			break;
		}
		return super.onInterceptTouchEvent(event);
	}

	事件分发给onTouchEvent方法后,我们就要让菜单做出响应了。首先被选中的菜单得要随着手势滑动,这个由slideTop和slideBottom方法来完成。然后,松开手的时候菜单要自动打开或关闭,这是由smoothSlide方法来完成的。关于这几个slide方法的具体实现,我就不在博客中描述了,里面很多计算,太繁琐的话不太好表述,感兴趣的读者自行阅读源码。
	从下面的代码中可以看出,我们先把事件添加到速度追踪器中,在后面松开手后要自动滑动的时候,需要从它里面取出当时的速度。
	再来看move事件中的逻辑,我似乎只是遍历了所有触点,但仔细看你会发现我的这句代码:if (mLastY.containsKey(id) && move) 表示的是只取了一个触点来计算偏移量,为什么呢?因为假设我们每一个触点都计算偏移量,那一次滑动触发将会是所有偏移量累加的结果,那样会滑很远距离的,看起来就不像自己滑的,我们人在感觉这个滑动是把注意力放在了一个点上,这才感觉符合自己的生活经验。那有的同学会说,我们取一个点不就可以了吗?干嘛还循环,注意循环体的最后一句mLastY.put(id, y);,我们要知道,虽然只取了一个触点作为参考,但每一个触点仍然要记录好坐标,因为在这种多触点事件中我们不能保证每一次取的参考都是同一个。
	对于up事件,有两种情况。一种是pointer_up事件,也就是多触点弹起事件,当触发这个事件时,表示某些个触点弹起了,这时我们要把它从“上一次”的坐标记录中删除。另外一种是单纯up事件,这时候表示用户的手已经彻底离开屏幕,我们要启动松开滑动操作了,smoothSlide();就实现了这个功能。当然,还要记得在up事件中做好重置操作。
	@Override
	public boolean onTouchEvent(MotionEvent event) {
		addVelocityTracker(event);
		int action = event.getAction();
		switch (action & MotionEvent.ACTION_MASK) {
		case MotionEvent.ACTION_MOVE:
			boolean move = true;
			for (int i = 0; i < event.getPointerCount(); i++) {
				int id = event.getPointerId(i);
				float y = event.getY(i);
				if (mLastY.containsKey(id) && move) {
					float lastY = mLastY.get(id);
					float distance = y - lastY;
					// Slide tab.
					if (mSelectedTop) {
						slideTop((int) distance);
					}
					if (mSelectedBottom) {
						slideBottom((int) distance);
					}
					// If slided this time.
					if (distance != 0) {
						move = false;
					}
				}
				mLastY.put(id, y);
			}
			break;
		case MotionEvent.ACTION_POINTER_UP:
			for (int i = 0; i <= event.getActionIndex(); i++) {
				int id = event.getPointerId(i);
				if (mLastY.containsKey(id)) {
					mLastY.remove(id);
				}
			}
			break;
		case MotionEvent.ACTION_CANCEL:
		case MotionEvent.ACTION_UP:
			mLastY.clear();
			smoothSlide();
			// Reset
			mSelectedTop = false;
			mSelectedBottom = false;
			break;
		}
		return super.onTouchEvent(event);
	}

4、对外开放的方法

	对外开放的方法中分为两个部分,一个是顶部菜单对外开放的方法,另一个是底部菜单对外开放的方法。顶部菜单有3个方法,分别是isTopOpened、openTop、openTopSync、closeTop。底部菜单也有3个方法,分别是isBottomOpened、openBottom、openBottomSync、closeBottom。这些方法从字面意思都知道,无非是判断打开或关闭了菜单,打开或者关闭菜单。在这里,要提一下的是openTopSync和openBottomSync,它们通常是同时使用,因为这两个方法的执行时同步的,这样保证了上下菜单不会交叉打开。

	/**
	 * Close the top view.
	 */
	public void openTop() {
		if (mTopView != null) {
			float startY = mTopView.getY();
			openCloseTop(startY, true);
		}
	}

	/**
	 * Close the top view by sync.
	 */
	public void openTopSync() {
		open(true, false);
	}

	/**
	 * Close the top view.
	 */
	public void closeTop() {
		if (mTopView != null) {
			float startY = mTopView.getY();
			openCloseTop(startY, false);
		}
	}

	/**
	 * Whether top view opened.
	 * 
	 * @return
	 */
	public boolean isTopOpened() {
		if (mTopView != null) {
			float startY = mTopView.getY();
			return startY == 0;
		}
		return false;
	}

	/**
	 * Open the bottom view.
	 */
	public void openBottom() {
		if (mBottomView != null) {
			float startY = mBottomView.getY();
			openCloseBottom(startY, true);
		}
	}

	/**
	 * Open the bottom view by sync.
	 */
	public void openBottomSync() {
		open(false, true);
	}

	/**
	 * Close the bottom view.
	 */
	public void closeBottom() {
		if (mBottomView != null) {
			float startY = mBottomView.getY();
			openCloseBottom(startY, false);
		}
	}

	/**
	 * Whether bottom view opened.
	 * 
	 * @return
	 */
	public boolean isBottomOpened() {
		if (mBottomView != null) {
			float startY = mBottomView.getY();
			return startY == mTopTabHeight;
		}
		return false;
	}
	至此,我们的上下拉SlidingDrawerLayout就算完成了。总结下,实现这个控件首先要设计好控件的结构,比如宽高、如何摆放等,然后是要处理好onInterceptTouchEvent和onTouchEvent这两个方法,还有一个比较重要的是松开手之后要实现自动滑动,最后是设计好对外开放的接口或方法。

使用示例

	知道如何实现这个控件之后,我再来讲讲如何使用这个控件,这个也是很多童鞋关注的地方吧。按照我的设计,只需要把该控件置于xml布局中作为父容器,然后往里面添加3个子布局作为内容,然后在Java代码中调用对外开放的方法就可以了。
	首先,我们把SlidingDrawerLayout放在xml布局中作为一个父容器,向里边添加一个内容布局,id设置为contentView,然后再向引入菜单布局,顶部菜单布局id为topView,而底部菜单布局id为bottomView。这里要注意,内容布局一定要有,但不能只有内容布局,那样的话这个控件也没有意义,还有顶部和底部菜单可以任选,可以引入一个菜单,也可以引入两个,当然没必要不给菜单对吧?每个子布局的id一定要按要求设置,不然找不到控件的。

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >

    <com.slidingdrawerlayout.view.SlidingDrawerLayout
        android:id="@+id/slidingDrawer"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/content_bg" >

        <include
            android:id="@+id/contentView"
            layout="@layout/content" />

        <include
            android:id="@+id/bottomView"
            layout="@layout/bottom" />

        <include
            android:id="@+id/topView"
            layout="@layout/top" />
    </com.slidingdrawerlayout.view.SlidingDrawerLayout>

</RelativeLayout>

	最后,我们再来看看Java代码如何使用。先从布局获取到SlidingDrawerLayout控件,再设置它的菜单Tab高度,接下来开发人员可以自由设置打开或关闭的逻辑。

public class MainActivity extends Activity implements OnClickListener {

	private SlidingDrawerLayout mSlidingDrawer;
	private View mTopBtn, mBottomBtn;

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_main);
		findView();
		initView();
	}

	private void findView() {
		mSlidingDrawer = (SlidingDrawerLayout) findViewById(R.id.slidingDrawer);
		mTopBtn = findViewById(R.id.topBtn);
		mBottomBtn = findViewById(R.id.bottomBtn);
	}

	private void initView() {
		Resources res = getResources();
		int topBarSize = (int) res.getDimension(R.dimen.topBarSize);
		int bottomBarSize = (int) res.getDimension(R.dimen.bottomBarSize);
		mSlidingDrawer.setTopTabHeight(topBarSize, true);
		mSlidingDrawer.setBottomTabHeight(bottomBarSize, true);

		mTopBtn.setOnClickListener(this);
		mBottomBtn.setOnClickListener(this);
	}

	@Override
	public void onClick(View v) {
		if (v.getId() == R.id.topBtn) {
			if (mSlidingDrawer.isBottomOpened()) {
				mSlidingDrawer.closeBottom();
			} else {
				if (mSlidingDrawer.isTopOpened()) {
					mSlidingDrawer.closeTop();
				} else {
					mSlidingDrawer.openTopSync();
				}
			}
		} else if (v.getId() == R.id.bottomBtn) {
			if (mSlidingDrawer.isTopOpened()) {
				mSlidingDrawer.closeTop();
			} else {
				if (mSlidingDrawer.isBottomOpened()) {
					mSlidingDrawer.closeBottom();
				} else {
					mSlidingDrawer.openBottomSync();
				}
			}
		}
	}
}

结语

	这是SlidingDrawerLayout的github地址:GitHub - xu0425/SlidingDrawerLayout(欢迎下载,记得给颗星哈!)
	以上就是SlidingDrawerLayout的全部内容,这是我设计的一个用于实现上下滑动菜单的控件。有问题的朋友可以评论留言,给我点赞也是不会拒绝的哦!
	第一次写博客,心情有点小激动!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值