Android自定義控件之從0到1輕松實現側滑按鈕


一、前言


二、構想圖

EasySwipeMenuLayout構想圖.jpg
  • 我們這次要實現的控件叫做EasySwipeMenuLayout,內部主要分為三部分:
    1、內容區域
    2、左邊菜單按鈕區域
    2、右邊菜單按鈕區域
  • 當我們向右滑時,通過scroller將左邊按鈕區域滾動出來
  • 當我們向左滑時,通過scroller將右邊按鈕區域滾動出來
  • 實現的思路濾清了,那么我們就開始動手吧

三、具體實現

  • 首先,網上類似的輪子有很多,但為什么我們還要自己寫一下呢,當然是為了學習,所謂知其然而知其所以然也,輪子只是滿足了大部分人的需求,試想某一天,有些效果網上是找不到的,那么此時就只能靠自己了。
  • 當然,你也可以說,我就是想自己寫,哈哈。
  • 在開始前,我還想再說一點,網上有很多類似的輪子,但是我發現個特點,他們要求控件內的子布局的順序相對呆板,不夠靈活,也就是所謂通過約定來實現。
  • but,我這次想通過配置來實現,那么如何配置呢,其實我們可以通過控件的id進行綁定,參考了google官方控件的部分思想。

布局文件配置效果

  • 首先,我想實現的配置效果是這樣子的

      <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="內容區域" />
            </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是現實內容的,哪個是左邊的菜單布局,哪個是右邊的菜單布局。

       <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">
    
  • 為什么要這樣子設計的,我的想法是,這樣子更靈活,我不用規定里面的子布局的順序。

  • 以上僅代表個人觀點,當然,肯定有更好的設計方案。

  • Ok,既然要通過id來配置,那么就會用到自定義控件屬性的知識,其實很簡單,就是在res/values下創建一個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對象,根據定義的名字獲取值即可
          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,自定義控件的自定義屬性問題就這樣解決了,接下來我們就開始分析實現代碼吧


  • 首先我們的EasySwipeMenuLayout通過繼承ViewGroup進行實現,里面的構造方法通過不斷的調用自身的構造方法,最終會調用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、肯定是獲取我們自定義的屬性了,因為我們要根據用戶配置的屬性進行處理嘛

  • 2、前面也說了,側滑用到了scroller,我們的scroller對象的初始化也可以放在這里

  • 3、一些輔助類的初始化

      /**
       * 初始化方法 * * @param context
        * @param attrs
        * @param defStyleAttr
        */
      private void init(Context context, AttributeSet attrs, int defStyleAttr) {
          //創建輔助對象
        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、開始遍歷,并用變量存儲用戶配置的數據,包括菜單布局的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();
          }
    
      }
    
  • 初始化之后,根據View的創建流程,下一步當然是測量了

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        
        //1、獲取childView的個數
      int count = getChildCount();
        //參考frameLayout測量代碼
        //2、判斷我們的EasySwipeMenuLayout的寬高是明確的具體數值還是匹配或者包裹父布局,為什么要處理呢,還不大清楚的可以看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、設置我們的EasySwipeMenuLayout的具體寬高
        setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, childState),
                resolveSizeAndState(maxHeight, heightMeasureSpec,
                        childState << MEASURED_HEIGHT_STATE_SHIFT));
        
        //11、EasySwipeMenuLayout的寬高已經知道了,前面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、以下是重新設置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,布局已經測量好了,我們只需要把它按設計擺上去即可

      @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、根據我們配置的id獲取對象的View對象,里面我們自動幫用戶設置了setClickable(true);當然你也可以讓用戶自己去配置,這樣做是為了響應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, "找到內容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事件進行實現,當然你也可以重寫onTouchEvent事件進行實現

      @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,參考了網上一個作者的思想,通過類單例來控制每次只有一個菜單被打開
                  if (mViewCache != null) {
                      if (mViewCache != this) {
                          //4、當此時點擊的view不實已開大菜單的view,我們就關閉已打開的菜單
                          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、當用戶松開時,判斷當前狀態,比如左滑菜單出現一半了,此時松開我們應該讓菜單自動滑出來
        State result = isShouldOpen(getScrollX());
                  handlerSwipeMenu(result);
                  break;
              }
              default: {
                  break;
              }
          }
    
          return super.dispatchTouchEvent(ev);
    
      }
    
  • Ok,之后我們再考慮點細節問題就差不多了

  • 比如,假如你在recyclerView中使用,那么當你側滑出菜單的時候,肯定不希望他出發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值時,認為應該進行滾動,攔截子控件的事件
        return true;
                  }
                  break;
    
              }
    
          }
          return super.onInterceptTouchEvent(event);
      }
    
  • Ok,到這里我們就基本完工了。


總結

  • 自定義View三部曲,測量、布局、繪制的掌握是關鍵
  • 與用戶交互,重寫dispatchTouchEvent或者onTouchEvent等,根據實際情況而定
  • 做好一定的touch事件攔截處理
  • 重點還是要掌握自定義View的三部曲以及touch事件的分發機制,再加上一些動畫的處理,基本能滿足大部分的業務需求了,重點還是要掌握根本的東西,厚積而薄發,加油。
  • 希望通過本次的內容分析能夠給予你一些幫助,謝謝!
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容