LinearLayout 的拖放操作和动画

前言

Android 中ListView 的拖放操作和动画实现已经被 这个 DevByte相关的样例 说明,并且也有 ListViewAnimations 这样强大的开源库进行了集成。但是,一番 Google 后,我发现基于 LinearLayout 的相关实现却不多。

然而,有时我们可能需要使用 LinearLayout 替代 ListView 来实现列表,例如不需要 ListView 的视图回收机制(比如使用 Fragment 作为列表项),或者我们需要把这个视图放在 ScrollView 中。

在使用 LinearLayout 实现拖放和动画时,实现代码相比于之前提到的 ListView 实现也需要一些变动。因为我在网络上没有找到相应的资料,所以写下这篇文章来记录这个过程。

拖放

前置阅读

Drag and Drop | Android Developers

LinearLayout 设定 View.OnDragListener 很简单,其机制在官方教程中有详细说明,在此不再赘述。

但是,官方教程中给出的样例在释放被拖动条目后只会显示一条 Toast,而一般的需求则是拖放排序。所以在参考了网上的一些文章后,我给出了下面这个简单的实现。与官方样例相比,添加的主要是在ViewGroup 中交换子视图的实现,以及将被拖动的视图作为 LocalState 传递。

public static void setupDragSort(View view) {
    view.setOnDragListener(new View.OnDragListener() {
        @Override
        public boolean onDrag(final View view, DragEvent event) {
            ViewGroup viewGroup = (ViewGroup)view.getParent();
            View dragView = (View)event.getLocalState();
            switch (event.getAction()) {
                case DragEvent.ACTION_DROP:
                    if (view != dragView) {
                        swapViewGroupChildren(viewGroup, view, dragView);
                    }
                    break;
            }
            return true;
        }
    });
    view.setOnLongClickListener(new View.OnLongClickListener() {
        @Override
        public boolean onLongClick(View view) {
            view.startDrag(null, new View.DragShadowBuilder(view), view, 0);
            return true;
        }
    });
}
public static void swapViewGroupChildren(ViewGroup viewGroup, View firstView, View secondView) {
    int firstIndex = viewGroup.indexOfChild(firstView);
    int secondIndex = viewGroup.indexOfChild(secondView);
    if (firstIndex < secondIndex) {
        viewGroup.removeViewAt(secondIndex);
        viewGroup.removeViewAt(firstIndex);
        viewGroup.addView(secondView, firstIndex);
        viewGroup.addView(firstView, secondIndex);
    } else {
        viewGroup.removeViewAt(firstIndex);
        viewGroup.removeViewAt(secondIndex);
        viewGroup.addView(firstView, secondIndex);
        viewGroup.addView(secondView, firstIndex);
    }
}

swap.mp4_1433932801.gif

这个实现已经可以完成拖动排序,然而界面效果却不理想:被拖动的条目没有消失,列表在拖动过程中也没有作出相应的改变。下面我们将使用 Android 的属性动画实现这种界面效果。

在拖动过程中响应更改

言归正传。为了实现拖放过程中的动画,我们的目标是使用 LinearLayout 的列表视图能够对用户的拖动实时作出相应,也就是每次当用户的拖动越过某个临界线的时候,就将列表展现为被拖动条目在这里放下时的预览。因此,需要完成的工作就是将被拖动视图的 Visibility 设置为View.INVISIBLE,此时被拖动视图参与布局计算,但不进行绘制(已经被用户拖起),再不断改变列表中各个条目的位置。

public static void setupDragSort(View view) {
    view.setOnDragListener(new View.OnDragListener() {
        @Override
        public boolean onDrag(final View view, DragEvent event) {
            ViewGroup viewGroup = (ViewGroup)view.getParent();
            DragState dragState = (DragState)event.getLocalState();
            switch (event.getAction()) {
                case DragEvent.ACTION_DRAG_STARTED:
                    if (view == dragState.view) {
                        view.setVisibility(View.INVISIBLE);
                    }
                    break;
                ...
                case DragEvent.ACTION_DRAG_ENDED:
                    if (view == dragState.view) {
                        view.setVisibility(View.VISIBLE);
                    }
                    break;
            }
            return true;
        }
    });
    view.setOnLongClickListener(new View.OnLongClickListener() {
        @Override
        public boolean onLongClick(View view) {
            view.startDrag(null, new View.DragShadowBuilder(view), new DragState(view), 0);
            return true;
        }
    });
}
private static class DragState {
    public View view;
    public int index;
    private DragState(View view) {
        this.view = view;
        index = ((ViewGroup)view.getParent()).indexOfChild(view);
    }
}

一个很自然的想法是,在用户拖动条目经过某个其他条目超过一般高度时,就将这个条目在父视图中的位置与被拖动条目互换(而不是等到用户拖动完成 时)。这样就基本实现了布局系统中的改变。然而,由于在用户快速拖动时,Android 可能来不及向每个经过的视图发送消息,这种方式可能导致列表顺序的改变的问题(我在自己测试时就遇到了)。

所以在实现视图交换时,我们需要使用递归的方式进行,直到两个视图达到相邻。实现代码如下。

public static void setupDragSort(View view) {
    view.setOnDragListener(new View.OnDragListener() {
        @Override
        public boolean onDrag(final View view, DragEvent event) {
            ...
            switch (event.getAction()) {
                ...
                case DragEvent.ACTION_DRAG_LOCATION: {
                    if (view == dragState.view){
                        break;
                    }
                    int index = viewGroup.indexOfChild(view);
                    if ((index > dragState.index && event.getY() > view.getHeight() / 2)
                            || (index < dragState.index && event.getY() < view.getHeight() / 2)) {
                        swapViews(viewGroup, view, index, dragState);
                    } else {
                        swapViewsBetweenIfNeeded(viewGroup, index, dragState);
                    }
                    break;
                }
                ...
            }
            return true;
        }
    });
    ...
}
private static void swapViewsBetweenIfNeeded(ViewGroup viewGroup, int index,
                                             DragState dragState) {
    if (index - dragState.index > 1) {
        int indexAbove = index - 1;
        swapViews(viewGroup, viewGroup.getChildAt(indexAbove), indexAbove, dragState);
    } else if (dragState.index - index > 1) {
        int indexBelow = index + 1;
        swapViews(viewGroup, viewGroup.getChildAt(indexBelow), indexBelow, dragState);
    }
}
private static void swapViews(ViewGroup viewGroup, final View view, int index,
                              DragState dragState) {
    swapViewsBetweenIfNeeded(viewGroup, index, dragState);
    swapViewGroupChildren(viewGroup, view, dragState.view);
    dragState.index = index;
}

realtime.mp4_1433932960.gif

动画

接下来是交换过程中动画的实现。在实现过程中,我参考了 justasm 的 DragLinearLayout 中的代码,在此表示感谢。

前置阅读

Property Animation | Android Developers

在实现动画时,我们主要利用的是 Android 的属性动画机制,涉及到的是 ViewY这个属性。

在谈及实际实现之前,值得在此提及的是 ViewLeftTopXY 的关系。LeftTop 是在视图树布局过程中按照视图层级和布局参数等得出的,表示特定视图在屏幕上被布局系统分配的位置;而 XY则是用于在实际绘制视图时定位的依据。

这种实现的好处是,通过将实际绘制时与布局时的视图位置独立起来,可以实现动画过程中视图的位移、旋转等视觉变换,而不必受到布局系统中视图定位的拘束。顺带一提,XY 其实是由 LeftTop分别加上 TRANSLATION_XTRANSLATION_Y得到的,这是因为实际上视图还是要依赖于布局才能定位。

言归正传。为了让视图位置的变化更加平滑,需要让视图的绘制位置从上一个位置渐变到下一个位置。我们在需要改变视图位置时可以通过 View.getY() 得到视图当前的绘制位置,但视图的下一个位置则需要经过下一次布局计算后才能获得。因此,我们使用一个常见的技巧,也就是利用ViewTreeObserver.OnPreDrawListener,在绘制之前获取已经计算完成的布局位置,在这时开始进行视图动画。

private static void swapViews(ViewGroup viewGroup, final View view, int index,
                              DragState dragState) {
    swapViewsBetweenIfNeeded(viewGroup, index, dragState);
    final float viewY = view.getY();
    swapViewGroupChildren(viewGroup, view, dragState.view);
    dragState.index = index;
    postOnPreDraw(view, new Runnable() {
        @Override
        public void run() {
            ObjectAnimator
                    .ofFloat(view, View.Y, viewY, view.getTop())
                    .setDuration(getDuration(view))
                    .start();
        }
    });
}
private static int getDuration(View view) {
    return view.getResources().getInteger(android.R.integer.config_shortAnimTime);
}
public static void postOnPreDraw(View view, final Runnable runnable) {
    final ViewTreeObserver observer = view.getViewTreeObserver();
    observer.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
        @Override
        public boolean onPreDraw() {
            if (observer.isAlive()) {
                observer.removeOnPreDrawListener(this);
            }
            runnable.run();
            return true;
        }
    });
}

如此,我们就基本完成了拖放操作和动画的实现。效果如下:

animation.mp4_1433933099.gif

附加:删除条目

既然写了这么多,最后再顺带给出一个删除条目及相应动画的实现。其中的 view 参数是在 viewGroup 外的一个拖放目标,用于删除。

public static void setupDragDelete(View view, final ViewGroup viewGroup) {
    view.setOnDragListener(new View.OnDragListener() {
        @Override
        public boolean onDrag(View view, DragEvent event) {
            switch (event.getAction()) {
                case DragEvent.ACTION_DRAG_ENTERED:
                    view.setActivated(true);
                    break;
                case DragEvent.ACTION_DRAG_EXITED:
                    view.setActivated(false);
                    break;
                case DragEvent.ACTION_DROP:
                    DragState dragState = (DragState)event.getLocalState();
                    removeView(viewGroup, dragState);
                    break;
                case DragEvent.ACTION_DRAG_ENDED:
                    // NOTE: Needed because ACTION_DRAG_EXITED may not be sent when the drag
                    // ends within the view.
                    view.setActivated(false);
                    break;
            }
            return true;
        }
    });
}
private static void removeView(ViewGroup viewGroup, DragState dragState) {
    viewGroup.removeView(dragState.view);
    int childCount = viewGroup.getChildCount();
    for (int i = dragState.index; i < childCount; ++i) {
        final View view = viewGroup.getChildAt(i);
        final float viewY = view.getY();
        postOnPreDraw(view, new Runnable() {
            @Override
            public void run() {
                ObjectAnimator
                        .ofFloat(view, View.Y, viewY, view.getTop())
                        .setDuration(getDuration(view))
                        .start();
            }
        });
    }
}

需要注意的是,如果 LinearLayout 的高度设置为 wrap_content,则为了避免动画被视图边界剪裁,以及在ScrollView 中高度正确变化,需要手动对 LinearLayout 的高度进行动画;这同时涉及到需要覆盖 ScrollViewmeasureChild()方法来计算我们所请求的高度。我在下面的完整实现中完成了这个部分。

完整实现

在 GitHub 上浏览