开始之前,先贴一个GIF。
现在就来实现上面的效果。
因为本篇的重点在于子View之间的位置变换,所以中间的矩形部分就只贴出代码,不做解释了,代码上也有注释。
一、该ViewGroup的构造函数
public PositionLayout(Context context, AttributeSet attrs) {
super(context, attrs);
this.mContext = context;
mViewDragHelper = ViewDragHelper.create(this, 1.0f, mCallback);
initScreenSize();
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.MeetingRoomView);
//外矩形的位置 左上,右下
mLeft = typedArray.getFloat(R.styleable.MeetingRoomView_left, mScreenWidth * 2 / 10);
mTop = typedArray.getFloat(R.styleable.MeetingRoomView_top, mScreenHeight / 4);
mRight = typedArray.getFloat(R.styleable.MeetingRoomView_right, mScreenWidth * 8 / 10);
mBottom = typedArray.getFloat(R.styleable.MeetingRoomView_bottom, mScreenHeight * 3 / 5);
//内外矩形的间距,默认为100
mAlignInner = typedArray.getFloat(R.styleable.MeetingRoomView_align_inner_rect, 100);
//是否需要显示圆角矩形
mIsRoundRect = typedArray.getBoolean(R.styleable.MeetingRoomView_round_rect, false);
//圆角矩形的半径,默认值为50,该值在mIsRoundRect为true时,设置才有效
mRadius = typedArray.getFloat(R.styleable.MeetingRoomView_round_rect_radius, 50);
//两个矩形的背景色
mBgColor = typedArray.getColor(R.styleable.MeetingRoomView_background_color,
mContext.getResources().getColor(R.color.colorPrimary));
mInnerBgColor = typedArray.getColor(R.styleable.MeetingRoomView_inner_background_color,
mContext.getResources().getColor(R.color.colorWhite));
typedArray.recycle();
initPaint();
}
ViewDragHelper是一个用于拖动View时处理事件的帮助类,用法会在下面有涉及到。
private void initScreenSize() {//获取屏幕宽高
mScreenWidth = ScreenSize.getScreenWidth(mContext);
mScreenHeight = ScreenSize.getScreenHeight(mContext);
}
private void initPaint() {
//外矩形的画笔设置
mOutPaint = new Paint();
mOutPaint.setColor(mBgColor);
mOutPaint.setAntiAlias(true);
// mOutPaint.setStrokeWidth(20);//画笔宽度
mOutPaint.setStyle(Paint.Style.FILL);//画笔空心 FILL 为实心
//内矩形的画笔设置
mInPaint = new Paint();
mInPaint.setColor(mInnerBgColor);
mInPaint.setAntiAlias(true);
// mInPaint.setStrokeWidth(20);//画笔宽度
mInPaint.setStyle(Paint.Style.FILL);//画笔空心 FILL 为实心
//外矩形
mOutRectF = new RectF(mLeft, mTop, mRight, mBottom);
//内矩形
mInRectF = new RectF(mLeft + mAlignInner, mTop + mAlignInner, mRight - mAlignInner, mBottom - mAlignInner);
//ViewGroup 默认不调用onDraw函数,设置下面的方法让它调用
this.setWillNotDraw(false);
}
用于画矩形部分的画笔设置。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (mIsRoundRect) {//需要圆角矩形
canvas.drawRoundRect(mOutRectF, mRadius, mRadius, mOutPaint);
canvas.drawRoundRect(mInRectF, mRadius, mRadius, mInPaint);
} else {
canvas.drawRect(mOutRectF, mOutPaint);
canvas.drawRect(mInRectF, mInPaint);
}
}
画矩形,这部分都是些比较基本的,就不详细讲了。
二、接下来就是ViewGroup每个子View的绘制与布局。
画所有的子View只需要看两部分就可以了,左右两个单个的View为一部分,上下两排子View为一部分。
1、左右单个的部分,先看一张位置的分析图
cl,ct,cr,cb 对应的是这个View的左上右下的点位,上面的图都能清晰地解释每个点的位置。
其中有个20的值,是为了让子View跟矩形保持一定的距离,不让整个布局显得很拥挤,当然,这个值可以根据自己的看的最舒服的大小来设置的。
2、上下两排View的布局方式,同样看一下分析图。
其实只需要看一排的位置设置就能相应推出另一排的位置。
在这里,设置好每排只放四个View,然后根据这四个View来平均分配矩形的宽就可以了。
先将矩形的宽分成八等分,因为只显示四个,所以每个View的中心的X坐标分别就是 1/8,3/8,5/8,7/8。
分好之后就可以计算出每个View的cl,跟cr了。
计算公式看上面的分析图就可以很清楚地理解了。
每个子View的位置确定,其实在分析图中应该说明地很清晰了,所以没有再进行更详细的解释,如果还有不懂的可以留个言,我会再进行解释的。
再把布局的代码贴出来:
//作为用户位置占矩形宽度的 ?/? 的标识,每次自增 2
private int up = 1;
private int down = 1;
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (!changed)
return;
int childCount = getChildCount();
float widthAll = mRight - mLeft;//外矩形的宽
float heightAll = mBottom - mTop;//外矩形的高
for (int i = 0; i < childCount; i++) {//遍历子View,进行位置的确认
int measuredWidth = getChildAt(i).getMeasuredWidth();
int measuredHeight = getChildAt(i).getMeasuredHeight();
//子View的位置,cl(左),ct(上),cr(右),cb(下)
int cl, ct, cr, cb;
if (i == 0) {
cl = (int) (mLeft - measuredWidth - 20);
ct = (int) (mBottom - heightAll / 2 - measuredHeight / 2);
cr = (int) (mLeft - 20);
cb = (int) (mBottom - heightAll / 2 + measuredHeight / 2);
getChildAt(i).layout(cl, ct, cr, cb);
//将位置与对应的View绑定
mChildViewRect = new ChildViewRect(cl, ct, cr, cb);
childViewMap.put(mChildViewRect, getChildAt(i));
} else if (i == 9) {
cl = (int) mRight + 20;
ct = (int) (mBottom - heightAll / 2 - measuredHeight / 2);
cr = (int) mRight + 20 + measuredWidth;
cb = (int) (mBottom - heightAll / 2 + measuredHeight / 2);
getChildAt(i).layout(cl, ct, cr, cb);
//将位置与对应的View绑定
mChildViewRect = new ChildViewRect(cl, ct, cr, cb);
childViewMap.put(mChildViewRect, getChildAt(i));
} else {
if (i % 2 == 0) {
cl = (int) (widthAll * down / 8 - measuredWidth / 2);
ct = (int) (mBottom + 20);
cr = (int) (widthAll * down / 8 + measuredWidth / 2);
cb = (int) (mBottom + measuredHeight + 20);
down += 2;
} else {
cl = (int) (widthAll * up / 8 - measuredWidth / 2);
ct = (int) (mTop - measuredHeight - 20);
cr = (int) (widthAll * up / 8 + measuredWidth / 2);
cb = (int) (mTop - 20);
up += 2;
}
getChildAt(i).layout(cl + (int) mLeft, ct, cr + (int) mLeft, cb);
//将位置与对应的View绑定
//这里的左跟右的位置一定要加上 mLeft
//在做的过程中就是因为没加,花了很多时间排查位置对换的错乱问题
mChildViewRect = new ChildViewRect(cl + (int) mLeft, ct, cr + (int) mLeft, cb);
childViewMap.put(mChildViewRect, getChildAt(i));
}
}
}
这就是子View位置的确定与绘制,其中像下面的代码:
//将位置与对应的View绑定
mChildViewRect = new ChildViewRect(cl, ct, cr, cb);
childViewMap.put(mChildViewRect, getChildAt(i));
这部分可以先不看,这是为了切换子View位置需要用到的一些变量。
在这个自定义ViewGroup中,只让它显示10个View,所以布局的时候可以判断当前是第几个View(比如:i==0,i==9,分别是第一个和最后一个)来进行位置确定。
布局确定之后,就可以为当前类添加子View的方法:
/**
* 为ViewGroup添加子View
* @param users 需要添加到ViewGroup的所有View的模型集合,
* UserModel只有viewName,viewRole两个字段,
* 一个保存名字,一个作为每个View显示哪张图片的标识。
* @param currentName 设置哪个名字的子View需要显示明显一点。
* @param onUserClickListener 每个子View的点击回调接口。
*/
public void addUsers(List<UserModel> users, String currentName, final OnUserClickListener onUserClickListener) {
this.removeAllViews();
for (int i = 0; i < users.size(); i++) {
mName = (TextView) View.inflate(this.getContext(), R.layout.text_view, null);
users.get(i).setPicForUser(mContext, mName);//为每个View设置相对应的图片
String userName = users.get(i).getViewName();
mName.setText(userName);
final int finalI = i;
mName.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
if (onUserClickListener != null)
onUserClickListener.onUserClick(v, finalI);
}
});
if (userName.equals(currentName)) {
users.get(i).setCurrentDelegate(mContext, mName);//根据传入的名字,让该名字对应的View显示高亮
}
this.addView(mName);
}
//通知ViewGroup绘制
postInvalidate();
}
到这里,就能让所有的子View显示出来了。
三、子View的切换及位置变换
1、首先应该是怎么让每个子View能够拖动,在上面有提到一个帮助类的ViewDragHelper,这个帮助类是在V4包下的,有了这个帮助类,就能帮开发者省去拖动View的那些处理逻辑。
在实例化ViewDragHelper之后,需要重写ViewGroup的onInterceptTouchEvent和onTouchEvent方法,让ViewDragHelper来处理。
实例化操作在构造函数中已经实现了
mViewDragHelper = ViewDragHelper.create(this, 1.0f, mCallback);
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return mViewDragHelper.shouldInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mViewDragHelper.processTouchEvent(event);
return true;
}
主要的处理逻辑在ViewDragHelper.callback中。在看代码之前,先说一下我关于切换子View位置的思路。
1、首先,要让两个View的位置进行变换,得知道这两个View的具体位置(也就是View的左上右下),然后再让这个位置与在这个位置上的View进行一一绑定起来。
开始拖动View,如果释放这个View的时候,拖动的View有覆盖另一个View的情况出现,就让这两个View的位置信息相互交换,让拖动的View刚开始的位置与被覆盖的View绑定,被覆盖的View的位置与拖动的View绑定,这样就完成了两个View的位置变换。
但是没有一个类是能存View的位置的,所以得先定义一个类,用来保存View的位置,创建类ChildViewRect
public class ChildViewRect {
private int left;
private int top;
private int right;
private int bottom;
还有一些构造函数,setter和getter方法,就不贴出来了。
还记得在上面的onLayout方法中有一些代码像:
//将位置与对应的View绑定
mChildViewRect = new ChildViewRect(cl, ct, cr, cb);
childViewMap.put(mChildViewRect, getChildAt(i));
这个就是用来将View的位置与View绑定的操作。
childViewMap就是保存一一绑定的集合,其实就是一个HashMap,位置类作为键,View作为值。
//保存所有的View,绑定位置与该位置对应的View
private Map<ChildViewRect, View> childViewMap = new HashMap<>();
这样,在布局完成的同时,就已经将每个View的位置与View都一一绑定起来了。
代码时间:
private ViewDragHelper.Callback mCallback = new ViewDragHelper.Callback() {
//手指拖动的那个View 的坐标位置
private ChildViewRect mMoveViewRect;
/**
* 手指按住的那个View
* @param child View
* @param pointerId
* @return
*/
@Override
public boolean tryCaptureView(View child, int pointerId) {
mMoveViewRect = new ChildViewRect(child.getLeft(), child.getTop(), child.getRight(), child.getBottom());
Set<ChildViewRect> childViewRects = childViewMap.keySet();
Iterator<ChildViewRect> iterator = childViewRects.iterator();
while (iterator.hasNext()) {
ChildViewRect childViewRect = iterator.next();
if (childViewRect.getLeft() == mMoveViewRect.getLeft()
&& childViewRect.getBottom() == mMoveViewRect.getBottom()
&& childViewRect.getRight() == mMoveViewRect.getRight()
&& childViewRect.getTop() == mMoveViewRect.getTop()) {
//拖动时先把View从childViewMap中移除掉,没有替换的话会在还原位置的时候重新添加到childViewMap
childViewMap.remove(childViewRect);
break;
}
}
return true;
}
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
//不让控件拖出屏幕,横向
final int leftBound = getPaddingLeft();
final int rightBound = getWidth() - mName.getWidth() - leftBound;
final int newLeft = Math.min(Math.max(left, leftBound), rightBound);
return newLeft;
}
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
//不让控件拖出屏幕,纵向
final int topBound = getPaddingTop();
final int bottomBound = getHeight() - mName.getHeight() - topBound;
final int newTop = Math.min(Math.max(top, topBound), bottomBound);
return newTop;
}
//手指释放的时候回调
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
int top = releasedChild.getTop();
int left = releasedChild.getLeft();
int right = releasedChild.getRight();
int bottom = releasedChild.getBottom();
Set<ChildViewRect> childViewRects = childViewMap.keySet();//获取所有的键
Iterator<ChildViewRect> iterator = childViewRects.iterator();
//是否有View被覆盖
boolean isReplace = false;
View beReplaceView = null;
ChildViewRect beReplaceChildViewRect = null;
while (iterator.hasNext()) {//循环遍历键,取得每个位置类,跟拖动的View的当前位置进行对比,是否有覆盖
ChildViewRect childViewRect = iterator.next();
boolean temp1 = bottom >= childViewRect.getTop();
boolean temp2 = top <= childViewRect.getBottom();
boolean temp3 = right >= childViewRect.getLeft();
boolean temp4 = left <= childViewRect.getRight();
//如果拖动的那个View覆盖在其中一个子View上
if (temp1 && temp2 && temp3 && temp4) {
beReplaceView = childViewMap.get(childViewRect);
beReplaceChildViewRect = childViewRect;
//将这个被替换的位置移除出childViewMap
childViewMap.remove(childViewRect);
//然后将被拖动的View的位置与被覆盖View绑定,让childViewMap的对应关系跟View的个数一致
childViewMap.put(mMoveViewRect, beReplaceView);
isReplace = true;
break;
}
}
if (isReplace) {//交换两个View的位置
beReplaceView.layout(mMoveViewRect.getLeft(), mMoveViewRect.getTop(), mMoveViewRect.getRight(), mMoveViewRect.getBottom());
releasedChild.layout(beReplaceChildViewRect.getLeft(), beReplaceChildViewRect.getTop(),
beReplaceChildViewRect.getRight(), beReplaceChildViewRect.getBottom());
//将被覆盖的View的位置与拖动的View绑定
childViewMap.put(beReplaceChildViewRect, releasedChild);
} else {
//如果拖动的View释放后,没有覆盖任何一个View,则返回到之前的位置,并且从新绑定位置
releasedChild.layout(mMoveViewRect.getLeft(), mMoveViewRect.getTop(), mMoveViewRect.getRight(), mMoveViewRect.getBottom());
childViewMap.put(mMoveViewRect, releasedChild);
}
}
//在边界拖动时回调
@Override
public void onEdgeDragStarted(int edgeFlags, int pointerId) {
}
@Override
public int getViewHorizontalDragRange(View child) {
return getMeasuredWidth() - child.getMeasuredWidth();
}
@Override
public int getViewVerticalDragRange(View child) {
return getMeasuredHeight() - child.getMeasuredHeight();
}
//拖动View的同时,只要坐标一直在变化,就会一直调用这个方法
@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
super.onViewPositionChanged(changedView, left, top, dx, dy);
}
};
这里我们需要重点关注
tryCaptureView 和
onViewReleased 这两个方法。
一个是按住View开始拖动时就会调用,一个是释放拖动的View时会调用。
在拖动开始时,把正在拖动的View位置跟View的绑定关系先解除掉,在释放View时如果有其他View被覆盖了,就让拖动的View的起始位置与被覆盖的View绑定,而被覆盖的View的位置与拖动的View绑定,如果释放时并没有其他的View被覆盖,则重新将拖动的View的起始位置跟拖动的View再重新绑定。保持childViewMap的大小跟View的个数一致。
那么,还需要判断什么情况下,一个View会是覆盖另一个View,其实也很简单,来看看分析图:
被拖动的View的下边界 >= 被覆盖的View的上边界,拖动的View的上边界 <= 被覆盖的View的下边界,
被拖动的View的右边界 >= 被覆盖的View的左边界,拖动的View的左边界 <= 被覆盖的View的右边界,
只要这四种情况同时成立,那么就证明一个View已经覆盖另一个View了。
以上,子View可以自由切换位置的自定义ViewGroup就完成了,接下来就是使用这个控件了。
四、使用该自定义ViewGroup
在MAinActivity的布局文件中直接引用就行了。
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="xiedroid.com.dragviewgroup.MainActivity">
<xiedroid.com.dragviewgroup.PositionLayout
android:id="@+id/id_room"
app:round_rect="true"
android:layout_width="match_parent"
android:layout_height="match_parent">
</xiedroid.com.dragviewgroup.PositionLayout>
</RelativeLayout>
app:round_rect="true" 让矩形显示为圆角矩形
public class MainActivity extends AppCompatActivity {
private PositionLayout mPositionLayout;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
final List<UserModel> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
UserModel userModel = new UserModel();
userModel.setViewName("View" + i);
if (i % 2 == 0) {
userModel.setViewRole(1);
} else {
userModel.setViewRole(2);
}
list.add(userModel);
}
mPositionLayout = (PositionLayout) findViewById(R.id.id_room);
mPositionLayout.addUsers(list, "View5", new OnUserClickListener() {
@Override
public void onUserClick(View v, int position) {//每个子View的点击事件
Toast.makeText(MainActivity.this, ""+list.get(position).getViewName(), Toast.LENGTH_SHORT).show();
}
});
}
}
其他一些图片的就不贴出来了。
运行程序,就能显示如最开始那样的效果了。