Android自定义控件之从0到1轻松实现侧滑按钮
- 文章独家授权公众号:码个蛋
- 更多分享:http://www.catbro.cn
-
啥都不说,先上个效果图吧
一、前言
- 本次主要用到的知识点有View的测量、布局、Android的touch事件的传递、Scroller三个知识点,之前我也写了几篇文章进行了学习,有需要的可以点击下面的链接哦
- Scorller的使用详解一
- Android Touch事件分发机制详解之由点击引发的战争
- Android之View的诞生之谜
- Android之自定义View的死亡三部曲之(Measure)
- Android之自定义View的死亡三部曲之(Layout)
- Android之自定义View的死亡三部曲之(Draw)
二、构想图
- 我们这次要实现的控件叫做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); }else {//左滑 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事件的分发机制,再加上一些动画的处理,基本能满足大部分的业务需求了,重点还是要掌握根本的东西,厚积而薄发,加油。
- 希望通过本次的内容分析能够给予你一些帮助,谢谢!