Leo's Studio.

可拖拽的网格布局

字数统计: 1.6k阅读时长: 6 min
2018/07/19

效果

效果图

背景

在之前的项目中为了实现类似微信朋友圈的图片九宫格效果,手写了一个简单的网格控件,继承于 ViewGroup,后面因为准备做小组的技术分享,关于自定义控件的,所以就想把这个控件优化下,来作为这次分享的例子。自定义控件一般涉及测量、布局、绘制三大流程,再加上触摸事件的处理。之前的已完成的部分,已经包括了测量和布局,绘制暂时没有好想法,所以这次就想再加上触摸事件的处理。在使用微信发布朋友圈的时候,看到可以通过拖拽来重新排序图片,所以这次就加上可拖拽的功能。

思路

因为网格控件是位置是比较固定的,所以可以通过实时计算每个 Item 的位置,这里我们为了方便,所以缓存了每个 Item 的位置。当我们拖拽某个 Item 靠近新的位置时,如果新位置大于原来的位置,我们将原来位置到新位置的 Item 向后移动一位,反之,则向前移动一位。不知道这样解释是否清晰。

实现

源码

首先我们要保存初始布局时的位置信息,这里我们使用 SparseArray<Point> 去缓存,相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int childTop = getPaddingTop();                                                                
int childLeft = getPaddingLeft();

for (int i = 0; i < getChildCount(); i++) {
final View childView = getChildAt(i);
Point point = mLayoutPositionArray.get(i);

if(point == null){
point = acquireTempPoint();
}

point.x = childLeft;
point.y = childTop;
mLayoutPositionArray.put(i, point);

childView.layout(childLeft, childTop, childLeft + itemWidth, childTop + itemHeight);

if (mGridAdapter != null) {
mGridAdapter.handleItemView(childView, i);
}
childLeft += itemWidth + mHGap;
if ((i + 1) % mColumnCount == 0) {
childTop += itemHeight + mVGap;
childLeft = getPaddingLeft();
}
}

接下来是 Item 的拖拽移动处理,同样的我们可以通过手动处理 onTouchEvent()onInterceptTouchEvent() 去实现,为了节约时间,我们使用 ViewDragHelper 去实现,关于 ViewDragHelper 的使用,这里我们就不去细讲,可以参考这篇博客,关于触发拖动操作,这里我们使用长按开始拖动,同样的,长按等手势判定,我们交给 GestureDetector 去处理,下面是两个类的集成代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Override                                                                   
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept = mDragHelper.shouldInterceptTouchEvent(ev);
if (mDragHelper.getViewDragState() == ViewDragHelper.STATE_DRAGGING) {
if (getParent() != null) {
// 这里需要禁止父视图拦截事件,防止父视图为滚动控件,发生滑动冲突
getParent().requestDisallowInterceptTouchEvent(true);
}
}
return intercept;
}

@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent event) {
mDragHelper.processTouchEvent(event);
if (mDragHelper.getViewDragState() == ViewDragHelper.STATE_DRAGGING) {
if (getParent() != null) {
// 这里需要禁止父视图拦截事件,防止父视图为滚动控件,发生滑动冲突
getParent().requestDisallowInterceptTouchEvent(true);
}
}
// 手势判断
mGestureDetector.onTouchEvent(event);
return true;
}

这时候,我们的长按开始拖拽已经实现了,接下来,当我们拖拽时,如何让目标位置的子视图移动出位置呢?在上面,我们已经提到了思路,即将部分视图进行整体移动去实现。我们通过代码来看下实现思路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Override                                                                                                                                          
public void onViewPositionChanged(@NonNull View changedView, int left, int top, int dx, int dy) {
// 当子视图拖拽移动时,ViewDragHelper 会回调这个方法返回新的位置
super.onViewPositionChanged(changedView, left, top, dx, dy);
Log.d(TAG,"dx: " + dx + ",dy: " + dy);
// findFirstContactView 拖拽到新的目标位置
int targetPosition = findFirstContactView(left, top, left + changedView.getWidth(), top + changedView.getHeight(), changedView);
int sourcePosition = indexOfChild(changedView);
if (targetPosition == -1 || sourcePosition == targetPosition) {
if (targetPosition == -1) {
Log.d(TAG, "targetPosition == -1");
}
if (sourcePosition == targetPosition) {
Log.w(TAG, "sourcePosition == targetPosition");
}
return;
}
if (mAnimatorCountDown.get() > 0) {
// 上一次整体移动的动画还没结束
return;
}
Log.d(TAG, "sourcePositon: " + sourcePosition + " ,targetPosition: " + targetPosition);
// 移动位置
if (targetPosition < sourcePosition && (dx < 0 || dy < 0)) {
// 当前目标位置小于拖拽视图的原位置,同时手势为向左或者向上
mTargetPosition = targetPosition;
for (int i = targetPosition; i < sourcePosition; i++) {
moveAnimation(getChildAt(i), mLayoutPositionArray.get(i + 1), i + 1, null);
}
} else if (targetPosition > sourcePosition && (dx > 0 || dy > 0)) {
// 当前目标位置大于拖拽视图的原位置,同时手势为向右或者向下
mTargetPosition = targetPosition;
for (int i = sourcePosition + 1; i < (targetPosition + 1); i++) {
moveAnimation(getChildAt(i), mLayoutPositionArray.get(i - 1), i - 1, null);
}
}
}

通过上面的代码和注释,实现的思路应该很清晰了。接下来,我们再看下 findFirstContactView 这个方法,这是获取拖拽视图移动目标位置,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private int findFirstContactView(int left, int top, int right, int bottom, View excludeView) {                          
final int childCount = getChildCount();
for (int i = childCount - 1; i >= 0; i--) {
// 这里我们遍历子视图
final View child = getChildAt(i);
if (child == excludeView) {
continue;
}
if (left < child.getRight() && child.getLeft() < right && top < child.getBottom() && child.getTop() < bottom) {
// 是否存在相交的子视图
int calculateLeft = left;
int calculateRight = right;
int caluculateTop = top;
int caluculateBottom = bottom;
if (child.getLeft() > calculateLeft){
calculateLeft = child.getLeft();
}
if (child.getRight() < calculateRight){
calculateRight = child.getRight();
}
if (child.getTop() > caluculateTop){
caluculateTop = child.getTop();
}
if (child.getBottom() < caluculateBottom){
caluculateBottom = child.getBottom();
}
int unionArea = (calculateRight - calculateLeft) * (caluculateBottom - caluculateTop);
if (unionArea > (child.getWidth() * child.getHeight() * 0.5f)){
// 这里我们再通过 相交面积是否大于视图面积的一半 去判定
// 这是为了不那么敏感
return i;
}
}
}
return -1;
}

经过上面的步骤,我们已经实现了百分之八十的代码,这时,我们还需要考虑几个问题。

  • 位置交换后,我们通过 indexOfChildgetChildAt 得到的数据还是原来的,即我们通过调用 addView 添加的顺序。这里我们自己去缓存一个子视图列表,重写上面两个方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Override                             
    public int indexOfChild(View child) {
    return mChildViews.indexOf(child);
    }

    @Override
    public View getChildAt(int index) {
    return mChildViews.get(index);
    }

    同时在视图移动时,去修改这个列表:

    1
    mChildViews.set(targetPosition, targetView); 
  • 绘制顺序问题,ViewGroup 的默认绘制顺序是根据 addView 的调用顺序,这样会带来一个问题,当你拖拽到比当前拖拽视图更晚添加的子视图时,会显示在这个视图下面,同样我们可以通过 setChildrenDrawingOrderEnabled

    getChildDrawingOrder 去自定义绘制顺序,这里为了方便,我们通过设置拖拽视图的 Z 值来实现同样的效果:

    1
    2
    ViewCompat.setZ(capturedChild, 100f);     
    postInvalidate();

最后我们顺便把 Item 的点击事件实现下。。。

1
2
3
4
5
6
7
8
9
10
11
@Override                                                                                 
public boolean onSingleTapConfirmed(MotionEvent e) {
final View targetView = mDragHelper.findTopChildUnder((int) e.getX(), (int) e.getY());
if (targetView == null) {
return performClick();
} else if (mGridItemClickListener != null) {
mGridItemClickListener.onClickItem(indexOfChild(targetView), targetView);
return true;
}
return false;
}
CATALOG
  1. 1. 效果
  2. 2. 背景
  3. 3. 思路
  4. 4. 实现