工做三年有餘,年紀大了專業技能到沒長進,有時候閒的時候總想寫點東西出來,因爲本身的懶惰一直拖拖拉拉,好幾回還沒開始就放棄了,你們也都知道,學編程的大多數不善於表達,加上本身的專業技能確實不怎麼樣。此次因緣巧合之下正好負責迭代版本中的控件部分,因而就有了控件人生系列文章。php
先來看看兩張效果圖: java
emmm,參考的是 小紅書編輯頁的標籤效果, 拿在手裏玩了一會,標籤能夠跟隨手指移動,當前拖動的標籤覆蓋在其餘標籤之上,還能夠擠壓,切換標籤方向,拖到刪除區域手指放開標籤被移除。。。先看看小紅書的效果: android
emmm,從效果上看呢,並不複雜,主要是細節的處理。接下來咱們具體一步一步分析,從而打造屬於咱們本身的效果。仔細觀察,你會發現: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的預覽效果圖:
好,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,用於後面的越界處理。
觀察小紅書的擠壓是分狀況來處理的:
標籤在呼吸燈的左側,只能向左擠壓。擠壓的條件,一、標籤長度大於必定值;二、標籤靠在父控件左側邊緣,手指並向左側拖動。
標籤在呼吸燈的右側,只能向右擠壓。擠壓條件同上。
有擠壓就有拉伸,與上面兩種狀況正好相反,標籤在呼吸燈左側只能向右拉伸;右側只能向左拉伸。拉伸的條件,一、標籤長度小於最大值;二、標籤靠在父控件的左、右邊緣同時向相反的方向拖動。
擠壓拉伸的方法以下:
/** * 擠壓拉伸文本區域 * * @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類繼承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);
}
}
}
複製代碼
最後讓咱們用一張動圖,來感覺標籤控件的強大: