前言
工作三年有余,年纪大了专业技能到没长进,有时候闲的时候总想写点东西出来,由于自己的懒惰一直拖拖拉拉,好几次还没开始就放弃了,大家也都知道,学编程的大多数不善于表达,加上自己的专业技能确实不怎么样。这次因缘巧合之下正好负责迭代版本中的控件部分,于是就有了控件人生系列文章。
先来看看两张效果图:


emmm,参考的是小红书编辑页的标签效果, 拿在手里玩了一会,标签可以跟随手指移动,当前拖动的标签覆盖在其他标签之上,还可以挤压,切换标签方向,拖到删除区域手指放开标签被移除。。。玩着,玩着却让我玩出了一个bug,捂脸:当有7,8张图片时(图片切换是以viewpager实现),在第一张图片添加标签,然后来回切换viewpager,标签的位置会错乱。。。
初步分析
先看看小红书的效果:


emmm,从效果上看呢,并不复杂,主要是细节的处理。接下来我们具体一步一步分析,从而打造属于我们自己的效果。
仔细观察,你会发现:
标签跟随手指移动并且当前所触摸的标签位于其他标签之上;
标签不能移出图片区域(除下方向外),同时手指按下与抬起,删除区域显示与隐藏(暴露接口);
当标签超过一定的长度,移动到图片边缘,标签出现挤压效果;
点击呼吸灯区域(横躺的棒棒糖),切换标签方向;
当前图片添加标签后,再次切回当前图片,标签数据依旧存在(保存与恢复);
好,现在我们基本分析的差不多了,下面开始构思代码。
构思代码
标签有添加与移除,自然会想到ViewGroup,同时ViewGroup的宽高需与图片保持一致,标签可能在ViewGroup的任意位置,那么就需要标签动态改变Translation值,怎么样才能让当前触摸的标签位于其他标签之上?大家都知道ViewGroup的子view索引值越大越能显示在屏幕的前面。那么当手指触摸到标签时,就需要改变子View的索引值,可ViewGroup并没有提供直接改变子View索引值的方法。父类直接添加会报父类已存在的异常,那么我可不可以先移除,再添加到ViewGroup的最后面,这方案不错,最终也是按着这个方案来实现的。
在最开始的两张效果图中,产品还有这样一个需求:需要拖动标签到屏幕底部【移动到此处】进行删除。刚刚已经分析了标签的父控件大小与图片一致,考虑到视图层级的关系,标签移出父控件,可能会出现被其他View遮挡的现象,那又怎么样才能不让遮挡呢?
还记不记得很早以前的自定义View之案列篇(三):仿QQ小红点呢?父控件默认裁剪子view,那么可以通过:
android:clipChildren="false"
设置父控件不裁剪。

在上文中提到,当标签超过一定的长度,移动到图片边缘,标签出现挤压效果。记得在漫画播放器一吐槽功能中已经实现了类似的功能。
那个思路也能用到这里来:动态改变控件的宽度,就能实现文字的挤压效果。
还有一个效果:点击呼吸灯区域,切换标签方向。说说最开始的实现思路:左右标签分别是两个xml布局文件,切换方向的时候,通过inflate来加载对应的xml文件实现方向的切换。每次切换方向都会重新加载xml文件,这样效率并不高。没想到我这样的年轻司机也有翻车的时候啊,哈哈。后来,细细一折磨,为何不把左右标签放在一个xml文件,通过隐藏显示控制标签方向,哈哈,好家伙,效率比两个xml文件好很多。
接下来,开工写代码洛~~
起名字
起名字一直是一门艺术,一个好的控件必须有一个好的名字,我看就叫:RandomDragTagLayout(标签父控件),RandomDragTagView(标签控件)。
编写代码
RandomDragTagView
先来看看标签的xml布局文件(R.layout.random_tag_layout):
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="horizontal"> <!-- 左侧标签 --> <LinearLayout...> <View android:id="@+id/left_line_view" android:layout_width="13.5dp" android:layout_height="1dp" android:layout_gravity="center_vertical" android:layout_marginRight="-3.5dp" android:background="#FFFFFF"></View> <!-- 中点呼吸灯 --> <FrameLayout...> <View android:id="@+id/right_line_view" android:layout_width="13.5dp" android:layout_height="1dp" android:layout_gravity="center_vertical" android:layout_marginLeft="-3.5dp" android:background="#FFFFFF"></View> <!-- 右侧标签 --> <LinearLayout...> </LinearLayout>
xml的预览效果图:
好,xml布局文件比较简单,接着我们来看看RandomDragTagView应该怎么写: RandomDragTagView类继承LinearLayout,先是成员变量:
// 左侧视图 private LinearLayout mLeftLayout; private TextView mLeftText; private View mLeftLine; // 右侧视图 private LinearLayout mRightLayout; private TextView mRightText; private View mRightLine; // 中间视图 private View mBreathingView; private FrameLayout mBreathingLayout; // 是否显示左侧视图 默认显示左侧视图 private boolean mIsShowLeftView = true; // 呼吸灯动画 private ValueAnimator mBreathingAnimator; // 回弹动画 private ValueAnimator mReboundAnimator; private float mStartReboundX; private float mStartReboundY; private float mLastMotionRawY; private float mLastMotionRawX; // 是否多跟手指按下 private boolean mPointerDown = false; private int mTouchSlop = -1; // 是否可以拖拽 private boolean mCanDrag = true; // 是否可以拖拽出父控件区域 private boolean mDragOutParent = true; // 父控件最大的高度 private int mMaxParentHeight = 0; // 最大挤压宽度 默认400 private int mMaxExtrusionWidth = 400; // 文本圆角矩形的最大宽度 private int mMaxTextLayoutWidth = 0; // 删除标签区域的高度 private int mDeleteRegionHeight; // 暴露接口 private boolean mStartDrag = false; private OnRandomDragListener mDragListener;
再到一参,二参,三参的构造方法,参数的话,Context,attrs,defStyleAttr是不用说了,一参,二参指向三参构造:
public RandomDragTagView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); setOrientation(HORIZONTAL); inflate(context, R.layout.random_tag_layout, this); initView(); initListener(); initData(); startBreathingAnimator(); }
initView,initListener方法也不用说了,用于初始化控件与事件监听的方法。initData方法隐藏右侧标签部分,而startBreathingAnimator方法用于开启呼吸灯动画,在效果中,呼吸灯有来回缩放的效果,就好似一呼一吸。
// 开启呼吸灯动画 注动画无线循环注意回收防止内存泄露 private void startBreathingAnimator() { if (mBreathingAnimator != null && mBreathingAnimator.isRunning()) { mBreathingAnimator.cancel(); mBreathingAnimator = null; } mBreathingAnimator = ValueAnimator.ofFloat(0.8F, 1.0F); mBreathingAnimator.setRepeatMode(ValueAnimator.REVERSE); mBreathingAnimator.setDuration(800); mBreathingAnimator.setStartDelay(200); mBreathingAnimator.setRepeatCount(-1); mBreathingAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float value = (float) animation.getAnimatedValue(); mBreathingView.setScaleX(value); mBreathingView.setScaleY(value); } }); mBreathingAnimator.start(); }
注意呼吸灯动画设置了setRepeatCount重复次数为-1,表示无限循环。onAnimationUpdate方法会被一直调用,同时方法内部持有mBreathingView的引用,最终会导致mBreathingView所属的activity被持有无法回收,从而引起内存泄露。
那么我们需要在合适的时机调用动画cancel并置为null,就像这样:
@Override protected void onDetachedFromWindow() { if (mBreathingAnimator != null && mBreathingAnimator.isRunning()) { mBreathingAnimator.cancel(); mBreathingAnimator = null; } super.onDetachedFromWindow(); }
标签的默认效果,就像这样:
好了,在效果中标签跟随手指移动,重写onTouchEvent方法,在触发拖动事件时,我们需要对一些数值进行初始化并改变标签在父控件中的索引值,让当前所触摸的标签显示在其他标签之上:
switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: final float x = event.getRawX(); final float y = event.getRawY(); // 允许父控件不拦截事件 getParent().requestDisallowInterceptTouchEvent(true); mStartDrag = false; mPointerDown = false; mLastMotionRawX = x; mLastMotionRawY = y; mStartReboundX = getTranslationX(); mStartReboundY = getTranslationY(); // 调整索引 位于其他标签之上 adjustIndex(); break;
adjustIndex方法用于调整索引:
/** * 调整索引 位于其他标签之上 */ private void adjustIndex() { ViewParent parent = getParent(); if (parent != null) { if (parent instanceof ViewGroup) { ViewGroup parentView = (ViewGroup) parent; int childCount = parentView.getChildCount(); if (childCount > 1 && indexOfChild(this) != (childCount - 1)) { parentView.removeView(this); parentView.addView(this); // 重新开启呼吸灯动画 startBreathingAnimator(); } } } }
emmmm,接下来到移动了,更新当前触摸坐标值,根据坐标值偏移量来动态设置setTranslation,同时对越界,挤压处理:
case MotionEvent.ACTION_MOVE: final float rawY = event.getRawY(); final float rawX = event.getRawX(); if (!mStartDrag) { mStartDrag = true; if (mDragListener != null) { mDragListener.onStartDrag(); } } if (!mPointerDown) { final float yDiff = rawY - mLastMotionRawY; final float xDiff = rawX - mLastMotionRawX; // 处理move事件 handlerMoveEvent(yDiff, xDiff); mLastMotionRawY = rawY; mLastMotionRawX = rawX; } break;
首先暴露开始拖动的接口回调,有同学就会有疑问为啥不在事件ACTION_DOWN中回调呢?主要是因为,观察小红书快速点击也没有执行开始拖动的回调。还有这里的回调判定并不是很合理,如果能够加上mTouchSlop,那就再好不过呢。不要问我为什么不加,懒呗 。
mPointerDown参数主要用来控制是否有多根手指按下,同样也是观察小红书,在多根手指按下的情况下,标签并没有跟随手指移动,只有在单根手指的情况才会移动。
那么mPointerDown在多根手指按下与抬起的事件中更新状态:
// 多根手指按下 case MotionEvent.ACTION_POINTER_DOWN: mPointerDown = true; break; // 多根手指抬起 case MotionEvent.ACTION_POINTER_UP: mPointerDown = false; break;
接下来对越界与挤压的处理:
/** * 处理手势的move事件 * * @param yDiff y轴方向的偏移量 * @param xDiff x轴方向的偏移量 */ private void handlerMoveEvent(float yDiff, float xDiff) { float translationX = getTranslationX() + xDiff; float translationY = getTranslationY() + yDiff; // 越界处理 最大最小原则 int parentWidth = ((View) getParent()).getWidth(); int parentHeight = ((View) getParent()).getHeight(); if (mMaxParentHeight == 0) { int parentParentHeight = ((View) getParent().getParent()).getHeight(); mMaxParentHeight = (mDragOutParent ? parentParentHeight : parentHeight) - getHeight(); } int maxWidth = parentWidth - getWidth(); // 分情况处理越界 宽度 if (translationX <= 0) { translationX = 0; // 标签文本出现挤压效果 if (isShowLeftView()) { extrusionTextRegion(xDiff); } } else if (translationX >= maxWidth) { translationX = maxWidth; // 右侧挤压 if (!isShowLeftView()) { extrusionTextRegion(-xDiff); handleWidthError(); } } else { int textWidth = isShowLeftView() ? mLeftLayout.getWidth() : mRightLayout.getWidth(); // 左侧视图 if (isShowLeftView()) { if (getTranslationX() == 0 && textWidth < mMaxTextLayoutWidth) { translationX = 0; extrusionTextRegion(xDiff); } } else { if (textWidth < mMaxTextLayoutWidth) { extrusionTextRegion(-xDiff); handleWidthError(); } } } // 高度越界处理 if (translationY <= 0) { translationY = 0; } else if (translationY >= mMaxParentHeight) { translationY = mMaxParentHeight; } setTranslationX(translationX); setTranslationY(translationY); }
在上文中已经提到过,产品新增标签可以拖出父控件底部区域(小红书不允许),不要问我为什么,三个字:产品最大。
作为一名程序猿,必须保证代码的健壮性,同时也为了防止产品哪天提出:不允许拖出父控件的底部区域的需求?
那就需要一个标识来标识是否拖出父控件底部区域,这就是mDragOutParent参数的由来。根据标识获取到父控件的最大高度mMaxParentHeight,用于后面的越界处理。
观察小红书的挤压是分情况来处理的:
标签在呼吸灯的左侧,只能向左挤压。挤压的条件,1、标签长度大于一定值;2、标签靠在父控件左侧边缘,手指并向左侧拖动。
标签在呼吸灯的右侧,只能向右挤压。挤压条件同上。
有挤压就有拉伸,与上面两种情况正好相反,标签在呼吸灯左侧只能向右拉伸;右侧只能向左拉伸。拉伸的条件,1、标签长度小于最大值;2、标签靠在父控件的左、右边缘同时向相反的方向拖动。
挤压拉伸的方法如下:
/** * 挤压拉伸文本区域 * * @param deltaX 偏移量 */ private void extrusionTextRegion(float deltaX) { int textWidth = isShowLeftView() ? mLeftLayout.getWidth() : mRightLayout.getWidth(); LinearLayout.LayoutParams lp = (LayoutParams) (isShowLeftView() ? mLeftLayout.getLayoutParams() : mRightLayout.getLayoutParams()); if (textWidth >= mMaxExtrusionWidth) { lp.width = (int) (textWidth + deltaX); // 越界判定 if (lp.width <= mMaxExtrusionWidth) { lp.width = mMaxExtrusionWidth; } else if (lp.width >= mMaxTextLayoutWidth) { lp.width = mMaxTextLayoutWidth; } if (isShowLeftView()) { mLeftLayout.setLayoutParams(lp); } else { mRightLayout.setLayoutParams(lp); } } }
注意:由于文本控件宽度改变,文本显示的字符数会发生变化,字符数的增减会导致文本宽度与deltaX不一致,导致标签在呼吸灯右侧挤压拉伸有几率并没有靠在右侧边缘。 所以有了以下的兼容误差处理:
// 处理宽度误差 private void handleWidthError() { post(new Runnable() { @Override public void run() { int parentWidth = ((View) getParent()).getWidth(); int maxWidth = parentWidth - getWidth(); setTranslationX(maxWidth); } }); }
处理完了挤压与拉伸,就剩下高度的越界处理与改变setTranslation值:
// 高度越界处理 if (translationY <= 0) { translationY = 0; } else if (translationY >= mMaxParentHeight) { translationY = mMaxParentHeight; } setTranslationX(translationX); setTranslationY(translationY);
来,看看效果:

好,ACTION_MOVE处理完,到ACTION_UP了。根据getTranslationY值来判定标签是否滑出父控件区域,如果滑动到删除区域,则移除标签控件;如果滑出图片区域并没有滑到删除区域(上图的黑色区域),则开始回弹动画。最后暴露结束拖动的回调。
case MotionEvent.ACTION_UP: mPointerDown = false; mStartDrag = false; getParent().requestDisallowInterceptTouchEvent(false); final float translationY = getTranslationY(); final int parentHeight = ((View) getParent()).getHeight(); if (mMaxParentHeight - mDeleteRegionHeight < translationY) { removeTagView(); } else if (parentHeight - getHeight() < translationY) { startReBoundAnimator(); } if (mDragListener != null) { mDragListener.onStopDrag(); } break;
回弹动画以手指按下与抬起为开始与结束点进行平移,代码非常简单:
// 开始回弹动画 private void startReBoundAnimator() { if (mReboundAnimator != null && mReboundAnimator.isRunning()) { mReboundAnimator.cancel(); } mReboundAnimator = ValueAnimator.ofFloat(1F, 0F); mReboundAnimator.setDuration(400); final float startTransX = getTranslationX(); final float startTransY = getTranslationY(); mReboundAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float value = (float) animation.getAnimatedValue(); setTranslationX(mStartReboundX + (startTransX - mStartReboundX) * value); setTranslationY(mStartReboundY + (startTransY - mStartReboundY) * value); } }); mReboundAnimator.start(); }
对了,还有一功能,点击呼吸灯切换标签方向:
// 切换方向 public void switchDirection() { mIsShowLeftView = !mIsShowLeftView; visibilityLeftLayout(); visibilityRightLayout(); // 第一步更改 重置 textLayout 的高度 final int preSwitchWidth = getWidth(); LinearLayout.LayoutParams lp = (LayoutParams) (isShowLeftView() ? mLeftLayout.getLayoutParams() : mRightLayout.getLayoutParams()); lp.width = LayoutParams.WRAP_CONTENT; if (mIsShowLeftView) { mLeftText.setText(mRightText.getText()); mLeftLayout.setLayoutParams(lp); } else { mRightText.setText(mLeftText.getText()); mRightLayout.setLayoutParams(lp); } post(new Runnable() { @Override public void run() { // 第二步 重新设置setTranslationX的值 float newTranslationX = 0; if (!isShowLeftView()) { newTranslationX = getTranslationX() + preSwitchWidth - mBreathingView.getWidth(); } else { newTranslationX = getTranslationX() - getWidth() + mBreathingView.getWidth(); } // 边界检测 checkBound(newTranslationX, getTranslationY()); } }); }
首先根据标签方向,显示与隐藏左右标签视图;然后给标签设置文本,同时重置标签的宽度属性;接着重新设置标签的setTranslationX值,最后边界检测。
边界检测方法代码如下:
/** * @param newTranslationX * @param newTranslationY */ private void checkBound(float newTranslationX, float newTranslationY) { setTranslationX(newTranslationX); // 越界的情况下 改变textLayout 的高度 final int parentWidth = ((View) getParent()).getWidth(); final int parentHeight = ((View) getParent()).getHeight(); float translationX = getTranslationX(); if (translationX <= 0) { extrusionTextRegion(translationX); } else if (getTranslationX() >= (parentWidth - getWidth())) { final float offsetX = getWidth() - (parentWidth - getTranslationX()); extrusionTextRegion(-offsetX); // 越界检测 post(new Runnable() { @Override public void run() { if (getTranslationX() >= (parentWidth - getWidth())) { setTranslationX(parentWidth - getWidth()); } } }); } // 越界检测 if (getTranslationX() <= 0) { setTranslationX(0); } if (newTranslationY <= 0) { newTranslationY = 0; } else if (newTranslationY >= parentHeight - getHeight()) { newTranslationY = parentHeight - getHeight(); } setTranslationY(newTranslationY); }
针对方法流程,并没有细讲,如果有疑问,请给我留言。让我们一起看看标签切换的效果图:
RandomDragTagView还有一些暴露数据的方法,这里就不一一列出了。
RandomDragTagLayout
RandomDragTagLayout类继承FrameLayout,只有一个方法:
/** * 添加标签 * * @param text 标签文本 * @param x 相对于父控件的x坐标百分比 * @param y 相对于父控件的y坐标百分比 * @param isShowLeftView 是否显示左侧标签 */ public boolean addTagView(String text, final float x, final float y, boolean isShowLeftView) { if (text == null || text.equals("")) return false; RandomDragTagView tagView = new RandomDragTagView(getContext()); addView(tagView, new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); tagView.initTagView(text, x * getWidth(), y * getHeight(), isShowLeftView); return true; }
保存、恢复
保存,新建TagModel 类用于保存标签属性:
private void saveTag() { mTagList.clear(); for (int i = 0; i < mRandomDragTagLayout.getChildCount(); i++) { View childView = mRandomDragTagLayout.getChildAt(i); if (childView instanceof RandomDragTagView) { RandomDragTagView tagView = (RandomDragTagView) childView; TagModel tagModel = new TagModel(); tagModel.direction = tagView.isShowLeftView(); tagModel.text = tagView.getTagText(); tagModel.x = tagView.getPercentTransX(); tagModel.y = tagView.getPercentTransY(); mTagList.add(tagModel); } } }
恢复:
private void restoreTag() { if (!mTagList.isEmpty()) { mRandomDragTagLayout.removeAllViews(); for (TagModel tagModel : mTagList) { mRandomDragTagLayout.addTagView(tagModel.text, tagModel.x, tagModel.y, tagModel.direction); } } }
最后让我们用一张动图,来感受标签控件的强大: