网易新闻标签栏的实现效果我一直想实现试试,最近发现支付宝的应用栏也变成了这样,最近花了点时间终于实现,初步实现效果如下,后面有时间还会继续完善
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进行添加和删除,中间遇到一点问题(博客)已分析。
这里主要是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)*一个比例所占的长度
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
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()布局。
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停止抖动。
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。
这个应该算是这个组件最难实现的地方,我在这上面花了最长的时间。
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 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的实际位置并没有变化,还是原始位置)
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