前言 Android 中ListView
的拖放操作和动画实现已经被 这个 DevByte 和 相关的样例 说明,并且也有 ListViewAnimations 这样强大的开源库进行了集成。但是,一番 Google 后,我发现基于 LinearLayout
的相关实现却不多。
然而,有时我们可能需要使用 LinearLayout
替代 ListView
来实现列表,例如不需要 ListView
的视图回收机制(比如使用 Fragment
作为列表项),或者我们需要把这个视图放在 ScrollView
中。
在使用 LinearLayout
实现拖放和动画时,实现代码相比于之前提到的 ListView
实现也需要一些变动。因为我在网络上没有找到相应的资料,所以写下这篇文章来记录这个过程。
拖放 前置阅读
Drag and Drop | Android Developers
为 LinearLayout
设定 View.OnDragListener
很简单,其机制在官方教程中有详细说明,在此不再赘述。
但是,官方教程中给出的样例在释放被拖动条目后只会显示一条 Toast
,而一般的需求则是拖放排序。所以在参考了网上的一些文章后,我给出了下面这个简单的实现。与官方样例相比,添加的主要是在ViewGroup
中交换子视图的实现,以及将被拖动的视图作为 LocalState
传递。
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 38 39 40 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
,此时被拖动视图参与布局计算,但不进行绘制(已经被用户拖起),再不断改变列表中各个条目的位置。
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 38 39 40 41 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 可能来不及向每个经过的视图发送消息,这种方式可能导致列表顺序的改变的问题(我在自己测试时就遇到了)。
所以在实现视图交换时,我们需要使用递归的方式进行,直到两个视图达到相邻。实现代码如下。
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 38 39 40 41 42 43 44 45 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 中的代码,在此表示感谢。
前置阅读
Property Animation | Android Developers
在实现动画时,我们主要利用的是 Android 的属性动画机制,涉及到的是 View
的Y
这个属性。
在谈及实际实现之前,值得在此提及的是 View
的Left
和 Top
与X
和 Y
的关系。Left
和 Top
是在视图树布局过程中按照视图层级和布局参数等得出的,表示特定视图在屏幕上被布局系统分配的位置;而 X
和Y
则是用于在实际绘制视图时定位的依据。
这种实现的好处是,通过将实际绘制时与布局时的视图位置独立起来,可以实现动画过程中视图的位移、旋转等视觉变换,而不必受到布局系统中视图定位的拘束。顺带一提,X
和 Y
其实是由 Left
和Top
分别加上 TRANSLATION_X
和TRANSLATION_Y
得到的,这是因为实际上视图还是要依赖于布局才能定位。
言归正传。为了让视图位置的变化更加平滑,需要让视图的绘制位置从上一个位置渐变到下一个位置。我们在需要改变视图位置时可以通过 View.getY()
得到视图当前的绘制位置,但视图的下一个位置则需要经过下一次布局计算后才能获得。因此,我们使用一个常见的技巧,也就是利用ViewTreeObserver.OnPreDrawListener
,在绘制之前获取已经计算完成的布局位置,在这时开始进行视图动画。
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 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
外的一个拖放目标,用于删除。
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 38 39 40 41 42 43 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: 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()
方法来计算我们所请求的高度。我在下面的完整实现中完成了这个部分。
完整实现
在 GitHub 上浏览