侧滑菜单在列表布局中越来越常见,其良好的交互为 App 增色了不好,在 Android 中,其实现方式也有很多种,本文是基于自定义 ViewGroup 方式实现,使用时在列表 item 布局中引入该 Layout 即可。
所用知识点:
- 自定义 ViewGroup
- ScrollTo() 和 ScrollTo() 区别及用法
- getScrollX(),getScrollY() 表示的意义及用法
- Scroller用法
- Android 事件分发机制
scrollTo() : 滑动到指定坐标位置点,是绝对滑动。
scrollBy() : 相对于当前位置滑动一段距离,是相对滑动,其内部实现是基于 scrollTo() 实现的,在当前位置坐标点加上滑动距离。
注意:滑动的是 View 的内容,并不是滑动 View 本身。
图上面,褐色的框,其实就是我们眼睛看到的手机界面,就是一个窗口。
而绿色的长方体呢,就是一块可以左右拉动的幕布啦,其实也就是我们要显示在窗口上面的内容,它其实是可以很大的,大到无限大,只是没在窗口中间的,所以我们就看不到。
而getScrollX 其实获取的值,就是这块 幕布在窗口左边界时候的值了,而幕布上面哪个点是原点(0,0)呢?就是初始化时内容显示的位置。
所以当我们将幕布往右推动的时候,幕布在窗口左边界的值就会在0的左边(-100),而向左推动,则其值会是在0的右边(100)。
(1).原理介绍:
scrollTo() 和 scrollBy() 实现的是一个结果,即是说,当调用scrollTo(100,0) 时,再重新绘制时,内容已经出现在(100,0)位置上,缺少一个移动的过程,而 Scroller 就是帮助我们实现这个滚动的过程的。
动画的原理其实不停的重绘位置变化的内容,在视觉效果上,就产生了动画的效果。
(2) 使用步骤:
private Scroller mScroller;
public SwipeMenuLayout(Context context) {
this(context, null);
}
public SwipeMenuLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public SwipeMenuLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mScroller = new Scroller(context);
}
@Override
public void computeScroll() {
super.computeScroll();
if (mScroller.computeScrollOffset()) { // 动画没有结束
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
}
mScroller.startScroll(int startX,int startY,int dx,int dy); // startX起始坐标,dx 偏移量
invalidate();
<resources>
<declare-styleable name="SwipeMenuLayout">
<attr name="leftMenuId" format="reference" />
<attr name="rightMenuId" format="reference" />
<attr name="contentId" format="reference" />
declare-styleable>
resources>
SwipeMenuLayout.java
public class SwipeMenuLayout extends ViewGroup {
private static final String TAG = "SwipeMenuLayout";
private Scroller mScroller;
private int mScaledTouchSlop;
private int leftMenuId;
private int rightMenuId;
private View leftMenuView;
private View rightMenuView;
private View contentView;
private int contentId;
private boolean isSwipeing;
// 静态类写入内存共享。用来判断当前界面是否有menu打开
private static SwipeMenuLayout swipeMenuLayout;
private static State curState;
private static boolean isTouching = false;
public SwipeMenuLayout(Context context) {
this(context, null);
}
public SwipeMenuLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public SwipeMenuLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
readAttrs(context, attrs);
// 1.创建 Scroller 对象
mScroller = new Scroller(context);
ViewConfiguration viewConfiguration = ViewConfiguration.get(context);
mScaledTouchSlop = viewConfiguration.getScaledTouchSlop();
}
private void readAttrs(Context context, AttributeSet attrs) {
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.SwipeMenuLayout);
try {
leftMenuId = typedArray.getResourceId(R.styleable.SwipeMenuLayout_leftMenuId, 0);
rightMenuId = typedArray.getResourceId(R.styleable.SwipeMenuLayout_rightMenuId, 0);
contentId = typedArray.getResourceId(R.styleable.SwipeMenuLayout_contentId, 0);
} finally {
typedArray.recycle();
}
}
/**
* 测量方法可能会被调用多次
*
* @param widthMeasureSpec
* @param heightMeasureSpec
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
setClickable(true);
int viewHeight = 0;
int viewWidth = 0;
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
childView.setClickable(true);
if (childView.getVisibility() == View.GONE) {
continue;
}
measureChild(childView, widthMeasureSpec, heightMeasureSpec);
viewHeight = Math.max(viewHeight, childView.getMeasuredHeight());
Log.d(TAG, "onMeasure: getMeasureWidth() = " + i + "," + +childView.getMeasuredWidth());
viewWidth += childView.getMeasuredWidth();
}
setMeasuredDimension(viewWidth, viewHeight);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
Log.d(TAG, "onLayout: l = " + l + ",t = " + t + ",r = " + r + ",b = " + b);
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
if (leftMenuView == null && childView.getId() == leftMenuId) {
leftMenuView = childView;
continue;
}
if (rightMenuView == null && childView.getId() == rightMenuId) {
rightMenuView = childView;
continue;
}
if (contentView == null && childView.getId() == contentId) {
contentView = childView;
}
}
Log.d(TAG, "onLayout: leftMenuView.getMeasureWidth() = " + leftMenuView.getMeasuredWidth());
Log.d(TAG, "onLayout: contentView.getMeasureWidth() = " + contentView.getMeasuredWidth());
Log.d(TAG, "onLayout: rightMenuView.getMeasureWidth() = " + rightMenuView.getMeasuredWidth());
// 布局 leftMenu
if (leftMenuView != null) {
leftMenuView.layout(-leftMenuView.getMeasuredWidth(), t, 0, b);
}
// 布局 contentView
if (contentView != null) {
contentView.layout(0, t, contentView.getMeasuredWidth(), b);
}
// 布局 rightMenu
if (rightMenuView != null) {
rightMenuView.layout(contentView.getMeasuredWidth(), t,
contentView.getMeasuredWidth() + rightMenuView.getMeasuredWidth(), b);
}
}
// 2. 重写 computeScroll()
@Override
public void computeScroll() {
super.computeScroll();
if (mScroller.computeScrollOffset()) { // 动画没有结束
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
//通知View重绘-invalidate()->onDraw()->computeScroll()
postInvalidate();
}
}
private PointF lastPoint;
// 记录第一次触摸点的坐标,方便计算手指抬起时,总的滑动距离
private PointF firstPoint;
// 手指按下到抬起,总的滑动距离
float finalDistance;
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
if (isTouching) {
return false;
}
isTouching = true;
isSwipeing = false;
if (firstPoint == null) {
firstPoint = new PointF();
}
if (lastPoint == null) {
lastPoint = new PointF();
}
// 当前触摸的不是已经打开的那个 SwipeMenuLayout,则需要将打开的那个关闭掉。
if (swipeMenuLayout != null) {
if (swipeMenuLayout != this) {
// 调用已经打开的那个 SwipeMenuLayout 关闭方法
swipeMenuLayout.handleSwipeMenu(State.CLOSE);
// getParent().requestDisallowInterceptTouchEvent(true);
}
}
firstPoint.set(ev.getX(), ev.getY());
lastPoint.set(ev.getX(), ev.getY());
break;
case MotionEvent.ACTION_MOVE:
// 偏移量 = 当前坐标值 - 上次坐标值
int dx = (int) (ev.getX() - lastPoint.x);
int dy = (int) (ev.getY() - lastPoint.y);
// scrollBy 移动,正值内容向左移动,负值内容向右移动
if (Math.abs(dx) > Math.abs(dy)) {
scrollBy(-dx, 0);
}
// 边界限定
if (getScrollX() > 0) { // 向左滑动,滑出 rightMenuView
if (rightMenuView != null) { // 存在 rightMenuView,滑动距离不能超过 rightMenuView 宽度
if (getScrollX() > rightMenuView.getMeasuredWidth()) {
scrollTo(rightMenuView.getMeasuredWidth(), 0);
}
} else { // 不存在 rightMenuView,禁止向左滑动
scrollTo(0, 0);
}
} else if (getScrollX() < 0) { // 向右滑动,滑出 leftMenuView
if (leftMenuView != null) { // 存在 leftMenuView,滑动距离不能大于 leftMenuView 宽度
if (getScrollX() < -leftMenuView.getMeasuredWidth()) { // getScrollX()是负值,
scrollTo(-leftMenuView.getMeasuredWidth(), 0);
}
} else {
scrollTo(0, 0);
}
}
// 当水平滑动时,请求父控件不要拦截事件
if (Math.abs(dx) > mScaledTouchSlop) {
getParent().requestDisallowInterceptTouchEvent(true);
}
lastPoint.set(ev.getX(), ev.getY());
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
isTouching = false;
finalDistance = ev.getX() - firstPoint.x;
if (Math.abs(finalDistance) > mScaledTouchSlop) {
isSwipeing = true;
}
State state = isShouldOpenMenu(getScrollX());
handleSwipeMenu(state);
break;
case MotionEvent.ACTION_POINTER_UP:
break;
}
return super.dispatchTouchEvent(ev);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
//滑动时拦截点击时间
if (Math.abs(finalDistance) > mScaledTouchSlop) {
// 当手指拖动值大于mScaledTouchSlop值时,认为应该进行滚动,拦截子控件的事件
return true;
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
//滑动后不触发contentView的点击事件
if (isSwipeing) {
isSwipeing = false;
finalDistance = 0;
return true;
}
//
// if (getX() < getScreenWidth() - rightMenuView.getMeasuredWidth()) {
// return true;
// }
break;
}
return super.onInterceptTouchEvent(ev);
}
private void handleSwipeMenu(State state) {
if (state == State.RIGHT_MENU_OPEN) {
swipeMenuLayout = this;
mScroller.startScroll(getScrollX(), 0, rightMenuView.getMeasuredWidth() - getScrollX(), 0);
curState = state;
} else if (state == State.LEFT_MENU_OPEN) {
swipeMenuLayout = this;
// getScrollX() 为负值
mScroller.startScroll(getScrollX(), 0, -getScrollX() - leftMenuView.getMeasuredWidth(), 0);
curState = state;
} else if (state == State.CLOSE) {
mScroller.startScroll(getScrollX(), 0, -getScrollX(), 0);
swipeMenuLayout = null;
curState = null;
}
//通知View重绘-invalidate()->onDraw()->computeScroll()
invalidate();
}
private State isShouldOpenMenu(int scrollX) {
// (1) scrollX > 0 : 表明现在处于 rightMenuView 打开状态,根据临界值决定是关闭,还是继续打开
if (scrollX > 0) {
if (finalDistance < 0) { // 左滑
if (rightMenuView != null && scrollX > mScaledTouchSlop) {
return State.RIGHT_MENU_OPEN;
}
} else if (finalDistance > 0) { // 右滑
if (rightMenuView != null && scrollX < rightMenuView.getMeasuredWidth() - mScaledTouchSlop) {
return State.CLOSE;
}
}
} else if (scrollX < 0) { // (2)scrollX < 0:表明现在处于 leftMenuView 打开状态,根据临界值是否打开,还是关闭
if (finalDistance < 0) { // 左滑
if (leftMenuView != null && Math.abs(scrollX) > mScaledTouchSlop) {
return State.CLOSE;
}
} else if (finalDistance > 0) { // 右滑
if (leftMenuView != null && Math.abs(scrollX) > mScaledTouchSlop) {
return State.LEFT_MENU_OPEN;
}
}
}
return State.CLOSE;
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (this == swipeMenuLayout) {
swipeMenuLayout.handleSwipeMenu(curState);
}
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (this == swipeMenuLayout) {
swipeMenuLayout.handleSwipeMenu(State.CLOSE);
}
}
public int getScreenWidth() {
return getResources().getDisplayMetrics().widthPixels;
}
enum State {
LEFT_MENU_OPEN,
RIGHT_MENU_OPEN,
CLOSE
}
}
ListView item 布局 item_list_view.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="70dp"
android:orientation="vertical">
<com.xing.swipemenulayoutlibrary.SwipeMenuLayout
android:id="@+id/swipeMenuLayout"
android:layout_width="match_parent"
android:layout_height="70dp"
app:contentId="@+id/content_view"
app:leftMenuId="@+id/left_menu"
app:rightMenuId="@+id/right_menu">
<LinearLayout
android:id="@+id/left_menu"
android:layout_width="80dp"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:layout_width="80dp"
android:layout_height="match_parent"
android:background="@android:color/holo_blue_dark"
android:gravity="center"
android:text="LeftMenu"
android:textColor="@android:color/white" />
LinearLayout>
<TextView
android:id="@+id/content_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:paddingLeft="16dp"
android:text="Android 8.0 奥利奥来了"
android:textColor="@android:color/black" />
<LinearLayout
android:id="@+id/right_menu"
android:layout_width="240dp"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:layout_width="80dp"
android:layout_height="match_parent"
android:background="#D9DEE4"
android:gravity="center"
android:text="Top"
android:textColor="@android:color/white" />
<TextView
android:id="@+id/tv_add"
android:layout_width="80dp"
android:layout_height="match_parent"
android:background="#ECD50A"
android:gravity="center"
android:text="Add"
android:textColor="@android:color/white"
android:textSize="16sp" />
<TextView
android:id="@+id/tv_delete"
android:layout_width="80dp"
android:layout_height="match_parent"
android:background="#FF4A57"
android:gravity="center"
android:text="Delete"
android:textColor="@android:color/white"
android:textSize="16sp" />
LinearLayout>
com.xing.swipemenulayoutlibrary.SwipeMenuLayout>
LinearLayout>