Android 自定义控件之从 0 到 1 轻松实现侧滑按钮

本贴最后更新于 2175 天前,其中的信息可能已经时移世异

一、前言


二、构想图

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 事件的分发机制,再加上一些动画的处理,基本能满足大部分的业务需求了,重点还是要掌握根本的东西,厚积而薄发,加油。
  • 希望通过本次的内容分析能够给予你一些帮助,谢谢!
  • Android

    Android 是一种以 Linux 为基础的开放源码操作系统,主要使用于便携设备。2005 年由 Google 收购注资,并拉拢多家制造商组成开放手机联盟开发改良,逐渐扩展到到平板电脑及其他领域上。

    334 引用 • 323 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...