前言
有一种控件需求,通过上下滑动来打开上下菜单。这个控件要求自动打开上下两个菜单,而且还要随着手势(注意:多触点)上下滑动菜单。之前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的全部内容,这是我设计的一个用于实现上下滑动菜单的控件。有问题的朋友可以评论留言,给我点赞也是不会拒绝的哦!
第一次写博客,心情有点小激动!