仿QQ侧滑(实现的效果跟QQ侧滑菜单一样)
当你们都拿着年终开着年会happy的时候,我还在苦逼的赶项目,老板带着外贸部的美女去拉斯维加斯参展马上要回来了,不得不加紧,本来月初就打算上的一篇博客,硬是被推到了现在。侧滑菜单这个功能出来很久了,网上也有很多开源框架,本身也没啥写的,写这个玩意还是源于一个同行网友,他说找不到ListView的侧滑菜单,我说现在大家都用RecyclerView了,你用啥子ListView,他说经理要他们用,好吧,经理都说了,不行也得行了,一年多网友了,我也不好拒绝,只好抽了点时间给他写了个,下面给大家展示下第一个版本:
为什么说第一个版本,因为我那网友又说话了,这样不行,要跟QQ的一样,他们经理说的,这年头经理就是牛,写都写了再改改呗。(不过原来的写法确实也存在一些问题,其中一点就是,如果你快速滑动一个item之后,立马快速滑动另一个item,就出现bug了,虽然一般没人这么无聊,下面给大家看看这个bug:)
说到这一点,我也下了几个开源的侧滑demo,基本都存在这个问题,所以我觉得QQ那么玩,确实考虑要周全很多,于是为了网友不被经理P,我又抽时间改成了第二个版本,这个版本的操作就跟QQ基本一致了,如果有侧滑菜单打开,那么用户的下一次任何操作都只会让打开的策划菜单关闭,其他啥都做不了,这样也就避免了上图出现的bug,当然为了一劳永逸,我写了ListView和RecycleView版本,思路基本一致,代码稍有差别,避免网友的经理又有新的想法:
当然因为这个功能相对单一,就是一个侧滑菜单(侧滑菜单布局内容支持自定义),所以我个人建议你们可以参照我的源码思想,再结合网上开源的功能更强大的框架,在上面稍作修改,这样就两全其美了。因为时间仓促,如果还有其他bug,欢迎留言……接下来我们就开始讲侧滑菜单的实现:
自定义LinearLayout —— PLinearLayout
一、准备工作(Item选择自定义LinearLayout):
这里的准备工作,主要是知识上的,首先你要熟悉事件分发的机制、熟悉让View移动的常用方法scrollTo(绝对)和scrollBy(相对)、Scroll或OverScroll(用于控制View移动的速度),当你这些都基本了解之后,接下来的内容就So Easy!了。因为ListView和RecycleView实现侧滑基本思路一致,所以我只讲解针对ListView,当然一些有差别的地方我也会说明。为了方便各位的使用,Item我选择重写LinearLayout,取名为:PLinearLayout。
我们先来看一下构造函数需要初始化哪些参数:
public PLinearLayout(Context context, AttributeSet attrs) {
super(context, attrs);
//用于控制View的移动速度
mScroller = new Scroller(context, new LinearInterpolator());
ViewConfiguration viewConfig = ViewConfiguration.get(context);
//正常滑动下触发move事件的最小距离
mScaledTouchSlop = viewConfig.getScaledTouchSlop();
mMaximumFlingVelocity = viewConfig.getScaledMaximumFlingVelocity();//最大滑动速率
mMinimumFlingVewlocty = viewConfig.getScaledMinimumFlingVelocity();//最小滑动速率
setClickable(true);//这里必须注明,因为LinearLaytou默认是不可点击的(不加其实也行,因为我最初就是没有加的,只是在重写onTouchEvent方法的时候你就不能直接返回super.onTouchEvent(event)了,至于为什么,有兴趣的自己可以去试下)
}
二、重写LinearLayout的onTouchEvent方法:
在重写onTouchEvent方法前,我们要讲一下VelocityTracker这个类,从字面意思来讲,它是一个速率追踪者,用来干什么的呢,它是用来监听你手势滑动的速率的,有了这个值,我们就可以处理用户瞬滑事件了,相信一般使用QQ的朋友都会经常习惯这样操作吧,快速向左滑动,菜单就自动弹出,有了这个类,我们就可以愉快的截取到用户的这个操作并做出相应的处理了。下面看下如何初始化以及释放VelocityTracker的引用:
private VelocityTracker mVelocityTracker;//这个追踪者会在onTouchEvent中Up事件中使用
private void obtainVelocityTracker(MotionEvent event) {
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(event);//这里需要传递事件进去
}
private void releaseVelocityTracker() {
if (mVelocityTracker != null) {
mVelocityTracker.recycle();
mVelocityTracker = null;
}
}
认识了VelocityTracker,我们就可以重写onTouchEvent方法了:
@Override
public boolean onTouchEvent(MotionEvent event) {
obtainVelocityTracker(event);
//这里需要注意的时候使用PLinearLayout中的第一级子项只能有两级,一级为content部分,一级为hide部分
maxScroll = getChildAt(1).getWidth();
int x = (int) event.getX();
int y = (int) event.getY();
int scrollX = getScrollX();
int currentX = scrollX + mPreX - x;//这里是用于计算当前所需要移动到的X坐标(绝对坐标)
mPreX = x;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mDownX = x;
mStartDownX = x;//这里用于Up事件中的逻辑校验
mDownY = y;
Log.e(TAG, "down");
break;
case MotionEvent.ACTION_MOVE:
moveX = Math.abs(mDownX - x);
moveY = Math.abs(mDownY - y);
if (!mIsMoveX && moveY > mScaledTouchSlop - 10 && !mIsMoveY) {
mIsMoveY = true;
}
//如果X轴移动的趋势明显大于Y轴,则说明用户意图是滑动侧滑菜单
if (!mIsMoveY) {
if (moveX > moveY && moveX > mScaledTouchSlop && !mIsMoveX) {
mIsMoveX = true;
}
if (mIsMoveX) {
//控制滑动范围
if (currentX < 0) {
currentX = 0;
} else if (currentX > maxScroll) {
currentX = maxScroll;
}
scrollTo(currentX, 0);//这里也可以使用scrollBy(),只是位移计算需要稍作修改
mDownX = (int) event.getX();
mDownY = (int) event.getY();
}
}
mIsMove = true;//标记move事件
break;
case MotionEvent.ACTION_UP:
mVelocityTracker.computeCurrentVelocity(1200, mMaximumFlingVelocity);
int velocityX = (int) mVelocityTracker.getXVelocity();//获取X轴速率
//判断是否满足X轴的瞬滑操作的最低要求,如果满足,则打开侧滑菜单(还需判断滑动方向)
if (Math.abs(velocityX) > mMinimumFlingVewlocty) {//快速滑动
if (mStartDownX - x > 0) {//向左
currentX = maxScroll;
} else {//向右
currentX = 0;
}
//这里是长时间的滑动
} else {//慢慢滑动
//判断当前滑动到的位置是否大于50%
if (Math.abs(getScrollX()) >= (maxScroll * 0.5f)) {
if (mIsOpen && !mIsMove) {//判断此次操作为点击,mIsOpen是用来标记当前item的策划菜单是否打开的,mIsMove上面有提到用于标记产生了滑动事件。如果侧滑菜单打开,且没有产生滑动事件,则说明用户是在Item侧滑菜单打开的情况单击了这个Item,那么我们自然需要将菜单关闭
currentX = 0;
} else {
currentX = maxScroll;//如果不是单击,则打开菜单
}
} else {//小于则回到原点
if (0 != Math.abs(getScaleX())){
currentX = 0;
}
}
}
mIsMoveX = false;
mIsMoveY = false;
mIsMove = false;
//注意要想它真正滑动还需要重绘画面,并重写computeScroll()方法
mScroller.startScroll(scrollX, 0, currentX - scrollX, 0);
invalidate();//调用computeScroll
releaseVelocityTracker();
break;
case MotionEvent.ACTION_CANCEL:
//个别朋友可能会好奇,为啥还要监听Cancel事件,这里是为了避免用户在操作过程中触发了这个事件,导致没有进去Up进行事件逻辑校验,这样就可能出现侧滑菜单出来一半就不静止了的情况。
if (!mScroller.isFinished()) {
mScroller.abortAnimation();//这里是停止mScroller触发的移动动画
} else {
//判断当前滑动到的位置是否大于50%
if (Math.abs(getScrollX()) >= (maxScroll * 0.5f)) {
if (mIsOpen && !mIsMove) {//点击
currentX = 0;
} else {
currentX = maxScroll;
}
} else {//小于则回到原点
if (0 != Math.abs(getScaleX())){
currentX = 0;
}
}
smoothScrollTo(0, currentX);
}
mIsStop = true;
mIsMoveX = false;
mIsMoveY = false;
break;
}
return super.onTouchEvent(event);
}
基本的控制逻辑都在onTouchEvent中呈现了,下面再讲一下,computeScroll()方法的重写,以及mIsOpen如何赋值:
@Override
public void computeScroll() {
//这个方法会被调用N次
if (mScroller.computeScrollOffset())//判断当前已经结束滑动
{
scrollTo(mScroller.getCurrX(), 0);//获取mScroller引用x和y轴的值,并滑动到相应位置
invalidate();//刷新
}
}
@Override
public void scrollTo(int x, int y) {
if (x >= maxScroll) {
mIsOpen = true;
if (mListener != null) {
mListener.state(true, this);
}
} else {
mIsOpen = false;
if (mListener != null) {
mListener.state(false, this);
}
}
super.scrollTo(x, y);
}
private OnScrollListener mListener;
public void setOnScrollListener(OnScrollListener listener) {
this.mListener = listener;
}
//这个接口用于给ListView判断当前Item菜单的状态
public interface OnScrollListener {
void state(boolean isOpen, PLinearLayout linearLayout);
}
文章开始部分有讲到一个bug,为了避免它发生,我们需要知道当前的Item的菜单是不是处于移动的过程中,如果能够知道,那么我们就能很好的处理掉这个bug了。这里我们用一个全局变量mStop来标记菜单的状态,如果用户触发下一次操作时,还有Item处于滑动过程中,那么我们需要终止用户当前操作,并且让处于滑动状态的Item关闭菜单。(QQ就是这么处理的)我采用的办法就是重写onScrollChanged这个方法。
代码如下:
//oldl、oldt表示滑动前的x和y
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
if (0 == l || maxScroll == l) {
mIsStop = true;
} else {
mIsStop = false;
}
super.onScrollChanged(l, t, oldl, oldt);
}
到这里,Item定义部分就基本讲完了。(更详细可以参照源码,文章结尾处会提供源码下载)
自定义ListView —— PListView
一、重写ListView的onInterceptTouchEvent:
大家都知道onInterceptTouchEvent主要用于控制事件是否向下分发的。这里用它主要有三个用途,第一个就是有Item侧滑菜单打开,但是用户单击的是别的Item,我们需要将打开的侧滑菜单关闭,并且不响应其他事件。第二就是用来处理上文说到的那个bug,第三就是用来定义OnItemClickListner(),为什么需要自定义,你们有时间可以自己去摸索下。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
obtainVelocityTracker(ev);
isIntercepted = super.onInterceptTouchEvent(ev);
int x = (int) ev.getX();
int y = (int) ev.getY();
//如果默认是不截获,则不需要再做判断
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
mDownX = (int) ev.getX();
mDownY = (int) ev.getY();
if (mListener != null) {
position = mListener.isOpen();
if (position != -1) {//有item打开侧滑菜单
if (position != pointToPosition(mDownX, mDownY)) {//判断按下的位置,如果不是侧滑的条目,则关闭侧滑菜单,且listView不响应任何事件
PLinearLayout pLinearLayout = (PLinearLayout) getChildAt(position - getFirstVisiblePosition());
pLinearLayout.smoothCloseMenu();
mIsOpen = true;
isIntercepted = true;
} else {
mIsOpen = true;
isIntercepted = false;
}
}
else {//当item未打开侧滑菜单(这里分两种情况,一种是所有事件都以执行完毕,依然没有菜单打开;另一种则是菜单还在打开的过程中(需要避免快速操作会同时打开两个item侧滑菜单))
//记录手指操作的item
if (pOldLinearLayout != null) {
if (!pOldLinearLayout.ismIsStop() && pOldLinearLayout.getScrollX() != 0) {//如果item还在执行滑动事件
//则关闭item,且listView不影响任何事件
pOldLinearLayout.smoothCloseMenu();
mIsOpen = true;
isIntercepted = true;
}
}
mIsOpen = false;
}
}
break;
case MotionEvent.ACTION_MOVE:
mIsMove = true;
break;
case MotionEvent.ACTION_UP:
moveY = Math.abs(mDownY - y);
mVelocity.computeCurrentVelocity(1000, mMaximumFlingVelocity);
int velocityY = (int) mVelocity.getYVelocity();//获取Y轴速率
if (!mIsMove && position == -1 && moveY < mScaledTouchSlop) {//确定是点击事件,且用户没有滑动意向,则是点击事件
if (mOnItemClickListener != null) {
mOnItemClickListener.onClick(pointToPosition(mDownX, mDownY));
}
mIsMove = false;
releaseVelocityTracker();
return true;
} else {
pOldLinearLayout = (PLinearLayout) getChildAt(pointToPosition(mDownX, mDownY) - getFirstVisiblePosition());
}
mIsMove = false;
releaseVelocityTracker();
break;
case MotionEvent.ACTION_CANCEL:
isIntercepted = moveOrDown((int) ev.getX(), (int) ev.getY(), isIntercepted);
break;
}
return isIntercepted;
}
public interface OnItemClickListener {
void onClick(int position);
}
private OnItemClickListener mOnItemClickListener;
public void setOnItemClickListener(OnItemClickListener onItemClickListener) {
this.mOnItemClickListener = onItemClickListener;
}
讲到这里,还需要讲一下PRecyclerView定义跟PListView的部分差异性,因为在RecyclerView是没有pointToPosition、getFirstVisiblePosition、getLastVisiblePosition三个方法的,所以我们需要自定义:
private int pointToPosition(int x, int y) {
return getChildAdapterPosition(findChildViewUnder(x, y));
}
private int getFirstVisiblePosition() {
return getChildAdapterPosition(getChildAt(0));
}
private int getLastVisiblePosition() {
return getChildAdapterPosition(getChildAt(getChildCount() - 1));
}
这样,PRecyclerView和PListView的定义就基本一致了,当然使用上的差别我就不讲了,相信各位都很清楚了。
二、重写ListView的onTouchEvent方法:
这里就很简单了,三句代码搞定,主要是为了求同QQ侧滑,QQ侧滑的效果是:当有item打开侧滑菜单时,用户的下一次操作无论是针对侧滑菜单的item,还是其他item或者是ListView的上下滑动都会失效,只会触发一个操作,那就是关闭打开的菜单,这样也减少了逻辑上容易出现的bug。
public boolean onTouchEvent(MotionEvent ev) {
if (mIsOpen) {//如果侧滑菜单打开,则不响应任何事件
return true;
}
return super.onTouchEvent(ev);
}
使用自定义PListView
这里之所以还讲如何使用,是因为使用过程中有几个注意事项,首先就是我们定义的PLinearLayout需要通过
public interface OnScrollListener {
void state(boolean isOpen, PLinearLayout linearLayout);
}
这个接口,将当前item的菜单打开状态传递给PListView,然后通过PListview的
public interface OnMenuListener {
int isOpen();
}
获取item的菜单打开状态,当然他们都需要Activity或者fragment做为媒介,下面就看代码部分:
一、布局
<?xml version="1.0" encoding="utf-8"?>
<com.twe.plinearlayout.PLinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/pl_parent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="65dp"
android:orientation="horizontal">
//这里需要注意的是,我们将PLinearLayout看做两部分,一部分是Content,宽度必须是match_parent,一部分则是侧滑菜单部分(我这里只是简单演示,你们可将他们分别用一个LinearLayout包裹,里面可以随意设置控件布局)
<TextView
android:id="@+id/tv_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:textSize="25dp" />
<TextView
android:id="@+id/tv_delete"
android:layout_width="100dp"
android:layout_height="match_parent"
android:background="@android:color/holo_red_light"
android:gravity="center"
android:text="删除"
android:textColor="#ffffff"
android:textSize="25dp" />
</com.twe.plinearlayout.PLinearLayout>
二、适配器定义
public class PListViewAdapter extends BaseAdapter {
private Context mContext;
private List<String> mList;
private PLinearLayout.OnScrollListener mListener;//这里则是将PLinearLayout.OnScrollListener通过适配器传给PLinearLayout
public PListViewAdapter(Context context, List<String> list, PLinearLayout.OnScrollListener listener) {
this.mContext = context;
this.mList = list;
this.mListener = listener;
}
@Override
public int getCount() {
return mList.size();
}
@Override
public Object getItem(int position) {
return mList.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView(final int position, View convertView, ViewGroup parent) {
final ViewHolder holder;
if (convertView == null) {
convertView = LayoutInflater.from(mContext).inflate(R.layout.item, parent, false);
holder = new ViewHolder();
holder.tv_content = (TextView) convertView.findViewById(R.id.tv_content);
holder.tv_delete = (TextView) convertView.findViewById(R.id.tv_delete);
holder.pl_parent = (PLinearLayout) convertView.findViewById(R.id.pl_parent);
convertView.setTag(holder);
} else {
holder = (ViewHolder) convertView.getTag();
}
holder.pl_parent.setOnScrollListener(mListener);
holder.tv_content.setText(mList.get(position));
holder.tv_delete.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
holder.pl_parent.smoothCloseMenu();//这里需要注意,在点击删除或者侧滑部分的其他键时,需要现将item的侧滑菜单关闭
mList.remove(position);
notifyDataSetChanged();
}
});
return convertView;
}
public class ViewHolder {
TextView tv_content;
TextView tv_delete;
PLinearLayout pl_parent;
}
}
三、Activity中的使用
public class PListViewActivity extends AppCompatActivity implements PLinearLayout.OnScrollListener {
private PListView mLvScroll;
private PListViewAdapter mAdapter;
private List<String> mList;
private boolean mIsOpen = false;
private PLinearLayout mLinerLayout;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_listview);
mLvScroll = (PListView) findViewById(R.id.lv_Scroll);
mList = new ArrayList<>();
for (int i = 0; i < 40; i++) {
mList.add("item" + i);
}
mAdapter = new PListViewAdapter(this, mList, this);
mLvScroll.setAdapter(mAdapter);
mLvScroll.setOnMenuListener(new PListView.OnMenuListener() {
@Override
public int isOpen() {
if (mIsOpen && mLinerLayout != null) {//返回菜单打开的item的position,以及item本身
return mLvScroll.getPositionForView(mLinerLayout);
}
return -1;
}
});
mLvScroll.setOnItemClickListener(new PListView.OnItemClickListener() {
@Override
public void onClick(int position) {
Toast.makeText(PListViewActivity.this, "我是第" + position + "条。", Toast.LENGTH_SHORT).show();
}
});
}
@Override
public void state(boolean isOpen, PLinearLayout linearLayout) {
mIsOpen = isOpen;//获取PLinearLayout的侧滑菜单打开状态
if (mIsOpen) {
mLinerLayout = linearLayout;
} else {
mLinerLayout = null;
}
}
}
讲到这里就基本讲完了,这应该也是我目前为止写的最长的一篇博客了,直接将代码copy就可以实现你想要的功能,当然最后我还是会提供源码下载的地址:传送门
最后欢迎各位朋友指正。