Android控件人生第一站,小红书任意拖拽标签控件

作者 : 开心源码 本文共13523个字,预计阅读时间需要34分钟 发布时间: 2022-05-12 共165人阅读

前言

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

先来看看两张效果图:

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

初步分析

先看看小红书的效果:

在这里插入图片形容在这里插入图片形容
emmm,从效果上看呢,并不复杂,主要是细节的解决。接下来我们具体一步一步分析,从而打造属于我们自己的效果。

仔细观察,你会发现:

  • 标签跟随手指移动并且当前所触摸的标签位于其余标签之上;

  • 标签不能移出图片区域(除下方向外),同时手指按下与抬起,删除区域显示与隐藏(暴露接口);

  • 当标签超过肯定的长度,移动到图片边缘,标签出现挤压效果;

  • 点击呼吸灯区域(横躺的棒棒糖),切换标签方向;

  • 当前图片增加标签后,再次切回当前图片,标签数据仍旧存在(保存与恢复);

好,现在我们基本分析的差不多了,下面开始构思代码。

构思代码

标签有增加与移除,自然会想到ViewGroup,同时ViewGroup的宽高需与图片保持一致,标签可能在ViewGroup的任意位置,那么就需要标签动态改变Translation值,怎样样才能让当前触摸的标签位于其余标签之上?大家都知道ViewGroup的子view索引值越大越能显示在屏幕的前面。那么当手指触摸到标签时,就需要改变子View的索引值,可ViewGroup并没有提供直接改变子View索引值的方法。父类直接增加会报父类已存在的异常,那么我可不可以先移除,再增加到ViewGroup的最后面,这方案不错,最终也是按着这个方案来实现的。

在最开始的两张效果图中,产品还有这样一个需求:需要拖动标签到屏幕底部<font color=#0099ff >【移动到此处】</font>进行删除。刚刚已经分析了标签的父控件大小与图片一致,考虑到视图层级的关系,标签移出父控件,可能会出现被其余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);            }        }    }

最后让我们用一张动图,来感受标签控件的强大:

在这里插入图片形容

好了,本篇文章到此结束,有错误的地方请指出,多谢~

Github地址: HpWens/MeiWidgetView 欢迎Star

扫一扫 关注我的公众号

说明
1. 本站所有资源来源于用户上传和网络,如有侵权请邮件联系站长!
2. 分享目的仅供大家学习和交流,您必须在下载后24小时内删除!
3. 不得使用于非法商业用途,不得违反国家法律。否则后果自负!
4. 本站提供的源码、模板、插件等等其他资源,都不包含技术服务请大家谅解!
5. 如有链接无法下载、失效或广告,请联系管理员处理!
6. 本站资源售价只是摆设,本站源码仅提供给会员学习使用!
7. 如遇到加密压缩包,请使用360解压,如遇到无法解压的请联系管理员
开心源码网 » Android控件人生第一站,小红书任意拖拽标签控件

发表回复