網(wǎng)易新聞標(biāo)簽欄的實現(xiàn)效果我一直想實現(xiàn)試試,最近花了點時間終于實現(xiàn),初步實現(xiàn)效果如下,后面有時間還會繼續(xù)完善(最近突然發(fā)現(xiàn)其實可以通過RecyclerView實現(xiàn),但我這種實現(xiàn)方式也算是一種學(xué)習(xí)和練習(xí)吧)
詳細Demo和源碼這里給出GitHub地址。
TabMoveLayout
實現(xiàn)功能
1.長按抖動
2.標(biāo)簽可隨意拖動,其他標(biāo)簽隨之變換位置
3.拖動變換子View順序
難點:
1.熟悉自定義ViewGroup過程,onMeasure、onLayout
2.ViewGroup事件處理
3.多種拖動情況考慮(位置移動計算)
4.ViewGroup中子View的變更替換添加
實現(xiàn)思路:
1.自定義ViewGroup,實現(xiàn)標(biāo)簽欄的排列,這里我以4列為例(onMeasure,onLayout)
2.實現(xiàn)觸摸標(biāo)簽的拖動,通過onTouch事件,在DOWN:獲取觸摸的x,y坐標(biāo),找到被觸摸的View,在MOVE:通過view.layout()方法動態(tài)改變View的位置
3.其他標(biāo)簽的位置變換,主要通過TranslateAnimation,在MOVE:找到拖動過程中經(jīng)過的View,并執(zhí)行相應(yīng)的Animation
(這里重點要考慮清楚所有拖動可能的情況)
4.拖動結(jié)束后,隨之變換ViewGroup中view的實際位置,通過removeViewAt和addView進行添加和刪除,中間遇到一點問題(博客)已分析。
關(guān)鍵代碼:
1.自定義ViewGroup
這里主要是onMeasure和onLayout方法。這里我要說一下我的布局方式
/**
* 標(biāo)簽個數(shù) 4
* |Magin|View|Magin|Magin|View|Magin|Magin|View|Magin|Magin|View|Magin|
* 總寬度:4*(標(biāo)簽寬度+2*margin) 按照比例 (總份數(shù)):4*(ITEM_WIDTH+2*MARGIN_WIDTH)
* 則一個比例占的寬度為:組件總寬度/總份數(shù)
* 一個標(biāo)簽的寬度為:組件寬度/總份數(shù) * ITEM_WIDTH(寬度占的比例)
* 一個標(biāo)簽的MARGIN為:組件寬度/總份數(shù) * MARGIN_WIDTH(MARGIN占的比例)
* 行高=(ITEN_HEIGHT+2*MARGIN_HEIGHT)*mItemScale
* 一個組件占的寬度=(ITEM_WIDTH + 2*MARGIN_WIDTH)*mItemScale
*/
可能看起來比較復(fù)雜,其實理解起來就是:
一個標(biāo)簽所占的寬度=標(biāo)簽的寬度+2marginwidth
一個標(biāo)簽所占的高度=標(biāo)簽的高度+2marginheight
這里都是用的權(quán)值計算的
一個比例占的長度為=總寬度/總份數(shù)
假如屏幕寬度為1000px,標(biāo)簽的寬度占10份,marginwidth占2份,標(biāo)簽的高度占5份,marginheight占1份
一個比例所占的長度(以一行4個標(biāo)簽為例) = 1000/((10+22)4)
一個標(biāo)簽所占的寬度 = (10+22)一個比例所占的長度
一個標(biāo)簽所占的高度 = (5+21)一個比例所占的長度
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,三種模式的對應(yīng)關(guān)系可以簡單理解為:
EXACITY -> MATCH_PARENT或者具體值
AT_MOST -> WARP_CONTENT
UNSPECIFIED是未指定尺寸,這種情況不多,一般都是父控件是AdapterView,通過measure方法傳入的模式。
所以這里我處理方式為
寬度:當(dāng)EXACITY時:width = widthsize,當(dāng)其他模式時,width=sizewidth和屏幕寬度的較小值(這里注意sizeWidth的值為父組件傳給自己的寬度值,所以如果當(dāng)前組件處于第一層級,sizeWidth=屏幕寬度)
高度:當(dāng)EXACITY時:height = heightsize,當(dāng)其他模式時,計算行數(shù),height=行數(shù)一行的高度(height+2marginheight)
再執(zhí)行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循環(huán)遍歷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坐標(biāo),通過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;
}
因為最初分析的時候已經(jīng)說到了
一行的高度 = 組件的高度+2marginheight
一列的寬度 = 組件的寬度+2marginwidth
所以當(dāng)我們得到觸摸位置的x,y,就可以通過y/行高得到行數(shù),x/列寬
當(dāng)觸摸位置沒有child時返回-1。
得到觸摸坐標(biāo)后,獲得通過getChildAt()獲得觸摸坐標(biāo)的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;
首先根據(jù)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();
}
這里有個細節(jié),在移動的時候,將觸摸的位置移動到大概child的中心位置,這樣看起來正常一下,也就是我對x和y分別減去了child寬高的一半,不然會使得手指觸摸的位置一直在child的左上角(坐標(biāo)原點),看起來很變扭。最后通過layout和invalidate方法重繪child。
移動其他view
這個應(yīng)該算是這個組件最難實現(xiàn)的地方,我在這上面花了最長的時間。
1)首先什么時候執(zhí)行位移動畫,反過來想就是什么時候不執(zhí)行位移動畫
這里分了四種情況:
(1)拖動的位置沒有標(biāo)簽,也就是圖上的從標(biāo)簽9往右拖
(2)拖動的位置和上一次位置相同(也就是沒動)
(3)移動的位置不到一行的高度(也就是沒有脫離當(dāng)前標(biāo)簽的區(qū)域)
(4)移動的位置不到一列的寬度(也就是沒有脫離當(dāng)前標(biāo)簽的區(qū)域)
2)執(zhí)行位移動畫,下面會分析
3)mOldIndex = resultIndex這里是為了保存上一次移動的坐標(biāo)位置
4)mOnHover=true,記錄拖動不放的情況(和拖動就釋放的情況有區(qū)分)
/**
* 移動動畫
*
* @param forward 拖動組件與經(jīng)過的index的前后順序 touchindex < resultindex
* true-拖動的組件在經(jīng)過的index前
* false-拖動的組件在經(jīng)過的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循環(huán)用的是<,取不到最后一個
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();
}
}
位移動畫,這段代碼怎么解釋哪...我寫的時候是發(fā)現(xiàn)一個bug改一種情況,最后實現(xiàn)了這段代碼。
1)這里首先確定開始位移的view的坐標(biāo)和結(jié)束位移的坐標(biāo)
這里分為兩種情況:
case1:手指拖動后抬起(down->move->up);
case2:手指來回拖動不放(down->move->move)
case1:是常見情況,這里我們就可以按照forward再分為兩種情況
case1.1:標(biāo)簽0->標(biāo)簽1(forward =true);
case1.2:標(biāo)簽5->標(biāo)簽1(forward=false)
case1.1:
標(biāo)簽0移動到標(biāo)簽1,標(biāo)簽0隨手指移動,所以需要執(zhí)行位移動畫的只有標(biāo)簽1,所以startI = 1,endI = 2(for循環(huán)<,所以取不到最后一個),而startindex = 0,endindex = 1;
所以forward = true,startI = startIndex+1,endI=endIndex+1;
case1.2:
標(biāo)簽4移動到標(biāo)簽0,標(biāo)簽4隨手指移動,所以需要執(zhí)行位移動畫的是標(biāo)簽0~標(biāo)簽3,所以startI=0,endI=4,所以而startindex=0,endindex=5;
所以forward = false,startI = startIndex,endI = endIndex
case2:是指手指拖動不放,來回拖動,所以通過mOnHover=true參數(shù)來確定是否是拖動沒放情況,這里面又要細分為三種情況
case2.1:標(biāo)簽0->標(biāo)簽2->標(biāo)簽1,將標(biāo)簽0拖動到2,再回到0的位置,這是標(biāo)簽0一直隨手指移動,
后面這段動畫,startindex = 1,endindex = 2,touchindex = 0,只有標(biāo)簽2需要執(zhí)行動畫,標(biāo)簽1不動,所以startI = 2,endI = 3
所以mOnHover = true,touchindex<starindex,startI = startIndex + 1;endI = endindex+1;
case2.2:標(biāo)簽8->標(biāo)簽4->標(biāo)簽5,將標(biāo)簽8拖到4,在拖到5的位置,后面這段動畫,startindex = 4,endindex = 5,touchindex = 8,只有標(biāo)簽4需要執(zhí)行動畫,其他標(biāo)簽不動,所以startI=4,endI=5
所以mOnHover = true,startIndex<endIndex<touchindex,startI=startIndex,endI=endIndex
case2.3:標(biāo)簽8->標(biāo)簽4->標(biāo)簽5->標(biāo)簽9,后面這段動畫,startindex = 5,endindex = 9,touchindex = 8,執(zhí)行動畫的有標(biāo)簽5~標(biāo)簽9,所以startI=5,endI=10
所以mOnHover=true.startIndex<touchindex<endIndex,startI=startIndex,endI=endIndex+1
接下來就是for循環(huán)的移動動畫,可以看到這里可以大致分為三種情況
case1:X軸的平移動畫,Y軸不動;
case2:Y軸上移一行,X軸左移三個(也就是一行的第一個上移到上一行的最后一個);
case3:Y軸下移一行,X軸右移三個(也就是一行的最后一個下移到下一行的第一個);
可以看到我還是總體分為了mOnHover=false和mOnHover=true兩種情況,我在初始化時,將每個child的所在行和列以Tag的形式保存到了child中
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:當(dāng)是一行的最后一個,forward=false(后面的標(biāo)簽往前擠),標(biāo)簽的Tag中的x,y沒有變化(也就是第一次拖動和mOnHover=true區(qū)分),這時下移
case2:當(dāng)是一行的第一個,forward=true(上面的標(biāo)簽往下擠),標(biāo)簽的Tag中的x,y沒有變化(也就是第一次拖動和mOnHover=true區(qū)分),這時上移
case3:當(dāng)mOnHover=true,標(biāo)簽當(dāng)前所在行<標(biāo)簽初始所在行,這時下移
case4:當(dāng)mOnHover=true,標(biāo)簽當(dāng)前所在行>標(biāo)簽初始所在行,這時上移
case5:X軸的平移,y軸不動
后面設(shè)置了child的動畫監(jiān)聽,當(dāng)動畫結(jié)束后,需要將child的實際位置設(shè)置為當(dāng)前位置(因為這里用的不是屬性動畫,所以執(zhí)行動畫后child的實際位置并沒有變化,還是原始位置)
UP事件:
case MotionEvent.ACTION_UP:
setTouchIndex(x, y);
mOnHover = false;
mTouchIndex = -1;
mTouchChildView = null;
return true;
這里主要看setTouchIndex事件
/**
* ---up事件觸發(fā)
* 設(shè)置拖動的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);
}
}
}
可以看到,這里拖動結(jié)束后就需要將拖動位置變化的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都在執(zhí)行抖動動畫,這時候直接removeViewAt是沒有辦法起效果的,需要先clearAnimation再執(zhí)行,具體我已經(jīng)寫了一篇博客從源碼分析了
Animation導(dǎo)致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數(shù)據(jù)集,再利用for循環(huán),通過refreshView函數(shù),刷新位置變化的child。
實現(xiàn)過程比較坎坷,但也是一種實現(xiàn)思路供大家參考吧,寫完后對于自定義ViewGroup和動畫能有很好的學(xué)習(xí),這里再放一下Github地址TabMoveLayout,喜歡的可以star一下~~~