仿網易新聞標籤選擇器(可拖動)-TabMoveLayout
仿網易新聞標籤欄-TabMoveLayout
網易新聞標籤欄的實現效果我一直想實現試試,最近發現支付寶的應用欄也變成了這樣,最近花了點時間終於實現,初步實現效果如下,後面有時間還會繼續完善
實現功能
1.長按抖動
2.標籤可隨意拖動,其他標籤隨之變換位置
3.拖動變換子View順序
後續想實現
1.仿照ListView+Adapter,利用adapter模式分離,實現自定義View的拖拽(現在只能為TextView)
2.實現自定義TextView,隨文字長度變換字型大小
3.詳細完善一些細節
4.設計完成後通過JitPack釋出
難點:
1.熟悉自定義ViewGroup過程,onMeasure、onLayout
2.ViewGroup事件處理
3.多種拖動情況考慮(位置移動計算)
4.ViewGroup中子View的變更替換新增
實現思路:
1.自定義ViewGroup,實現標籤欄的排列,這裡我以4列為例(onMeasure,onLayout)
2.實現觸控標籤的拖動,通過onTouch事件,在DOWN:獲取觸控的x,y座標,找到被觸控的View,在MOVE:通過view.layout()方法動態改變View的位置
3.其他標籤的位置變換,主要通過TranslateAnimation,在MOVE:找到拖動過程中經過的View,並執行相應的Animation
(這裡重點要考慮清楚所有拖動可能的情況)
4.拖動結束後,隨之變換ViewGroup中view的實際位置,通過removeViewAt和addView進行新增和刪除,中間遇到一點問題(部落格)已分析。
關鍵程式碼:
1.自定義ViewGroup
這裡主要是onMeasure和onLayout方法。這裡我要說一下我的佈局方式
/**
* 標籤個數 4
* |Magin|View|Magin|Magin|View|Magin|Magin|View|Magin|Magin|View|Magin|
* 總寬度:4*(標籤寬度+2*margin) 按照比例 (總份數):4*(ITEM_WIDTH+2*MARGIN_WIDTH)
* 則一個比例佔的寬度為:元件總寬度/總份數
* 一個標籤的寬度為:元件寬度/總份數 * ITEM_WIDTH(寬度佔的比例)
* 一個標籤的MARGIN為:元件寬度/總份數 * MARGIN_WIDTH(MARGIN佔的比例)
* 行高=(ITEN_HEIGHT+2*MARGIN_HEIGHT)*mItemScale
* 一個元件佔的寬度=(ITEM_WIDTH + 2*MARGIN_WIDTH)*mItemScale
*/
可能看起來比較複雜,其實理解起來就是:
一個標籤所佔的寬度=標籤的寬度+2*marginwidth
一個標籤所佔的高度=標籤的高度+2*marginheight
這裡都是用的權值計算的
一個比例佔的長度為=總寬度/總份數
假如螢幕寬度為1000px,標籤的寬度佔10份,marginwidth佔2份,標籤的高度佔5份,marginheight佔1份
一個比例所佔的長度(以一行4個標籤為例) = 1000/((10+2*2)*4)
一個標籤所佔的寬度 = (10+2*2)*一個比例所佔的長度
一個標籤所佔的高度 = (5+2*1)*一個比例所佔的長度
onMeasure方法
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);
int modeWidth = MeasureSpec.getMode(widthMeasureSpec);
int modeHeight = MeasureSpec.getMode(heightMeasureSpec);
int width;
int height;
int childCount = getChildCount();
if (modeWidth == MeasureSpec.EXACTLY) {
width = sizeWidth;
} else {
width = Math.min(sizeWidth, getScreenWidth(mContext));
}
if (modeHeight == MeasureSpec.EXACTLY) {
height = sizeHeight;
} else {
int rowNum = childCount / ITEM_NUM;
if (childCount % ITEM_NUM != 0) {
height = (int) Math.min(sizeHeight, (rowNum + 1) * (ITEM_HEIGHT + 2 * MARGIN_HEIGHT) * mItemScale);
} else {
height = (int) Math.min(sizeHeight, rowNum * (ITEM_HEIGHT + 2 * MARGIN_HEIGHT) * mItemScale);
}
}
measureChildren(
MeasureSpec.makeMeasureSpec((int) (mItemScale * ITEM_WIDTH), MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec((int) (mItemScale * ITEM_HEIGHT), MeasureSpec.EXACTLY));
setMeasuredDimension(width, height);
}
這裡也是自定義View常見的一個點,注意MeasureSpace的三種模式EXACITY,AT_MOST,UNSPECIFIED,三種模式的對應關係可以簡單理解為:
EXACITY -> MATCH_PARENT或者具體值
AT_MOST -> WARP_CONTENT
UNSPECIFIED是未指定尺寸,這種情況不多,一般都是父控制元件是AdapterView,通過measure方法傳入的模式。
所以這裡我處理方式為
寬度:當EXACITY時:width = widthsize,當其他模式時,width=sizewidth和螢幕寬度的較小值(這裡注意sizeWidth的值為父元件傳給自己的寬度值,所以如果當前元件處於第一層級,sizeWidth=螢幕寬度)
高度:當EXACITY時:height = heightsize,當其他模式時,計算行數,height=行數*一行的高度(height+2*marginheight)
再執行measureChildren
onLayout方法
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
int left;
int top;
int right;
int bottom;
for (int i = 0; i < childCount; i++) {
int row = i / ITEM_NUM;
int column = i % ITEM_NUM;
View child = getChildAt(i);
left = (int) ((MARGIN_WIDTH + column * (ITEM_WIDTH + 2 * MARGIN_WIDTH)) * mItemScale);
top = (int) ((MARGIN_HEIGHT + row * (ITEM_HEIGHT + 2 * MARGIN_HEIGHT)) * mItemScale);
right = (int) (left + ITEM_WIDTH * mItemScale);
bottom = (int) (top + ITEM_HEIGHT * mItemScale);
child.layout(left, top, right, bottom);
}
}
所以onlayout也就比較好理解了,利用for迴圈遍歷child,計算每個child所在的行和列,再通過child.layout()佈局。
2.onTouch事件
public boolean onTouchEvent(MotionEvent event) {
float x = event.getX();
float y = event.getY();
if(isMove){
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mBeginX = x;
mBeginY = y;
mTouchIndex = findChildIndex(x, y);
mOldIndex = mTouchIndex;
if (mTouchIndex != -1) {
mTouchChildView = getChildAt(mTouchIndex);
mTouchChildView.clearAnimation();
//mTouchChildView.bringToFront();
}
break;
case MotionEvent.ACTION_MOVE:
if (mTouchIndex != -1 && mTouchChildView != null) {
moveTouchView(x, y);
//拖動過程中的View的index
int resultIndex = findChildIndex(x, y);
if (resultIndex != -1 && (resultIndex != mOldIndex)
&& ((Math.abs(x - mBeginX) > mItemScale * 2 * MARGIN_WIDTH)
|| (Math.abs(y - mBeginY) > mItemScale * 2 * MARGIN_HEIGHT))
) {
beginAnimation(Math.min(mOldIndex, resultIndex)
, Math.max(mOldIndex, resultIndex)
, mOldIndex < resultIndex);
mOldIndex = resultIndex;
mOnHover = true;
}
}
break;
case MotionEvent.ACTION_UP:
setTouchIndex(x, y);
mOnHover = false;
mTouchIndex = -1;
mTouchChildView = null;
return true;
}
}
return super.onTouchEvent(event);
}
這個方法算是這個效果的主要方法了,詳細分析一下吧。首先看DOWN事件
case MotionEvent.ACTION_DOWN:
mBeginX = x;
mBeginY = y;
mTouchIndex = findChildIndex(x, y);
mOldIndex = mTouchIndex;
if (mTouchIndex != -1) {
mTouchChildView = getChildAt(mTouchIndex);
mTouchChildView.clearAnimation();
//mTouchChildView.bringToFront();
}
break;
可以看到,首先我先記錄了觸控位置的x,y座標,通過findChildIndex方法確定觸控位置的child的index。
/**
* 通過觸控位置確定觸控位置的View
*/
private int findChildIndex(float x, float y) {
int row = (int) (y / ((ITEM_HEIGHT + 2 * MARGIN_HEIGHT) * mItemScale));
int column = (int) (x / ((ITEM_WIDTH + 2 * MARGIN_WIDTH) * mItemScale));
int index = row * ITEM_NUM + column;
if (index > getChildCount() - 1) {
return -1;
}
return index;
}
因為最初分析的時候已經說到了
一行的高度 = 元件的高度+2*marginheight
一列的寬度 = 元件的寬度+2*marginwidth
所以當我們得到觸控位置的x,y,就可以通過y/行高得到行數,x/列寬
當觸控位置沒有child時返回-1。
得到觸控座標後,獲得通過getChildAt()獲得觸控座標的child,通過clearAnimation停止抖動。
MOVE事件:
case MotionEvent.ACTION_MOVE:
if (mTouchIndex != -1 && mTouchChildView != null) {
moveTouchView(x, y);
//拖動過程中的View的index
int resultIndex = findChildIndex(x, y);
if (resultIndex != -1 && (resultIndex != mOldIndex)
&& ((Math.abs(x - mBeginX) > mItemScale * 2 * MARGIN_WIDTH)
|| (Math.abs(y - mBeginY) > mItemScale * 2 * MARGIN_HEIGHT))
) {
beginAnimation(Math.min(mOldIndex, resultIndex)
, Math.max(mOldIndex, resultIndex)
, mOldIndex < resultIndex);
mOldIndex = resultIndex;
mOnHover = true;
}
}
break;
首先根據move過程中的x,y,通過moveTouchView移動拖動的view隨手指移動。
private void moveTouchView(float x, float y) {
int left = (int) (x - mTouchChildView.getWidth() / 2);
int top = (int) (y - mTouchChildView.getHeight() / 2);
mTouchChildView.layout(left, top
, (left + mTouchChildView.getWidth())
, (top + mTouchChildView.getHeight()));
mTouchChildView.invalidate();
}
這裡有個細節,在移動的時候,將觸控的位置移動到大概child的中心位置,這樣看起來正常一下,也就是我對x和y分別減去了child寬高的一半,不然會使得手指觸控的位置一直在child的左上角(座標原點),看起來很變扭。最後通過layout和invalidate方法重繪child。
移動其他view
這個應該算是這個元件最難實現的地方,我在這上面花了最長的時間。
1)首先什麼時候執行位移動畫,反過來想就是什麼時候不執行位移動畫
這裡分了四種情況:
(1)拖動的位置沒有標籤,也就是圖上的從標籤9往右拖
(2)拖動的位置和上一次位置相同(也就是沒動)
(3)移動的位置不到一行的高度(也就是沒有脫離當前標籤的區域)
(4)移動的位置不到一列的寬度(也就是沒有脫離當前標籤的區域)
2)執行位移動畫,下面會分析
3)mOldIndex = resultIndex這裡是為了儲存上一次移動的座標位置
4)mOnHover=true,記錄拖動不放的情況(和拖動就釋放的情況有區分)
/**
* 移動動畫
*
* @param forward 拖動元件與經過的index的前後順序 touchindex < resultindex
* true-拖動的元件在經過的index前
* false-拖動的元件在經過的index後
*/
private void beginAnimation(int startIndex, int endIndex, final boolean forward) {
TranslateAnimation animation;
ViewHolder holder;
List<TranslateAnimation> animList = new ArrayList<>();
int startI = forward ? startIndex + 1 : startIndex;
int endI = forward ? endIndex + 1 : endIndex;//for迴圈用的是<,取不到最後一個
if (mOnHover) {//拖動沒有釋放情況
if (mTouchIndex > startIndex) {
if (mTouchIndex < endIndex) {
startI = startIndex;
endI = endIndex + 1;
} else {
startI = startIndex;
endI = endIndex;
}
} else {
startI = startIndex + 1;
endI = endIndex + 1;
}
}
//X軸的單位移動距離
final float moveX = (ITEM_WIDTH + 2 * MARGIN_WIDTH) * mItemScale;
//y軸的單位移動距離
final float moveY = (ITEM_HEIGHT + 2 * MARGIN_HEIGHT) * mItemScale;
//x軸移動方向
final int directX = forward ? -1 : 1;
final int directY = forward ? 1 : -1;
boolean isMoveY = false;
for (int i = startI; i < endI; i++) {
if (i == mTouchIndex) {
continue;
}
final View child = getChildAt(i);
holder = (ViewHolder) child.getTag();
child.clearAnimation();
if (i % ITEM_NUM == (ITEM_NUM - 1) && !forward
&& holder.row == i / ITEM_NUM && holder.column == i % ITEM_NUM) {
//下移
holder.row++;
isMoveY = true;
animation = new TranslateAnimation(0, directY * (ITEM_NUM - 1) * moveX, 0, directX * moveY);
} else if (i % ITEM_NUM == 0 && forward
&& holder.row == i / ITEM_NUM && holder.column == i % ITEM_NUM) {
//上移
holder.row--;
isMoveY = true;
animation = new TranslateAnimation(0, directY * (ITEM_NUM - 1) * moveX, 0, directX * moveY);
} else if (mOnHover && holder.row < i / ITEM_NUM) {
//onHover 下移
holder.row++;
isMoveY = true;
animation = new TranslateAnimation(0, -(ITEM_NUM - 1) * moveX, 0, moveY);
} else if (mOnHover && holder.row > i / ITEM_NUM) {
//onHover 上移
holder.row--;
isMoveY = true;
animation = new TranslateAnimation(0, (ITEM_NUM - 1) * moveX, 0, -moveY);
} else {//y軸不動,僅x軸移動
holder.column += directX;
isMoveY = false;
animation = new TranslateAnimation(0, directX * moveX, 0, 0);
}
animation.setDuration(mDuration);
animation.setFillAfter(true);
final boolean finalIsMoveY = isMoveY;
animation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
child.clearAnimation();
if (finalIsMoveY) {
child.offsetLeftAndRight((int) (directY * (ITEM_NUM - 1) * moveX));
child.offsetTopAndBottom((int) (directX * moveY));
} else {
child.offsetLeftAndRight((int) (directX * moveX));
}
}
@Override
public void onAnimationRepeat(Animation animation) {
}
});
child.setAnimation(animation);
animList.add(animation);
}
for (TranslateAnimation anim : animList) {
anim.startNow();
}
}
位移動畫,這段程式碼怎麼解釋哪…我寫的時候是發現一個bug改一種情況,最後實現了這段程式碼。
1)這裡首先確定開始位移的view的座標和結束位移的座標
這裡分為兩種情況:
case1:手指拖動後擡起(down->move->up);
case2:手指來回拖動不放(down->move->move)
case1:是常見情況,這裡我們就可以按照forward再分為兩種情況
case1.1:標籤0->標籤1(forward =true);
case1.2:標籤5->標籤1(forward=false)
case1.1:
標籤0移動到標籤1,標籤0隨手指移動,所以需要執行位移動畫的只有標籤1,所以startI = 1,endI = 2(for迴圈<,所以取不到最後一個),而startindex = 0,endindex = 1;
所以forward = true,startI = startIndex+1,endI=endIndex+1;
case1.2:
標籤4移動到標籤0,標籤4隨手指移動,所以需要執行位移動畫的是標籤0~標籤3,所以startI=0,endI=4,所以而startindex=0,endindex=5;
所以forward = false,startI = startIndex,endI = endIndex
case2:是指手指拖動不放,來回拖動,所以通過mOnHover=true引數來確定是否是拖動沒放情況,這裡面又要細分為三種情況
case2.1:標籤0->標籤2->標籤1,將標籤0拖動到2,再回到0的位置,這是標籤0一直隨手指移動,
後面這段動畫,startindex = 1,endindex = 2,touchindex = 0,只有標籤2需要執行動畫,標籤1不動,所以startI = 2,endI = 3
所以mOnHover = true,touchindex
if (i % ITEM_NUM == (ITEM_NUM - 1) && !forward
&& holder.row == i / ITEM_NUM && holder.column == i % ITEM_NUM) {
//下移
holder.row++;
isMoveY = true;
animation = new TranslateAnimation(0, directY * (ITEM_NUM - 1) * moveX, 0, directX * moveY);
} else if (i % ITEM_NUM == 0 && forward
&& holder.row == i / ITEM_NUM && holder.column == i % ITEM_NUM) {
//上移
holder.row--;
isMoveY = true;
animation = new TranslateAnimation(0, directY * (ITEM_NUM - 1) * moveX, 0, directX * moveY);
} else if (mOnHover && holder.row < i / ITEM_NUM) {
//onHover 下移
holder.row++;
isMoveY = true;
animation = new TranslateAnimation(0, -(ITEM_NUM - 1) * moveX, 0, moveY);
} else if (mOnHover && holder.row > i / ITEM_NUM) {
//onHover 上移
holder.row--;
isMoveY = true;
animation = new TranslateAnimation(0, (ITEM_NUM - 1) * moveX, 0, -moveY);
} else {//y軸不動,僅x軸移動
holder.column += directX;
isMoveY = false;
animation = new TranslateAnimation(0, directX * moveX, 0, 0);
}
case1:當是一行的最後一個,forward=false(後面的標籤往前擠),標籤的Tag中的x,y沒有變化(也就是第一次拖動和mOnHover=true區分),這時下移
case2:當是一行的第一個,forward=true(上面的標籤往下擠),標籤的Tag中的x,y沒有變化(也就是第一次拖動和mOnHover=true區分),這時上移
case3:當mOnHover=true,標籤當前所在行<標籤初始所在行,這時下移
case4:當mOnHover=true,標籤當前所在行>標籤初始所在行,這時上移
case5:X軸的平移,y軸不動
後面設定了child的動畫監聽,當動畫結束後,需要將child的實際位置設定為當前位置(因為這裡用的不是屬性動畫,所以執行動畫後child的實際位置並沒有變化,還是原始位置)
UP事件:
case MotionEvent.ACTION_UP:
setTouchIndex(x, y);
mOnHover = false;
mTouchIndex = -1;
mTouchChildView = null;
return true;
這裡主要看setTouchIndex事件
/**
* ---up事件觸發
* 設定拖動的View的位置
* @param x
* @param y
*/
private void setTouchIndex(float x,float y){
if(mTouchChildView!= null){
int resultIndex = findChildIndex(x, y);
Log.e("resultindex", "" + resultIndex);
if(resultIndex == mTouchIndex||resultIndex == -1){
refreshView(mTouchIndex);
}else{
swapView(mTouchIndex, resultIndex);
}
}
}
可以看到,這裡拖動結束後就需要將拖動位置變化的child實際改變它在ViewGroup中的位置
這裡有兩種情況
case1:拖動到最後,child的順序沒有改變,只有touchview小浮動的位置變化,這時只需要重新整理touchview即可
case2:將位置變換的child重新整理其在viewgroup中的順序。
/**
*重新整理View
* ------------------------------重要------------------------------
* 移除前需要先移除View的動畫效果,不然無法移除,可看原始碼
*/
private void refreshView(int index) {
//移除原來的View
getChildAt(index).clearAnimation();
removeViewAt(index);
//新增一個View
TextView tv = new TextView(mContext);
LayoutParams params = new ViewGroup.LayoutParams((int) (mItemScale * ITEM_WIDTH),
(int) (mItemScale * ITEM_HEIGHT));
tv.setText(mData.get(index));
tv.setTextColor(TEXT_COLOR);
tv.setBackgroundResource(ITEM_BACKGROUND);
tv.setGravity(Gravity.CENTER);
tv.setTextSize(TypedValue.COMPLEX_UNIT_PX,TEXT_SIZE);
tv.setTag(new ViewHolder(index / ITEM_NUM, index % ITEM_NUM));
this.addView(tv,index ,params);
tv.startAnimation(mSnake);
}
重新整理index的View,這裡有個需要注意的點,因為每個child都在執行抖動動畫,這時候直接removeViewAt是沒有辦法起效果的,需要先clearAnimation再執行,具體我已經寫了一篇部落格從原始碼分析了
Animation導致removeView無效(原始碼分析)
private void swapView(int fromIndex, int toIndex) {
if(fromIndex < toIndex){
mData.add(toIndex+1,mData.get(fromIndex));
mData.remove(fromIndex);
}else{
mData.add(toIndex,mData.get(fromIndex));
mData.remove(fromIndex+1);
}
for (int i = Math.min(fromIndex, toIndex); i <= Math.max(fromIndex, toIndex); i++) {
refreshView(i);
}
}
這裡交換touch和最終位置的child,所以首先實際改變Data資料集,再利用for迴圈,通過refreshView函式,重新整理位置變化的child。
主要程式碼已經分析完了,詳細Demo和原始碼這裡給出GitHub地址。
TabMoveLayout