LinearLayout 的拖放操作和动画
前言
Android 中ListView
的拖放操作和动画实现已经被 这个 DevByte 和 相关的样例 说明,并且也有 ListViewAnimations 这样强大的开源库进行了集成。但是,一番 Google 后,我发现基于 LinearLayout
的相关实现却不多。
然而,有时我们可能需要使用 LinearLayout
替代 ListView
来实现列表,例如不需要 ListView
的视图回收机制(比如使用 Fragment
作为列表项),或者我们需要把这个视图放在 ScrollView
中。
在使用 LinearLayout
实现拖放和动画时,实现代码相比于之前提到的 ListView
实现也需要一些变动。因为我在网络上没有找到相应的资料,所以写下这篇文章来记录这个过程。
拖放
前置阅读
为 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);
}
}
这个实现已经可以完成拖动排序,然而界面效果却不理想:被拖动的条目没有消失,列表在拖动过程中也没有作出相应的改变。下面我们将使用 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;
}
动画
接下来是交换过程中动画的实现。在实现过程中,我参考了 justasm 的 DragLinearLayout 中的代码,在此表示感谢。
前置阅读
在实现动画时,我们主要利用的是 Android 的属性动画机制,涉及到的是 View
的Y
这个属性。
在谈及实际实现之前,值得在此提及的是 View
的Left
和 Top
与X
和 Y
的关系。Left
和 Top
是在视图树布局过程中按照视图层级和布局参数等得出的,表示特定视图在屏幕上被布局系统分配的位置;而 X
和Y
则是用于在实际绘制视图时定位的依据。
这种实现的好处是,通过将实际绘制时与布局时的视图位置独立起来,可以实现动画过程中视图的位移、旋转等视觉变换,而不必受到布局系统中视图定位的拘束。顺带一提,X
和 Y
其实是由 Left
和Top
分别加上 TRANSLATION_X
和TRANSLATION_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;
}
});
}
如此,我们就基本完成了拖放操作和动画的实现。效果如下:
附加:删除条目
既然写了这么多,最后再顺带给出一个删除条目及相应动画的实现。其中的 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
的高度进行动画;这同时涉及到需要覆盖 ScrollView
中measureChild()
方法来计算我们所请求的高度。我在下面的完整实现中完成了这个部分。
完整实现