您的位置:首页 > 移动开发 > Android开发

QQ侧滑删除功能原理以及冲突解决

2017-04-07 18:53 465 查看

侧滑概要

前面几篇文章中已经学习了如何自定义 View / ViewGroup,学习了 View / ViewGroup 的事件分发机制,以及自定义 ViewGroup 的工具类 ViewDragHelper。那么今天,利用前面所学的,自己来做一个例子,仿QQ的侧滑删除功能。

先看看效果图



效果图中有几点需要说明

1. 从右往左滑动的时候,当删除按钮全部显示出来的时候,整个 Item 会滑动到最左边。如果删除按钮没有全部显示,整个 Item 会返回到原来的位置。

2. 在侧滑进行的时候,效果图中也进行了上下滑动,但是 RecyclerView 并没有上下滑动,因为我们解决了 Item 和 RecyclerView 的滑动冲突。

布局文件

<?xml version="1.0" encoding="utf-8"?>
<com.ckt.viewdraghelperdemo.SwipeLinearLayout
android:id="@+id/swipe_container"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="60dp">

<TextView
android:id="@+id/tv_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="这是内容区域"
android:textSize="18sp"/>

<Button
android:id="@+id/btn_delete"
android:background="@color/colorAccent"
android:layout_width="100dp"
android:layout_height="match_parent"
android:gravity="center"
android:text="删除"
android:textSize="18sp"/>

<Button
android:id="@+id/btn_unread"
android:background="#9E9E9E"
android:layout_width="100dp"
android:layout_height="match_parent"
android:gravity="center"
android:text="未读"
android:textSize="18sp"/>
</com.ckt.viewdraghelperdemo.SwipeLinearLayout>


完成基本的侧滑实现

public class SwipeLinearLayout extends LinearLayout {

private static final String TAG = "david";
private ViewDragHelper mViewDragHelper;

public SwipeLinearLayout(Context context) {
this(context, null);
}

public SwipeLinearLayout(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}

private void init() {
// set orientation of LinearLayout always to be horizontal
setOrientation(LinearLayout.HORIZONTAL);
mViewDragHelper = ViewDragHelper.create(this, new ViewDragHelper.Callback() {
@Override
public boolean tryCaptureView(View child, int pointerId) {
return child == getContentView();
}

@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
int leftOffset = 0;
for (int i = 1; i < getChildCount(); i++) {
leftOffset += getChildAt(i).getWidth();
}
int leftBound = getPaddingLeft() - leftOffset;
int rightBound = getWidth() - getPaddingRight() - child.getWidth();
return Math.min(rightBound, Math.max(left, leftBound));
}

@Override
public int clampViewPositionVertical(View child, int top, int dy) {
int topBound = getPaddingTop();
int bottomBound = getHeight() - getPaddingBottom() - child.getHeight();
return Math.min(bottomBound, Math.max(topBound, top));
}

@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
if (changedView == getContentView()) {
for (int i = 1; i < getChildCount(); i++) {
View child = getChildAt(i);
child.layout(child.getLeft() + dx, 0, child.getRight() + dx, getHeight());
}
}
}

@Override
public int getViewHorizontalDragRange(View child) {
int range = 0;
for (int i = 1; i < getChildCount(); i++) {
range += getChildAt(i).getWidth();
}
return range;
}
});
}

private View getContentView() {
return getChildAt(0);
}

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return mViewDragHelper.shouldInterceptTouchEvent(ev);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
mViewDragHelper.processTouchEvent(event);
return true;
}
}


代码解析:

1. 首先,SwipeLinearLayout 选择继承 LinearLayout,是因为我想让所有的 child view 水平排列,因此,在 init() 方法中,调用 setOrientation(LinearLayout.HORIZONTAL) 强制设置水平方向排列。

2. 从 tryCaptureView() 回调实现中,可以看出我只想移动第一个 child view (布局中的 TextView),并且我在 clampViewPositionHorizontal() 和 clampViewPositionVertical() 回调中,让 TextView 只能水平向左滑动,并且滑动的距离只能是布局中两个 Button的宽度之和。

3. 由于我们可能在后面代码中,对 TextView 调用 setOnClickListener() 方法,这样就会让 TextView 获取到 ACTION_DOWN 以及以后的事件,那么如果我们要完成对 TextView 的滑动,就需要在 onInterceptTouchEvent() 中调用 mViewDragHelper.shouldInterceptTouchEvent(ev) 来截断 ACTION_MOVE 事件,并且还要实现 getViewHorizontalDragRange()返回值大于0。

4. 实现了前三个步骤后,我们是可以滑动 TextView 了,但是两个 Button 还没有一起移动,所以需要实现 onViewPositionChanged() 来让2个 Button 跟着 TextView 移动。

滑动冲突

如果你运行上面的代码,你会发现,在侧滑 Item 的时候,你也可以上下滑动 RecyclerView,这很明显已经出现冲突了。那么现在我们来解决这个冲突。由之前的分析可以明白,当我们滑动 TextView 的时候,ACTION_MOVE 事件是被 SwipeLinearLayout 截断的,这样所有的 ACTION_MOVE 事件只会由 RecyclerView 的 onInterceptTouchEvent() 传入到 SwipeLinearLayout 的 onTouchEvent() 中来,而 RecylerView onInterceptTouchEvent() 对于 ACTION_MOVE 事件,只会当我们滑动的时候,在 y 方向的距离明显大约 x 方向的距离的时候,才会截断事件,所以 SwipeLinearLayout 的 onTouchEvent() 方法中,需要判断在 x 方向的距离明显大于 y 方向的距离的时候,调用 requestDisallowInterceptTouchEvent(true) 来让 parent view 不要执行 onInterceptTouchEvent() 方法,从而 RecylerView 也就不会截断 SwipeLinearLayout 的后续事件了。

private float mLastX, mLastY;

@Override
public boolean onTouchEvent(MotionEvent event) {
float x = event.getX();
float y = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mLastX = x;
mLastY = y;
break;

case MotionEvent.ACTION_MOVE:
float dx = x - mLastX;
float dy = y - mLastY;
float angel = (float) Math.toDegrees(Math.atan(Math.abs(dy / dx)));
if (angel < 30) {
getParent().requestDisallowInterceptTouchEvent(true);
}
break;

}
mViewDragHelper.processTouchEvent(event);
return true;
}


优化用户体验

到此为止,你已经解决了滑动的冲突。现在我们来优化下用户体验,上面的实现中,侧滑不“智能”,它一定要我们滑动到底,才能看见2个Button,这就显得很僵硬了,所以我们可以让它滑动到 2个Button 宽度之和的一半的时候,就让他们自动显示,这个逻辑当然就是在 Callback 中的 onViewReleased() 实现。

private boolean mIsOpen;
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
if (releasedChild == getContentView()) {
int offsetLeft = Math.abs(releasedChild.getLeft());
int maxDistance = 0;
for (int i = 1; i < getChildCount(); i++) {
maxDistance += getChildAt(i).getWidth();
}
if (!mIsOpen) {
if (offsetLeft > maxDistance / 2) {
mViewDragHelper.settleCapturedViewAt(-maxDistance, 0);
mIsOpen = true;
} else {
mViewDragHelper.settleCapturedViewAt(0, 0);
mIsOpen = false;
}
} else {
mViewDragHelper.settleCapturedViewAt(0, 0);
mIsOpen = false;
}
postInvalidate();
}
}


而因为我们调用的是 ViewDragHelper 的 settleCapturedViewAt() 方法,这是利用 Scroller 原理,所以我们还需要复写 ViewGroup 的 computeScroll() 方法

@Override
public void computeScroll() {
if (mViewDragHelper.continueSettling(true)) {
postInvalidate();
}
}


源码

到此,基本的侧滑实现已经完成。我现在贴出 SwipeLinearLayout 整个源码。

/**
* Created by David Chow on 2017/4/6.
*/

public class SwipeLinearLayout extends LinearLayout {

private static final String TAG = "david";
private ViewDragHelper mViewDragHelper;
private float mLastX, mLastY;
private boolean mIsOpen;

public SwipeLinearLayout(Context context) {
this(context, null);
}

public SwipeLinearLayout(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}

private void init() {
// set orientation of LinearLayout always to be horizontal
setOrientation(LinearLayout.HORIZONTAL);
mViewDragHelper = ViewDragHelper.create(this, new ViewDragHelper.Callback() {
@Override
public boolean tryCaptureView(View child, int pointerId) {
return child == getContentView();
}

@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
int leftOffset = 0;
for (int i = 1; i < getChildCount(); i++) {
leftOffset += getChildAt(i).getWidth();
}
int leftBound = getPaddingLeft() - leftOffset;
int rightBound = getWidth() - getPaddingRight() - child.getWidth();
return Math.min(rightBound, Math.max(left, leftBound));
}

@Override
public int clampViewPositionVertical(View child, int top, int dy) {
int topBound = getPaddingTop();
int bottomBound = getHeight() - getPaddingBottom() - child.getHeight();
return Math.min(bottomBound, Math.max(topBound, top));
}

@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
if (changedView == getContentView()) {
for (int i = 1; i < getChildCount(); i++) {
View child = getChildAt(i);
child.layout(child.getLeft() + dx, 0, child.getRight() + dx, getHeight());
}
}
}

@Override
public int getViewHorizontalDragRange(View child) {
int range = 0;
for (int i = 1; i < getChildCount(); i++) {
range += getChildAt(i).getWidth();
}
return range;
}

@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
if (releasedChild == getContentView()) {
int offsetLeft = Math.abs(releasedChild.getLeft());
int maxDistance = 0;
for (int i = 1; i < getChildCount(); i++) {
maxDistance += getChildAt(i).getWidth();
}
if (!mIsOpen) {
if (offsetLeft > maxDistance / 2) {
mViewDragHelper.settleCapturedViewAt(-maxDistance, 0);
mIsOpen = true;
} else {
mViewDragHelper.settleCapturedViewAt(0, 0);
mIsOpen = false;
}
} else {
mViewDragHelper.settleCapturedViewAt(0, 0);
mIsOpen = false;
}
postInvalidate();
}
}
});
}
@Override public void computeScroll() { if (mViewDragHelper.continueSettling(true)) { postInvalidate(); } }

private View getContentView() {
return getChildAt(0);
}

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return mViewDragHelper.shouldInterceptTouchEvent(ev);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
float x = event.getX();
float y = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mLastX = x;
mLastY = y;
break;

case MotionEvent.ACTION_MOVE:
float dx = x - mLastX;
float dy = y - mLastY;
float angel = (float) Math.toDegrees(Math.atan(Math.abs(dy / dx)));
if (angel < 30) {
getParent().requestDisallowInterceptTouchEvent(true);
}
break;

}
mViewDragHelper.processTouchEvent(event);
return true;
}

/**
* 这个方法作用是,当检测到 mIsOpen 为 true 的时候,可以不让 TextView 有点击事件
* @return
*/
public boolean isOpen() {
return mIsOpen;
}
}


侧滑库

上面讲的例子只是一个引子,很多地方都没有完善。Github 上有个库 AndroidSwipeLayout,可以非常方便实现这个功能,这里介绍下如何使用这个库。

下面我只贴出使用 ListView,RecyclerView 中的 item.xml 的布局。

<?xml version="1.0" encoding="utf-8"?>
<com.daimajia.swipe.SwipeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/swipe_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?android:attr/listPreferredItemHeightSmall">

<LinearLayout
android:id="@+id/bottom_wrapper"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal">

<Button
android:id="@+id/item_top"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="@color/colorPrimary"
android:text="置顶"/>

<Button
android:id="@+id/item_delete"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="@color/colorAccent"
android:text="删除"/>

</LinearLayout>

<LinearLayout
android:id="@+id/content_wrapper"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#ffffff">

<TextView
android:id="@+id/item_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"/>
</LinearLayout>
</com.daimajia.swipe.SwipeLayout>


SwipeLayout 是继承自 FrameLayout,布局中第一个 child view 是隐藏的侧滑需要显示的内容,而第二个 child view 就是要显示的界面。

默认的效果,是产生如 QQ 一样的侧滑删除效果,从右到左拉出来,如下图



而如果我们让侧滑效果如 iphone 上的 QQ 侧滑效果一样,就需要为 SwipeLayout 加一个属性,show_mode

<com.daimajia.swipe.SwipeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/swipe_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?android:attr/listPreferredItemHeightSmall"
app:show_mode="lay_down">


这里的 show_mode 默认是 pull_out 模式,现在设置为 lay_down 默认,看看效果



当然,我们也可以改变侧滑的方向,例如我要从左向右侧滑

<com.daimajia.swipe.SwipeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/swipe_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?android:attr/listPreferredItemHeightSmall"
app:drag_edge="left"
app:show_mode="lay_down">


drag_edge 属性默认是 right,这里我选择 left (当然还有 top, bottom),看下效果



还有一个属性 clickToClose,默认为 false,如果设置为 true ,就是说,点击下就会关闭侧滑效果,这个效果就不展示了。

当然,除了属性外,SwipeLayout 还有很多监听函数,例如,侧滑时候的监听函数 addSwipeListener。还有当我们子 View 需要使用监听手势的时候,可以使用 addSwipeDenier 函数,来不让 SwipeLayout 侧滑。还有当我们需要监听某个子 View 是否完全显示的时候,可以调用 addRevealListener。这里我就不一一去举例了,实战中我们知道有这些函数就知道怎么用了。

当然,这个库还不止如此,还针对 ListView,GridView,RecyclerView 设计了相应的 SwipeAdapter,例如 RecylerView 设计了 RecyclerSwipeAdapter.java,而这些 Adapter 是当用户滚动列表的时候,用来恢复 item 的侧滑开关的状态的。

结束

本篇文章,通过自定义 ViewGroup 实现了基本的侧滑,同时也说明并解决了滑动的冲突。本文也引出了 AndroidSwipeLayout 这个优秀的库,如果是深入掌控侧滑,可以学习这个库的源码。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息