- 文章獨家授權(quán)公眾號:碼個蛋
- 更多分享:http://www.cherylgood.cn
-
啥都不說,先上個效果圖吧
EasySwipeMenuLayout.gif
一、前言
- 本次主要用到的知識點有View的測量、布局、Android的touch事件的傳遞、Scroller三個知識點,之前我也寫了幾篇文章進行了學習,有需要的可以點擊下面的鏈接哦
- Scorller的使用詳解一
- Android Touch事件分發(fā)機制詳解之由點擊引發(fā)的戰(zhàn)爭
- Android之View的誕生之謎
- Android之自定義View的死亡三部曲之(Measure)
- Android之自定義View的死亡三部曲之(Layout)
- Android之自定義View的死亡三部曲之(Draw)
二、構(gòu)想圖
- 我們這次要實現(xiàn)的控件叫做EasySwipeMenuLayout,內(nèi)部主要分為三部分:
1、內(nèi)容區(qū)域
2、左邊菜單按鈕區(qū)域
2、右邊菜單按鈕區(qū)域 - 當我們向右滑時,通過scroller將左邊按鈕區(qū)域滾動出來
- 當我們向左滑時,通過scroller將右邊按鈕區(qū)域滾動出來
- 實現(xiàn)的思路濾清了,那么我們就開始動手吧
三、具體實現(xiàn)
- 首先,網(wǎng)上類似的輪子有很多,但為什么我們還要自己寫一下呢,當然是為了學習,所謂知其然而知其所以然也,輪子只是滿足了大部分人的需求,試想某一天,有些效果網(wǎng)上是找不到的,那么此時就只能靠自己了。
- 當然,你也可以說,我就是想自己寫,哈哈。
- 在開始前,我還想再說一點,網(wǎng)上有很多類似的輪子,但是我發(fā)現(xiàn)個特點,他們要求控件內(nèi)的子布局的順序相對呆板,不夠靈活,也就是所謂通過約定來實現(xiàn)。
- but,我這次想通過配置來實現(xiàn),那么如何配置呢,其實我們可以通過控件的id進行綁定,參考了google官方控件的部分思想。
布局文件配置效果
-
首先,我想實現(xiàn)的配置效果是這樣子的
<com.guanaj.easyswipemenulibrary.EasySwipeMenuLayout android:layout_width="match_parent" android:layout_height="wrap_content" app:contentView="@+id/content" app:leftMenuView="@+id/left" app:rightMenuView="@+id/right"> <LinearLayout android:id="@+id/left" android:layout_width="100dp" android:layout_height="wrap_content" android:background="@android:color/holo_blue_dark" android:orientation="horizontal" android:padding="20dp"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="分享" /> </LinearLayout> <LinearLayout android:id="@+id/content" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="#cccccc" android:orientation="vertical" android:padding="20dp"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="內(nèi)容區(qū)域" /> </LinearLayout> <LinearLayout android:id="@+id/right" android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="@android:color/holo_red_light" android:orientation="horizontal"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="@android:color/holo_blue_bright" android:padding="20dp" android:text="刪除" /> <TextView android:id="@+id/right_menu_2" android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="@android:color/holo_orange_dark" android:padding="20dp" android:text="收藏" /> </LinearLayout> </com.guanaj.easyswipemenulibrary.EasySwipeMenuLayout>
-
如下可以看到,就是通過id來綁定,讓EasySwipeMenuLayout知道哪個childView是現(xiàn)實內(nèi)容的,哪個是左邊的菜單布局,哪個是右邊的菜單布局。
<com.guanaj.easyswipemenulibrary.EasySwipeMenuLayout android:layout_width="match_parent" android:layout_height="wrap_content" app:contentView="@+id/content" app:leftMenuView="@+id/left" app:rightMenuView="@+id/right">
為什么要這樣子設(shè)計的,我的想法是,這樣子更靈活,我不用規(guī)定里面的子布局的順序。
以上僅代表個人觀點,當然,肯定有更好的設(shè)計方案。
-
Ok,既然要通過id來配置,那么就會用到自定義控件屬性的知識,其實很簡單,就是在res/values下創(chuàng)建一個attrs.xml文件,在里面以你喜歡的名字定義屬性即可
xml version="1.0" encoding="utf-8"?> <resources> /** * Created by guanaj on . */ <declare-styleable name="EasySwipeMenuLayout"> <attr name="leftMenuView" format="reference" /> <attr name="rightMenuView" format="reference" /> <attr name="contentView" format="reference" /> <attr name="canRightSwipe" format="boolean" /> <attr name="canLeftSwipe" format="boolean" /> <attr name="fraction" format="float" /> declare-styleable> resources>
-
定義好了,我們要怎么獲取呢,其實也很easy的了
//1、通過上下文context獲取TypedArray對象 TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EasySwipeMenuLayout, defStyleAttr, 0); try { int indexCount = typedArray.getIndexCount(); //2遍歷TypedArray對象,根據(jù)定義的名字獲取值即可 for (int i = 0; i < indexCount; i++) { int attr = typedArray.getIndex(i); if (attr == R.styleable.EasySwipeMenuLayout_leftMenuView) { mLeftViewResID = typedArray.getResourceId(R.styleable.EasySwipeMenuLayout_leftMenuView, -1); } else if (attr == R.styleable.EasySwipeMenuLayout_rightMenuView) { mRightViewResID = typedArray.getResourceId(R.styleable.EasySwipeMenuLayout_rightMenuView, -1); } else if (attr == R.styleable.EasySwipeMenuLayout_contentView) { mContentViewResID = typedArray.getResourceId(R.styleable.EasySwipeMenuLayout_contentView, -1); } else if (attr == R.styleable.EasySwipeMenuLayout_canLeftSwipe) { mCanLeftSwipe = typedArray.getBoolean(R.styleable.EasySwipeMenuLayout_canLeftSwipe, true); } else if (attr == R.styleable.EasySwipeMenuLayout_canRightSwipe) { mCanRightSwipe = typedArray.getBoolean(R.styleable.EasySwipeMenuLayout_canRightSwipe, true); } else if (attr == R.styleable.EasySwipeMenuLayout_fraction) { mFraction = typedArray.getFloat(R.styleable.EasySwipeMenuLayout_fraction, 0.5f); } } } catch (Exception e) { e.printStackTrace(); } finally { //3、最后不要忘記回收typedArray對象哦 typedArray.recycle(); }
Ok,自定義控件的自定義屬性問題就這樣解決了,接下來我們就開始分析實現(xiàn)代碼吧
-
首先我們的EasySwipeMenuLayout通過繼承ViewGroup進行實現(xiàn),里面的構(gòu)造方法通過不斷的調(diào)用自身的構(gòu)造方法,最終會調(diào)用init()方法做一些初始化方面的工作。
public class EasySwipeMenuLayout extends ViewGroup { private static final String TAG = "EasySwipeMenuLayout"; .... public EasySwipeMenuLayout(Context context) { this(context, null); } public EasySwipeMenuLayout(Context context, AttributeSet attrs) { this(context, attrs, 0); } public EasySwipeMenuLayout(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context, attrs, defStyleAttr); } }
我們想下初始化需要做什么工作呢?其實很簡單
1、肯定是獲取我們自定義的屬性了,因為我們要根據(jù)用戶配置的屬性進行處理嘛
2、前面也說了,側(cè)滑用到了scroller,我們的scroller對象的初始化也可以放在這里
-
3、一些輔助類的初始化
/** * 初始化方法 * * @param context * @param attrs * @param defStyleAttr */ private void init(Context context, AttributeSet attrs, int defStyleAttr) { //創(chuàng)建輔助對象 ViewConfiguration viewConfiguration = ViewConfiguration.get(context); mScaledTouchSlop = viewConfiguration.getScaledTouchSlop(); mScroller = new Scroller(context); //1、獲取配置的屬性值 TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EasySwipeMenuLayout, defStyleAttr, 0); try { int indexCount = typedArray.getIndexCount(); //2、開始遍歷,并用變量存儲用戶配置的數(shù)據(jù),包括菜單布局的id等 for (int i = 0; i < indexCount; i++) { int attr = typedArray.getIndex(i); if (attr == R.styleable.EasySwipeMenuLayout_leftMenuView) { mLeftViewResID = typedArray.getResourceId(R.styleable.EasySwipeMenuLayout_leftMenuView, -1); } else if (attr == R.styleable.EasySwipeMenuLayout_rightMenuView) { mRightViewResID = typedArray.getResourceId(R.styleable.EasySwipeMenuLayout_rightMenuView, -1); } else if (attr == R.styleable.EasySwipeMenuLayout_contentView) { mContentViewResID = typedArray.getResourceId(R.styleable.EasySwipeMenuLayout_contentView, -1); } else if (attr == R.styleable.EasySwipeMenuLayout_canLeftSwipe) { mCanLeftSwipe = typedArray.getBoolean(R.styleable.EasySwipeMenuLayout_canLeftSwipe, true); } else if (attr == R.styleable.EasySwipeMenuLayout_canRightSwipe) { mCanRightSwipe = typedArray.getBoolean(R.styleable.EasySwipeMenuLayout_canRightSwipe, true); } else if (attr == R.styleable.EasySwipeMenuLayout_fraction) { mFraction = typedArray.getFloat(R.styleable.EasySwipeMenuLayout_fraction, 0.5f); } } } catch (Exception e) { e.printStackTrace(); } finally { typedArray.recycle(); } }
初始化之后,根據(jù)View的創(chuàng)建流程,下一步當然是測量了
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//1、獲取childView的個數(shù)
int count = getChildCount();
//參考frameLayout測量代碼
//2、判斷我們的EasySwipeMenuLayout的寬高是明確的具體數(shù)值還是匹配或者包裹父布局,為什么要處理呢,還不大清楚的可以看Android之自定義View的死亡三部曲之(Measure) 這篇文章
final boolean measureMatchParentChildren =
MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY ||
MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY;
mMatchParentChildren.clear();
int maxHeight = 0;
int maxWidth = 0;
int childState = 0;
//3、開始遍歷childViews進行測量
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
//4、如果view是GONE,那么我們就不需要測量它了,因為它是隱藏的嘛
if (child.getVisibility() != GONE) {
//5、測量子childView
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);
MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
//6、獲取childView中寬的最大值
maxWidth = Math.max(maxWidth,
child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin);
//7、獲取childView中高的最大值
maxHeight = Math.max(maxHeight,
child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin);
childState = combineMeasuredStates(childState, child.getMeasuredState());
//8、如果child中有MATCH_PARENT的,需要再次測量,這里先添加到mMatchParentChildren集合中
if (measureMatchParentChildren) {
if (lp.width == LayoutParams.MATCH_PARENT ||
lp.height == LayoutParams.MATCH_PARENT) {
mMatchParentChildren.add(child);
}
}
}
}
// Check against our minimum height and width
//9、我們的EasySwipeMenuLayout的寬度和高度還要考慮背景的大小哦
maxHeight = Math.max(maxHeight, getSuggestedMinimumHeight());
maxWidth = Math.max(maxWidth, getSuggestedMinimumWidth());
//10、設(shè)置我們的EasySwipeMenuLayout的具體寬高
setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, childState),
resolveSizeAndState(maxHeight, heightMeasureSpec,
childState << MEASURED_HEIGHT_STATE_SHIFT));
//11、EasySwipeMenuLayout的寬高已經(jīng)知道了,前面MATCH_PARENT的child的值當然我們也能知道了 ,所以這次再次測量它
count = mMatchParentChildren.size();
if (count > 1) {
for (int i = 0; i < count; i++) {
final View child = mMatchParentChildren.get(i);
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
//12、以下是重新設(shè)置child測量所需的MeasureSpec對象
final int childWidthMeasureSpec;
if (lp.width == LayoutParams.MATCH_PARENT) {
final int width = Math.max(0, getMeasuredWidth()
- lp.leftMargin - lp.rightMargin);
childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(
width, MeasureSpec.EXACTLY);
} else {
childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec,
lp.leftMargin + lp.rightMargin,
lp.width);
}
final int childHeightMeasureSpec;
if (lp.height == FrameLayout.LayoutParams.MATCH_PARENT) {
final int height = Math.max(0, getMeasuredHeight()
- lp.topMargin - lp.bottomMargin);
childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
height, MeasureSpec.EXACTLY);
} else {
childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec,
lp.topMargin + lp.bottomMargin,
lp.height);
}
//13、重新測量child
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
}
}
-
Ok,布局已經(jīng)測量好了,我們只需要把它按設(shè)計擺上去即可
@Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int count = getChildCount(); int left = 0 + getPaddingLeft(); int right = 0 + getPaddingLeft(); int top = 0 + getPaddingTop(); int bottom = 0 + getPaddingTop(); //1、根據(jù)我們配置的id獲取對象的View對象,里面我們自動幫用戶設(shè)置了setClickable(true);當然你也可以讓用戶自己去配置,這樣做是為了響應(yīng)touch事件 for (int i = 0; i < count; i++) { View child = getChildAt(i); if (mLeftView == null && child.getId() == mLeftViewResID) { // Log.i(TAG, "找到左邊按鈕view"); mLeftView = child; mLeftView.setClickable(true); } else if (mRightView == null && child.getId() == mRightViewResID) { // Log.i(TAG, "找到右邊按鈕view"); mRightView = child; mRightView.setClickable(true); } else if (mContentView == null && child.getId() == mContentViewResID) { // Log.i(TAG, "找到內(nèi)容View"); mContentView = child; mContentView.setClickable(true); } } //2、布局contentView,contentView是放在屏幕中間的 int cRight = 0; if (mContentView != null) { mContentViewLp = (MarginLayoutParams) mContentView.getLayoutParams(); int cTop = top + mContentViewLp.topMargin; int cLeft = left + mContentViewLp.leftMargin; cRight = left + mContentViewLp.leftMargin + mContentView.getMeasuredWidth(); int cBottom = cTop + mContentView.getMeasuredHeight(); mContentView.layout(cLeft, cTop, cRight, cBottom); } //3、布局mLeftView,mLeftView是在左邊的,一開始是看不到的 if (mLeftView != null) { MarginLayoutParams leftViewLp = (MarginLayoutParams) mLeftView.getLayoutParams(); int lTop = top + leftViewLp.topMargin; int lLeft = 0 - mLeftView.getMeasuredWidth() + leftViewLp.leftMargin + leftViewLp.rightMargin; int lRight = 0 - leftViewLp.rightMargin; int lBottom = lTop + mLeftView.getMeasuredHeight(); mLeftView.layout(lLeft, lTop, lRight, lBottom); } //4、布局mRightView,mRightView是在右邊的,一開始也是看不到的 if (mRightView != null) { MarginLayoutParams rightViewLp = (MarginLayoutParams) mRightView.getLayoutParams(); int lTop = top + rightViewLp.topMargin; int lLeft = mContentView.getRight() + mContentViewLp.rightMargin + rightViewLp.leftMargin; int lRight = lLeft + mRightView.getMeasuredWidth(); int lBottom = lTop + mRightView.getMeasuredHeight(); mRightView.layout(lLeft, lTop, lRight, lBottom); } }
Ok,弄到這里,我們接下來還有什么沒做呢
yes,當然是對于touch事件的交互了
-
這里采用重寫dispatchTouchEvent事件進行實現(xiàn),當然你也可以重寫onTouchEvent事件進行實現(xiàn)
@Override public boolean dispatchTouchEvent(MotionEvent ev) { switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: { // System.out.println(">>>>dispatchTouchEvent() ACTION_DOWN"); isSwipeing = false; //1、記錄最后點擊的位置 if (mLastP == null) { mLastP = new PointF(); } mLastP.set(ev.getRawX(), ev.getRawY()); if (mFirstP == null) { mFirstP = new PointF(); } //2、記錄第一次點擊的位置 mFirstP.set(ev.getRawX(), ev.getRawY()); //3、mViewCache,參考了網(wǎng)上一個作者的思想,通過類單例來控制每次只有一個菜單被打開 if (mViewCache != null) { if (mViewCache != this) { //4、當此時點擊的view不實已開大菜單的view,我們就關(guān)閉已打開的菜單 mViewCache.handlerSwipeMenu(State.CLOSE); } } break; } case MotionEvent.ACTION_MOVE: { // System.out.println(">>>>dispatchTouchEvent() ACTION_MOVE getScrollX:" + getScrollX()); isSwipeing = true; //5、獲得橫向和縱向的移動距離 float distanceX = mLastP.x - ev.getRawX(); float distanceY = mLastP.y - ev.getRawY(); if (Math.abs(distanceY) > mScaledTouchSlop * 2) { break; } //當處于水平滑動時,禁止父類攔截 if (Math.abs(distanceX) > mScaledTouchSlop * 2 || Math.abs(getScrollX()) > mScaledTouchSlop * 2) { requestDisallowInterceptTouchEvent(true); } //6、通過使用scrollBy控制view的滑動 scrollBy((int) (distanceX), 0); //7、越界修正 if (getScrollX() < 0) { if (!mCanRightSwipe || mLeftView == null) { scrollTo(0, 0); } {//左滑 if (getScrollX() < mLeftView.getLeft()) { scrollTo(mLeftView.getLeft(), 0); } } } else if (getScrollX() > 0) { if (!mCanLeftSwipe || mRightView == null) { scrollTo(0, 0); } else { if (getScrollX() > mRightView.getRight() - mContentView.getRight() - mContentViewLp.rightMargin) { scrollTo(mRightView.getRight() - mContentView.getRight() - mContentViewLp.rightMargin, 0); } } } mLastP.set(ev.getRawX(), ev.getRawY()); break; } case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: { // System.out.println(">>>>dispatchTouchEvent() ACTION_CANCEL OR ACTION_UP"); //8、當用戶松開時,判斷當前狀態(tài),比如左滑菜單出現(xiàn)一半了,此時松開我們應(yīng)該讓菜單自動滑出來 State result = isShouldOpen(getScrollX()); handlerSwipeMenu(result); break; } default: { break; } } return super.dispatchTouchEvent(ev); }
Ok,之后我們再考慮點細節(jié)問題就差不多了
-
比如,假如你在recyclerView中使用,那么當你側(cè)滑出菜單的時候,肯定不希望他出發(fā)recyclerView的滾動事件,這時我們可以通過重寫onInterceptTouchEvent方法處理
@Override public boolean onInterceptTouchEvent(MotionEvent event) { // Log.d(TAG, "dispatchTouchEvent() called with: " + "ev = [" + event + "]"); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: { break; } case MotionEvent.ACTION_MOVE: { //對左邊界進行處理 float distance = mLastP.x - event.getRawX(); if (Math.abs(distance) > mScaledTouchSlop) { // 當手指拖動值大于mScaledTouchSlop值時,認為應(yīng)該進行滾動,攔截子控件的事件 return true; } break; } } return super.onInterceptTouchEvent(event); }
Ok,到這里我們就基本完工了。
總結(jié)
- 自定義View三部曲,測量、布局、繪制的掌握是關(guān)鍵
- 與用戶交互,重寫dispatchTouchEvent或者onTouchEvent等,根據(jù)實際情況而定
- 做好一定的touch事件攔截處理
- 重點還是要掌握自定義View的三部曲以及touch事件的分發(fā)機制,再加上一些動畫的處理,基本能滿足大部分的業(yè)務(wù)需求了,重點還是要掌握根本的東西,厚積而薄發(fā),加油。
- 希望通過本次的內(nèi)容分析能夠給予你一些幫助,謝謝!