仿网易新闻标签选择器(可拖动)-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的indexint 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的indexint 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 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() {@Overridepublic void onAnimationStart(Animation animation) {}@Overridepublic 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));}}@Overridepublic 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) {//移除原来的ViewgetChildAt(index).clearAnimation();removeViewAt(index);//添加一个ViewTextView 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


本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!

相关文章

立即
投稿

微信公众账号

微信扫一扫加关注

返回
顶部