工做三年有余,年纪大了专业技能到没长进,有时候闲的时候总想写点东西出来,因为本身的懒惰一直拖拖拉拉,好几回还没开始就放弃了,你们也都知道,学编程的大多数不善于表达,加上本身的专业技能确实不怎么样。此次因缘巧合之下正好负责迭代版本中的控件部分,因而就有了控件人生系列文章。php
先来看看两张效果图: java
先看看小红书的效果: android
仔细观察,你会发现:git
标签跟随手指移动而且当前所触摸的标签位于其余标签之上;github
标签不能移出图片区域(除下方向外),同时手指按下与抬起,删除区域显示与隐藏(暴露接口);编程
当标签超过必定的长度,移动到图片边缘,标签出现挤压效果;dom
点击呼吸灯区域(横躺的棒棒糖),切换标签方向;ide
当前图片添加标签后,再次切回当前图片,标签数据依旧存在(保存与恢复);布局
好,如今咱们基本分析的差很少了,下面开始构思代码。post
标签有添加与移除,天然会想到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(标签控件)。
先来看看标签的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的预览效果图:
// 左侧视图
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();
}
复制代码
标签的默认效果,就像这样:
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,用于后面的越界处理。
观察小红书的挤压是分状况来处理的:
标签在呼吸灯的左侧,只能向左挤压。挤压的条件,一、标签长度大于必定值;二、标签靠在父控件左侧边缘,手指并向左侧拖动。
标签在呼吸灯的右侧,只能向右挤压。挤压条件同上。
有挤压就有拉伸,与上面两种状况正好相反,标签在呼吸灯左侧只能向右拉伸;右侧只能向左拉伸。拉伸的条件,一、标签长度小于最大值;二、标签靠在父控件的左、右边缘同时向相反的方向拖动。
挤压拉伸的方法以下:
/** * 挤压拉伸文本区域 * * @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);
复制代码
来,看看效果:
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);
}
复制代码
针对方法流程,并无细讲,若是有疑问,请给我留言。让咱们一块儿看看标签切换的效果图:
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);
}
}
}
复制代码
最后让咱们用一张动图,来感觉标签控件的强大: