Android開發ListView左滑刪除
阿新 • • 發佈:2018-11-20
import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ValueAnimator; import android.content.Context; import android.content.res.TypedArray; import android.graphics.PointF; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.VelocityTracker; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.animation.AccelerateInterpolator; import android.view.animation.OvershootInterpolator; /** * 【Item側滑刪除選單】 * 繼承自ViewGroup,實現滑動出現刪除等選項的效果, * 思路:跟隨手勢將item向左滑動, * 在onMeasure時 將第一個Item設為螢幕寬度 * 【解決螢幕上多個側滑刪除選單】:內設一個類靜態View型別變數 ViewCache,儲存的是當前正處於右滑狀態的CstSwipeMenuItemViewGroup, * 每次Touch時對比,如果兩次Touch的不是一個View,那麼令ViewCache恢復普通狀態,並且設定新的CacheView * 只要有一個側滑選單處於開啟狀態, 就不給外層佈局上下滑動了 * <p/> * 平滑滾動使用的是Scroller,20160811,最新平滑滾動又用屬性動畫做了,因為這樣更酷炫(設定加速器不同) * <p/> * 20160824,fix 【多指一起滑我的情況】:只接第一個客人(使用一個類靜態布林變數) * other: * 1 選單處於側滑時,攔截長按事件 * 2 解決側滑時 點選 的衝突 * 3 通過 isIos 變數控制是否是IOS阻塞式互動,預設是開啟的。 * 4 通過 isSwipeEnable 變數控制是否開啟右滑選單,預設開啟。(某些場景,複用item,沒有編輯許可權的使用者不能右滑) * 5 2016 09 29 add,,通過開關 isLeftSwipe支援左滑右滑 * 6 2016 10 21 add , 增加viewChache 的 get()方法,可以用在:當點選外部空白處時,關閉正在展開的側滑選單。 * 7 2016 10 22 fix , 當父控制元件寬度不是全屏時的bug。 * 2016 10 22 add , 仿QQ,側滑選單展開時,點選除側滑選單之外的區域,關閉側滑選單。 * 8 2016 11 03 add,判斷手指起始落點,如果距離屬於滑動了,就遮蔽一切點選事件。 * 9 2016 11 04 fix 長按事件和側滑的衝突。 * 10 2016 11 09 add,適配GridLayoutManager,將以第一個子Item(即ContentItem)的寬度為控制元件寬度。 * 11 2016 11 14 add,支援padding,且後續計劃加入上滑下滑,因此不再支援ContentItem的margin屬性。 * 2016 11 14 add,修改回彈的動畫,更平滑。 * 2016 11 14 fix,微小位移的move不回回彈的bug * 2016 11 18,fix 當ItemView存在高度可變的情況 * 2016 12 07,fix 禁止側滑時(isSwipeEnable false),點選事件不受干擾。 * 2016 12 09,fix ListView快速滑動快速刪除時,偶現選單不消失的bug。 * Created by zhangxutong . * Date: 16/04/24 */ public class SwipeMenuLayout extends ViewGroup { private static final String TAG = "zxt/SwipeMenuLayout"; private int mScaleTouchSlop;//為了處理單擊事件的衝突 private int mMaxVelocity;//計算滑動速度用 private int mPointerId;//多點觸控只算第一根手指的速度 private int mHeight;//自己的高度 //右側選單寬度總和(最大滑動距離) private int mRightMenuWidths; //滑動判定臨界值(右側選單寬度的40%) 手指擡起時,超過了展開,沒超過收起menu private int mLimit; private View mContentView;//2016 11 13 add ,儲存contentView(第一個View) //private Scroller mScroller;//以前item的滑動動畫靠它做,現在用屬性動畫做 //上一次的xy private PointF mLastP = new PointF(); //2016 10 22 add , 仿QQ,側滑選單展開時,點選除側滑選單之外的區域,關閉側滑選單。 //增加一個布林值變數,dispatch函式裡,每次down時,為true,move時判斷,如果是滑動動作,設為false。 //在Intercept函式的up時,判斷這個變數,如果仍為true 說明是點選事件,則關閉選單。 private boolean isUnMoved = true; //2016 11 03 add,判斷手指起始落點,如果距離屬於滑動了,就遮蔽一切點選事件。 //up-down的座標,判斷是否是滑動,如果是,則遮蔽一切點選事件 private PointF mFirstP = new PointF(); private boolean isUserSwiped; //儲存的是當前正在展開的View private static SwipeMenuLayout mViewCache; //防止多隻手指一起滑我的flag 在每次down裡判斷, touch事件結束清空 private static boolean isTouching; private VelocityTracker mVelocityTracker;//滑動速度變數 private android.util.Log LogUtils; /** * 右滑刪除功能的開關,預設開 */ private boolean isSwipeEnable; /** * IOS、QQ式互動,預設開 */ private boolean isIos; private boolean iosInterceptFlag;//IOS型別下,是否攔截事件的flag /** * 20160929add 左滑右滑的開關,預設左滑開啟選單 */ private boolean isLeftSwipe; 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); init(context, attrs, defStyleAttr); } public boolean isSwipeEnable() { return isSwipeEnable; } /** * 設定側滑功能開關 * * @param swipeEnable */ public void setSwipeEnable(boolean swipeEnable) { isSwipeEnable = swipeEnable; } public boolean isIos() { return isIos; } /** * 設定是否開啟IOS阻塞式互動 * * @param ios */ public SwipeMenuLayout setIos(boolean ios) { isIos = ios; return this; } public boolean isLeftSwipe() { return isLeftSwipe; } /** * 設定是否開啟左滑出選單,設定false 為右滑出選單 * * @param leftSwipe * @return */ public SwipeMenuLayout setLeftSwipe(boolean leftSwipe) { isLeftSwipe = leftSwipe; return this; } /** * 返回ViewCache * * @return */ public static SwipeMenuLayout getViewCache() { return mViewCache; } private void init(Context context, AttributeSet attrs, int defStyleAttr) { mScaleTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); mMaxVelocity = ViewConfiguration.get(context).getScaledMaximumFlingVelocity(); //初始化滑動幫助類物件 //mScroller = new Scroller(context); //右滑刪除功能的開關,預設開 isSwipeEnable = true; //IOS、QQ式互動,預設開 isIos = false; //左滑右滑的開關,預設左滑開啟選單 isLeftSwipe = true; } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { //Log.d(TAG, "onMeasure() called with: " + "widthMeasureSpec = [" + widthMeasureSpec + "], heightMeasureSpec = [" + heightMeasureSpec + "]"); super.onMeasure(widthMeasureSpec, heightMeasureSpec); setClickable(true);//令自己可點選,從而獲取觸控事件 mRightMenuWidths = 0;//由於ViewHolder的複用機制,每次這裡要手動恢復初始值 mHeight = 0; int contentWidth = 0;//2016 11 09 add,適配GridLayoutManager,將以第一個子Item(即ContentItem)的寬度為控制元件寬度 int childCount = getChildCount(); //add by 2016 08 11 為了子View的高,可以matchParent(參考的FrameLayout 和LinearLayout的Horizontal) final boolean measureMatchParentChildren = MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY; boolean isNeedMeasureChildHeight = false; for (int i = 0; i < childCount; i++) { View childView = getChildAt(i); //令每一個子View可點選,從而獲取觸控事件 childView.setClickable(true); if (childView.getVisibility() != GONE) { //後續計劃加入上滑、下滑,則將不再支援Item的margin measureChild(childView, widthMeasureSpec, heightMeasureSpec); //measureChildWithMargins(childView, widthMeasureSpec, 0, heightMeasureSpec, 0); final MarginLayoutParams lp = (MarginLayoutParams) childView.getLayoutParams(); mHeight = Math.max(mHeight, childView.getMeasuredHeight()/* + lp.topMargin + lp.bottomMargin*/); if (measureMatchParentChildren && lp.height == LayoutParams.MATCH_PARENT) { isNeedMeasureChildHeight = true; } if (i > 0) {//第一個佈局是Left item,從第二個開始才是RightMenu mRightMenuWidths += childView.getMeasuredWidth(); } else { mContentView = childView; contentWidth = childView.getMeasuredWidth(); } } } setMeasuredDimension(getPaddingLeft() + getPaddingRight() + contentWidth, mHeight + getPaddingTop() + getPaddingBottom());//寬度取第一個Item(Content)的寬度 mLimit = mRightMenuWidths * 4 / 10;//滑動判斷的臨界值 //Log.d(TAG, "onMeasure() called with: " + "mRightMenuWidths = [" + mRightMenuWidths); if (isNeedMeasureChildHeight) {//如果子View的height有MatchParent屬性的,設定子View高度 forceUniformHeight(childCount, widthMeasureSpec); } } @Override public LayoutParams generateLayoutParams(AttributeSet attrs) { return new MarginLayoutParams(getContext(), attrs); } /** * 給MatchParent的子View設定高度 * * @param count * @param widthMeasureSpec * @see android.widget.LinearLayout# 同名方法 */ private void forceUniformHeight(int count, int widthMeasureSpec) { // Pretend that the linear layout has an exact size. This is the measured height of // ourselves. The measured height should be the max height of the children, changed // to accommodate the heightMeasureSpec from the parent int uniformMeasureSpec = MeasureSpec.makeMeasureSpec(getMeasuredHeight(), MeasureSpec.EXACTLY);//以父佈局高度構建一個Exactly的測量引數 for (int i = 0; i < count; ++i) { final View child = getChildAt(i); if (child.getVisibility() != GONE) { MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); if (lp.height == LayoutParams.MATCH_PARENT) { // Temporarily force children to reuse their old measured width // FIXME: this may not be right for something like wrapping text? int oldWidth = lp.width;//measureChildWithMargins 這個函式會用到寬,所以要儲存一下 lp.width = child.getMeasuredWidth(); // Remeasure with new dimensions measureChildWithMargins(child, widthMeasureSpec, 0, uniformMeasureSpec, 0); lp.width = oldWidth; } } } } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { //LogUtils.e(TAG, "onLayout() called with: " + "changed = [" + changed + "], l = [" + l + "], t = [" + t + "], r = [" + r + "], b = [" + b + "]"); int childCount = getChildCount(); int left = 0 + getPaddingLeft(); int right = 0 + getPaddingLeft(); for (int i = 0; i < childCount; i++) { View childView = getChildAt(i); if (childView.getVisibility() != GONE) { if (i == 0) {//第一個子View是內容 寬度設定為全屏 childView.layout(left, getPaddingTop(), left + childView.getMeasuredWidth(), getPaddingTop() + childView.getMeasuredHeight()); left = left + childView.getMeasuredWidth(); } else { if (isLeftSwipe) { childView.layout(left, getPaddingTop(), left + childView.getMeasuredWidth(), getPaddingTop() + childView.getMeasuredHeight()); left = left + childView.getMeasuredWidth(); } else { childView.layout(right - childView.getMeasuredWidth(), getPaddingTop(), right, getPaddingTop() + childView.getMeasuredHeight()); right = right - childView.getMeasuredWidth(); } } } } //Log.d(TAG, "onLayout() called with: " + "maxScrollGap = [" + maxScrollGap + "], l = [" + l + "], t = [" + t + "], r = [" + r + "], b = [" + b + "]"); } @Override public boolean dispatchTouchEvent(MotionEvent ev) { //LogUtils.d(TAG, "dispatchTouchEvent() called with: " + "ev = [" + ev + "]"); if (isSwipeEnable) { acquireVelocityTracker(ev); final VelocityTracker verTracker = mVelocityTracker; switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: isUserSwiped = false;//2016 11 03 add,判斷手指起始落點,如果距離屬於滑動了,就遮蔽一切點選事件。 isUnMoved = true;//2016 10 22 add , 仿QQ,側滑選單展開時,點選內容區域,關閉側滑選單。 iosInterceptFlag = false;//add by 2016 09 11 ,每次DOWN時,預設是不攔截的 if (isTouching) {//如果有別的指頭摸過了,那麼就return false。這樣後續的move..等事件也不會再來找這個View了。 return false; } else { isTouching = true;//第一個摸的指頭,趕緊改變標誌,宣誓主權。 } mLastP.set(ev.getRawX(), ev.getRawY()); mFirstP.set(ev.getRawX(), ev.getRawY());//2016 11 03 add,判斷手指起始落點,如果距離屬於滑動了,就遮蔽一切點選事件。 //如果down,view和cacheview不一樣,則立馬讓它還原。且把它置為null if (mViewCache != null) { if (mViewCache != this) { mViewCache.smoothClose(); iosInterceptFlag = isIos;//add by 2016 09 11 ,IOS模式開啟的話,且當前有側滑選單的View,且不是自己的,就該攔截事件咯。 } //只要有一個側滑選單處於開啟狀態, 就不給外層佈局上下滑動了 getParent().requestDisallowInterceptTouchEvent(true); } //求第一個觸點的id, 此時可能有多個觸點,但至少一個,計算滑動速率用 mPointerId = ev.getPointerId(0); break; case MotionEvent.ACTION_MOVE: //add by 2016 09 11 ,IOS模式開啟的話,且當前有側滑選單的View,且不是自己的,就該攔截事件咯。滑動也不該出現 if (iosInterceptFlag) { break; } float gap = mLastP.x - ev.getRawX(); //為了在水平滑動中禁止父類ListView等再豎直滑動 if (Math.abs(gap) > 10 || Math.abs(getScrollX()) > 10) {//2016 09 29 修改此處,使遮蔽父佈局滑動更加靈敏, getParent().requestDisallowInterceptTouchEvent(true); } //2016 10 22 add , 仿QQ,側滑選單展開時,點選內容區域,關閉側滑選單。begin if (Math.abs(gap) > mScaleTouchSlop) { isUnMoved = false; } //2016 10 22 add , 仿QQ,側滑選單展開時,點選內容區域,關閉側滑選單。end //如果scroller還沒有滑動結束 停止滑動動畫 /* if (!mScroller.isFinished()) { mScroller.abortAnimation(); }*/ scrollBy((int) (gap), 0);//滑動使用scrollBy //越界修正 if (isLeftSwipe) {//左滑 if (getScrollX() < 0) { scrollTo(0, 0); } if (getScrollX() > mRightMenuWidths) { scrollTo(mRightMenuWidths, 0); } } else {//右滑 if (getScrollX() < -mRightMenuWidths) { scrollTo(-mRightMenuWidths, 0); } if (getScrollX() > 0) { scrollTo(0, 0); } } mLastP.set(ev.getRawX(), ev.getRawY()); break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: //2016 11 03 add,判斷手指起始落點,如果距離屬於滑動了,就遮蔽一切點選事件。 if (Math.abs(ev.getRawX() - mFirstP.x) > mScaleTouchSlop) { isUserSwiped = true; } //add by 2016 09 11 ,IOS模式開啟的話,且當前有側滑選單的View,且不是自己的,就該攔截事件咯。滑動也不該出現 if (!iosInterceptFlag) {//且滑動了 才判斷是否要收起、展開menu //求偽瞬時速度 verTracker.computeCurrentVelocity(1000, mMaxVelocity); final float velocityX = verTracker.getXVelocity(mPointerId); if (Math.abs(velocityX) > 1000) {//滑動速度超過閾值 if (velocityX < -1000) { if (isLeftSwipe) {//左滑 //平滑展開Menu smoothExpand(); } else { //平滑關閉Menu smoothClose(); } } else { if (isLeftSwipe) {//左滑 // 平滑關閉Menu smoothClose(); } else { //平滑展開Menu smoothExpand(); } } } else { if (Math.abs(getScrollX()) > mLimit) {//否則就判斷滑動距離 //平滑展開Menu smoothExpand(); } else { // 平滑關閉Menu smoothClose(); } } } //釋放 releaseVelocityTracker(); //LogUtils.i(TAG, "onTouch A ACTION_UP ACTION_CANCEL:velocityY:" + velocityX); isTouching = false;//沒有手指在摸我了 break; default: break; } } return super.dispatchTouchEvent(ev); } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { //add by zhangxutong 2016 12 07 begin: //禁止側滑時,點選事件不受干擾。 if (isSwipeEnable) { switch (ev.getAction()) { //add by zhangxutong 2016 11 04 begin : // fix 長按事件和側滑的衝突。 case MotionEvent.ACTION_MOVE: //遮蔽滑動時的事件 if (Math.abs(ev.getRawX() - mFirstP.x) > mScaleTouchSlop) { return true; } break; //add by zhangxutong 2016 11 04 end case MotionEvent.ACTION_UP: //為了在側滑時,遮蔽子View的點選事件 if (isLeftSwipe) { if (getScrollX() > mScaleTouchSlop) { //add by 2016 09 10 解決一個智障問題~ 居然不給點選側滑選單 我跪著謝罪 //這裡判斷落點在內容區域遮蔽點選,內容區域外,允許傳遞事件繼續向下的的。。。 if (ev.getX() < getWidth() - getScrollX()) { //2016 10 22 add , 仿QQ,側滑選單展開時,點選內容區域,關閉側滑選單。 if (isUnMoved) { smoothClose(); } return true;//true表示攔截 } } } else { if (-getScrollX() > mScaleTouchSlop) { if (ev.getX() > -getScrollX()) {//點選範圍在選單外 遮蔽 //2016 10 22 add , 仿QQ,側滑選單展開時,點選內容區域,關閉側滑選單。 if (isUnMoved) { smoothClose(); } return true; } } } //add by zhangxutong 2016 11 03 begin: // 判斷手指起始落點,如果距離屬於滑動了,就遮蔽一切點選事件。 if (isUserSwiped) { return true; } //add by zhangxutong 2016 11 03 end break; } //模仿IOS 點選其他區域關閉: if (iosInterceptFlag) { //IOS模式開啟,且當前有選單的View,且不是自己的 攔截點選事件給子View return true; } } return super.onInterceptTouchEvent(ev); } /** * 平滑展開 */ private ValueAnimator mExpandAnim, mCloseAnim; private boolean isExpand;//代表當前是否是展開狀態 2016 11 03 add public void smoothExpand() { //Log.d(TAG, "smoothExpand() called" + this); /*mScroller.startScroll(getScrollX(), 0, mRightMenuWidths - getScrollX(), 0); invalidate();*/ //展開就加入ViewCache: mViewCache = SwipeMenuLayout.this; //2016 11 13 add 側滑選單展開,遮蔽content長按 if (null != mContentView) { mContentView.setLongClickable(false); } cancelAnim(); mExpandAnim = ValueAnimator.ofInt(getScrollX(), isLeftSwipe ? mRightMenuWidths : -mRightMenuWidths); mExpandAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { scrollTo((Integer) animation.getAnimatedValue(), 0); } }); mExpandAnim.setInterpolator(new OvershootInterpolator()); mExpandAnim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { isExpand = true; } }); mExpandAnim.setDuration(300).start(); } /** * 每次執行動畫之前都應該先取消之前的動畫 */ private void cancelAnim() { if (mCloseAnim != null && mCloseAnim.isRunning()) { mCloseAnim.cancel(); } if (mExpandAnim != null && mExpandAnim.isRunning()) { mExpandAnim.cancel(); } } /** * 平滑關閉 */ public void smoothClose() { //Log.d(TAG, "smoothClose() called" + this); /* mScroller.startScroll(getScrollX(), 0, -getScrollX(), 0); invalidate();*/ mViewCache = null; //2016 11 13 add 側滑選單展開,遮蔽content長按 if (null != mContentView) { mContentView.setLongClickable(true); } cancelAnim(); mCloseAnim = ValueAnimator.ofInt(getScrollX(), 0); mCloseAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { scrollTo((Integer) animation.getAnimatedValue(), 0); } }); mCloseAnim.setInterpolator(new AccelerateInterpolator()); mCloseAnim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { isExpand = false; } }); mCloseAnim.setDuration(300).start(); //LogUtils.d(TAG, "smoothClose() called with:getScrollX() " + getScrollX()); } /** * @param event 向VelocityTracker新增MotionEvent * @see VelocityTracker#obtain() * @see VelocityTracker#addMovement(MotionEvent) */ private void acquireVelocityTracker(final MotionEvent event) { if (null == mVelocityTracker) { mVelocityTracker = VelocityTracker.obtain(); } mVelocityTracker.addMovement(event); } /** * * 釋放VelocityTracker * * @see VelocityTracker#clear() * @see VelocityTracker#recycle() */ private void releaseVelocityTracker() { if (null != mVelocityTracker) { mVelocityTracker.clear(); mVelocityTracker.recycle(); mVelocityTracker = null; } } //每次ViewDetach的時候,判斷一下 ViewCache是不是自己,如果是自己,關閉側滑選單,且ViewCache設定為null, // 理由:1 防止記憶體洩漏(ViewCache是一個靜態變數) // 2 側滑刪除後自己後,這個View被Recycler回收,複用,下一個進入螢幕的View的狀態應該是普通狀態,而不是展開狀態。 @Override protected void onDetachedFromWindow() { if (this == mViewCache) { mViewCache.smoothClose(); mViewCache = null; } super.onDetachedFromWindow(); } //展開時,禁止長按 @Override public boolean performLongClick() { if (Math.abs(getScrollX()) > mScaleTouchSlop) { return false; } return super.performLongClick(); } //平滑滾動 棄用 改屬性動畫實現 /* @Override public void computeScroll() { //判斷Scroller是否執行完畢: if (mScroller.computeScrollOffset()) { scrollTo(mScroller.getCurrX(), mScroller.getCurrY()); //通知View重繪-invalidate()->onDraw()->computeScroll() invalidate(); } }*/ /** * 快速關閉。 * 用於 點選側滑選單上的選項,同時想讓它快速關閉(刪除 置頂)。 * 這個方法在ListView裡是必須呼叫的, * 在RecyclerView裡,視情況而定,如果是mAdapter.notifyItemRemoved(pos)方法不用呼叫。 */ public void quickClose() { if (this == mViewCache) { //先取消展開動畫 cancelAnim(); mViewCache.scrollTo(0, 0);//關閉 mViewCache = null; } } } ((SwipeMenuLayout) holder.itemView).quickClose();