五行代码实现 炫动滑动 卡片层叠布局,仿探探、人人影视订阅界面 简单&优雅:LayoutManager+ItemTo...

lizhenalex 8年前
   <h2>概述</h2>    <p>前几天看有人实现了仿人人美剧的订阅界面,不过在细节之处以及实现方式我个人认为都不是最佳的姿势。</p>    <p>于是我也动手撸了一个,还顺带撸了个探探的界面,先看GIF:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/e4b58d694f40484679e47e3ea45df482.gif"></p>    <p style="text-align:center">探探皇帝翻牌子即视感</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/479e4f5d83e988d09a9891a510cc794d.gif"></p>    <p style="text-align:center">人人美剧订阅界面</p>    <p>这里吐个槽,探探这种设计真的像皇帝翻牌子的感觉,不喜欢左滑,喜欢右滑。</p>    <p>人人影视版特点(需求):</p>    <ul>     <li>动画:最多可见的这四层,在顶层卡片滑动时, <strong>每一层都会位移&放大动画,有种补充到顶层的感觉</strong> 。</li>     <li>动画:松手时,如果未被判定为删除,则会有顶层以下每一层卡片 <strong>收缩回原位</strong> 的动画。</li>     <li>无限循环:模仿人人影视,顶层卡片被删除后,补充到最底层。</li>    </ul>    <p>除上述动画特点,探探版特点(需求):</p>    <ul>     <li>Roate的变化:左右滑动时,顶层卡片会慢慢 <strong>旋转</strong> ,到阈值max大概十五度。</li>     <li>Alpha的变化:左滑时顶层卡片的 <strong>删除按钮会慢慢显现</strong> ,右滑时 <strong>爱心按钮会慢慢显现</strong> 。</li>     <li>显然,松手时,以上动画也需要复位。</li>    </ul>    <p>我们的效果,基本上和原版一致了,写起来怎么样呢?</p>    <p>我不是标题党,如标题所说:</p>    <ul>     <li>简单:思路 <strong>简单清晰易理解</strong></li>     <li>优雅: <strong>性能</strong> 没有任何隐患, LayoutManager 只会加载显示屏幕上 <strong>可见的数量的View</strong> 。</li>     <li>快速:利用 ItemTouchHelper 处理拖拽&滑动删除逻辑,核心代码不超过50行。且经过封装,四行代码就可以用。</li>    </ul>    <h3>伸手党福利:</h3>    <p>如果懒得看这么多文字只想用,直接移步gayhub,gradle导入相关文件or复制。然后如下,搞定。</p>    <pre>  <code class="language-java">mRv.setLayoutManager(new OverLayCardLayoutManager());          CardConfig.initConfig(this);          ItemTouchHelper.Callback callback = new RenRenCallback(mRv, mAdapter, mDatas);          ItemTouchHelper itemTouchHelper = new ItemTouchHelper(callback);          itemTouchHelper.attachToRecyclerView(mRv);</code></pre>    <p>而且我将一些参</p>    <p>数都以变量形式计算,这样就做到了可配置,假如老板让你一开始多显示几层卡片,例如6层,你只需要修改一个参数即可,效果如图:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/a3409943c8ef990ba11354e645307de3.gif"></p>    <p style="text-align:center">6层View</p>    <h3>正确的姿势</h3>    <p>正确的姿势就是:</p>    <ul>     <li>利用 LayoutManager 实现卡片层叠布局,值得注意的是,只layout出界面上可能会看见的那些View。</li>     <li>搭配 ItemTouchHelper ,它本身实现了拖拽&滑动删除逻辑,我们只需要在 onChildDraw() 中绘制动画和 onSwiped() 中处理数据集(循环or删除)。</li>    </ul>    <p>所以本文也算是填了 LayoutManger系列 的坑,实现了一个酷炫效果的布局。</p>    <p>Let's Go!</p>    <h2>LayoutManager的实现卡片层叠</h2>    <p>其实本例中的 LayoutManager 十分简单,因为 ItemTouchHelper 的存在, LayoutManager 根本不需要处理它的滑动事件,而 LayoutManager 中最难写的就是在滑动时的 View 回收和复用,以及 layout 新 View 的处理。</p>    <h3>唯一注意事项</h3>    <p>但是即便如此,还是有一个唯一的注意事项。我们只 layout 出界面上 <strong>可能会看见</strong> 的那些 View 即可。</p>    <p>因为考虑到动画,所以是 <strong>可能会看见</strong> 。</p>    <p>我们看人人美剧的界面:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/09237e366bcb1faea4b1ef65c3793005.png"></p>    <p style="text-align:center">底部细节</p>    <p>初始化时,界面上可见三个 View ,我们分别起名: TopView,Top-1View,Top-2View 。其中 TopView 完全可见, Top-1View,Top-2View 只有下边缘可见。</p>    <p>如文首GIF,滑动 TopView 时, Top-1View,Top-2View 开始慢慢放大,并且向上位移,直至填充至它们各自上层的View。这时候露出了 Top-3View 。</p>    <p>所以我们在书写 LayoutManager 的 onLayoutChildren() 方法时,只要 layout 出当前数据集 <strong>最后四个View</strong> 即可。</p>    <p>前文提到的参数配置如下:</p>    <p>包括一些配置</p>    <ul>     <li>界面最多显示几个View</li>     <li> <p>每一级View之间的Scale差异、translationY等等</p> <pre>  <code class="language-java">public class CardConfig {   //屏幕上最多同时显示几个Item   public static int MAX_SHOW_COUNT;   //每一级Scale相差0.05f,translationY相差7dp左右   public static float SCALE_GAP;   public static int TRANS_Y_GAP;     public static void initConfig(Context context) {       MAX_SHOW_COUNT = 6;       SCALE_GAP = 0.05f;       TRANS_Y_GAP = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15, context.getResources().getDisplayMetrics());   }  }</code></pre> </li>    </ul>    <p>LayoutManager全部代码如下,布满注释,如果看不懂,建议阅读前置文章 LayoutManger系列 :</p>    <pre>  <code class="language-java">public class OverLayCardLayoutManager extends RecyclerView.LayoutManager {      @Override      public RecyclerView.LayoutParams generateDefaultLayoutParams() {          return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);      }      @Override      public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {          detachAndScrapAttachedViews(recycler);          int itemCount = getItemCount();          if (itemCount >= MAX_SHOW_COUNT) {              //从可见的最底层View开始layout,依次层叠上去              for (int position = itemCount - MAX_SHOW_COUNT; position < itemCount; position++) {                  View view = recycler.getViewForPosition(position);                  addView(view);                  measureChildWithMargins(view, 0, 0);                  int widthSpace = getWidth() - getDecoratedMeasuredWidth(view);                  int heightSpace = getHeight() - getDecoratedMeasuredHeight(view);                  //我们在布局时,将childView居中处理,这里也可以改为只水平居中                  layoutDecoratedWithMargins(view, widthSpace / 2, heightSpace / 2,                          widthSpace / 2 + getDecoratedMeasuredWidth(view),                          heightSpace / 2 + getDecoratedMeasuredHeight(view));                  /**                   * TopView的Scale 为1,translationY 0                   * 每一级Scale相差0.05f,translationY相差7dp左右                   *                   * 观察人人影视的UI,拖动时,topView被拖动,Scale不变,一直为1.                   * top-1View 的Scale慢慢变化至1,translation也慢慢恢复0                   * top-2View的Scale慢慢变化至 top-1View的Scale,translation 也慢慢变化只top-1View的translation                   * top-3View的Scale要变化,translation岿然不动                   */                    //第几层,举例子,count =7, 最后一个TopView(6)是第0层,                  int level = itemCount - position - 1;                  //除了顶层不需要缩小和位移                  if (level > 0 /*&& level < mShowCount - 1*/) {                      //每一层都需要X方向的缩小                      view.setScaleX(1 - SCALE_GAP * level);                      //前N层,依次向下位移和Y方向的缩小                      if (level < MAX_SHOW_COUNT - 1) {                          view.setTranslationY(TRANS_Y_GAP * level);                          view.setScaleY(1 - SCALE_GAP * level);                      } else {//第N层在 向下位移和Y方向的缩小的成都与 N-1层保持一致                          view.setTranslationY(TRANS_Y_GAP * (level - 1));                          view.setScaleY(1 - SCALE_GAP * (level - 1));                      }                  }              }          }      }    }</code></pre>    <p>撸到这里,我们的静态界面已经成型,下面让我们动起来:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/e915e55c02688e8fc09d03623068a0c6.png"></p>    <p style="text-align:center">静态界面</p>    <h2>ItemTouchHelper实现炫动滑动:</h2>    <p>ItemTouchHelper 的基础知识,建议大家自行学习,网上文章很多,我简单介绍一下,</p>    <p>This is a utility class to add swipe to dismiss and drag & drop support to RecyclerView.</p>    <p>It works with a RecyclerView and a Callback class, which configures what type of interactions</p>    <p>are enabled and also receives events when user performs these actions.</p>    <p>Depending on which functionality you support, you should override</p>    <p>{@link Callback#onMove(RecyclerView, ViewHolder, ViewHolder)} and / or</p>    <p>{@link Callback#onSwiped(ViewHolder, int)}.</p>    <p>翻译 + 总结:</p>    <p>这货是一个工具类,为RecyclerView扩展滑动消失(删除)和drag & drop效果的。</p>    <p>它需要和RecyclerView、Callback 一起工作。Callback 类里定义了 允许哪些交互,并且会接收到对应的交互事件</p>    <p>根据你需要哪种功能(滑动消失(删除)和drag & drop),你需要重写</p>    <p>Callback#onMove(RecyclerView, ViewHolder, ViewHolder)-----drag & drop</p>    <p>Callback#onSwiped(ViewHolder, int) 方法。 -----滑动消失(删除)</p>    <p>总结一下入门级用法如下,三个步骤:</p>    <ul>     <li> <p>定义一个Callback: ItemTouchHelper.Callback callback = new ItemTouchHelper.SimpleCallback(int,int) ,这两个int分别代表要 <strong>监听哪几个方向上的拖拽、滑动事件</strong> 。 常用: ItemTouchHelper.DOWN | ItemTouchHelper.UP | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT</p> </li>     <li> <p>将Callback传给ItemTouchHelper: ItemTouchHelper itemTouchHelper = new ItemTouchHelper(callback);</p> </li>     <li> <p>关联ItemTouchHelper和RecyclerView: itemTouchHelper.attachToRecyclerView(mRv)</p> </li>    </ul>    <p>这三个步骤做完后,ItemTouchHelper就会自动帮我们完成 <strong>滑动消失(删除)和drag & drop</strong> 的功能。</p>    <h3>滑动删除</h3>    <p>我们本例中,需要的是 <strong>滑动消失(删除)</strong> ,所以我们的 Callback 不需要关注 onMove() 方法。</p>    <p>且我们需要上下左右滑动都可以删除的效果。</p>    <p>则如下构造Callback,传入上下左右:</p>    <pre>  <code class="language-java">ItemTouchHelper.Callback callback = new ItemTouchHelper.SimpleCallback(0,                  ItemTouchHelper.DOWN | ItemTouchHelper.UP | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT)</code></pre>    <p>onSwiped() 方法,是滑动删除动作 <strong>已经发生后回调</strong> 的,即,我们先滑动卡片,然后松手,此时 ItemTouchHelper 判断我们的手势是删除手势,会 <strong>自动对这个卡片执行丢出屏幕外的动画</strong> ,同时回调 onSwiped() 方法。</p>    <p>所以我们需要在其中如下写:</p>    <pre>  <code class="language-java">@Override              public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {                  //★实现循环的要点                  SwipeCardBean remove = mDatas.remove(viewHolder.getLayoutPosition());                  mDatas.add(0, remove);                  mAdapter.notifyDataSetChanged();              }</code></pre>    <p>在这里我们完成了循环的操作:</p>    <ul>     <li>利用当前被删除的 View 的 ViewHolder 拿到 Position</li>     <li>删除数据集中对应 Position 的数据源</li>     <li>同时将该数据源插入数据集中的首位。</li>     <li>调用 notifyDataSetChanged() ,通知列表刷新<br> 如此我们便完成了, <strong>循环列表的需求</strong> 。</li>    </ul>    <p>这里提一下为什么我们要调用 notifyDataSetChanged() 。</p>    <p>看官方文档:</p>    <p>ItemTouchHelper moves the items' translateX/Y properties to reposition them</p>    <p>即ItemTouchHelper实现的滑动删除,其实只是 <strong>隐藏了这个滑动的View</strong> 。并不是真的删除了。</p>    <p>在 LayoutManager实现流式布局 一文第五节中,我们已经提到, notifyDataSetChanged() 会回调 onLayoutChildren() 这个函数,而在这个函数中,我们会 <strong>重新布局</strong> ,即真正的移除(不再layout)滑动掉的View,同时 <strong>会补充进新的最底层的View</strong> 。</p>    <p>嗯,JavaBean也看一眼吧,没亮点:</p>    <pre>  <code class="language-java">public class SwipeCardBean {      private int postition;//位置      private String url;      private String name;      }</code></pre>    <p>我们写到这里已经完成了滑动删除的功能,其实我们什么都没有写是吧,复杂的判断都由ItemTouchHelper帮我们处理掉了,例如速度、滑动距离是否到达删除阈值,删除成功移除的动画、取消删除复位的动画等等。</p>    <p>所以我说利用ItemTouchHelper才是正确的姿势,因为很简单&快速。</p>    <p>下面我们来实现滑动时的动画。</p>    <h3>滑动时动画</h3>    <p>我们需要重写 Callback 的 onChildDraw() 方法,这个方法参数较多:</p>    <pre>  <code class="language-java">* @param c                 The canvas which RecyclerView is drawing its children         * @param recyclerView      The RecyclerView to which ItemTouchHelper is attached to         * @param viewHolder        The ViewHolder which is being interacted by the User or it was                                  interacted and simply animating to its original position         * @param dX                The amount of horizontal displacement caused by user's action         * @param dY                The amount of vertical displacement caused by user's action         * @param actionState       是拖拽还是滑动事件  The type of interaction on the View. Is either {@link #ACTION_STATE_DRAG} or {@link #ACTION_STATE_SWIPE}.           * @param isCurrentlyActive 事件是用户产生还是动画产生的 True if this view is currently being controlled by the user or false it is simply animating back to its original state.</code></pre>    <p>对我们比较有用的有 dX dX ,可以 判断滑动方向,以及计算滑动的比例,从而控制缩放、位移动画的程度 。</p>    <p>本文如下编写,对View的缩放、位移,其实是对LayoutManager里的操作的 <strong>逆操作</strong> ,值得注意的是最后一层,即 top-3View 在Y轴上是保持不变的:</p>    <pre>  <code class="language-java">@Override              public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {                  super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);                  //先根据滑动的dxdy 算出现在动画的比例系数fraction                  double swipValue = Math.sqrt(dX * dX + dY * dY);                  double fraction = swipValue / getThreshold(viewHolder);                  //边界修正 最大为1                  if (fraction > 1) {                      fraction = 1;                  }                  //对每个ChildView进行缩放 位移                  int childCount = recyclerView.getChildCount();                  for (int i = 0; i < childCount; i++) {                      View child = recyclerView.getChildAt(i);                      //第几层,举例子,count =7, 最后一个TopView(6)是第0层,                      int level = childCount - i - 1;                      if (level > 0) {                          child.setScaleX((float) (1 - SCALE_GAP * level + fraction * SCALE_GAP));                            if (level < MAX_SHOW_COUNT - 1) {                              child.setScaleY((float) (1 - SCALE_GAP * level + fraction * SCALE_GAP));                              child.setTranslationY((float) (TRANS_Y_GAP * level - fraction * TRANS_Y_GAP));                          }                      }                  }              }</code></pre>    <p>getThreshold(viewHolder) 函数,返回 <strong>是否可以被回收掉的阈值</strong> ,关于它为什么这么写,我是从源码里找到的,本末会讲解:</p>    <pre>  <code class="language-java">//水平方向是否可以被回收掉的阈值              public float getThreshold(RecyclerView.ViewHolder viewHolder) {                  return mRv.getWidth() * getSwipeThreshold(viewHolder);              }</code></pre>    <h2>探探效果的实现</h2>    <p>一开始文章撸到这里应该结束了,群里出来一个马小跳,告诉我探探和这略有不同,希望我一并实现。</p>    <p>嗯,好吧。表示没听说过探探,那我先去下载一个看看吧。</p>    <p>loading-install-open........</p>    <p>哎哟呵,十分钟过去了,我还在滑动看美女 忘记了要干什么,被女票看到胖揍了我一顿。</p>    <p>好的,我捂着脸继续分析。</p>    <p>探探和人人影视有 <strong>两点不同</strong> :</p>    <ul>     <li>Roate的变化:左右滑动时,顶层卡片会慢慢 <strong>旋转</strong> ,到阈值max大概十五度。</li>     <li>Alpha的变化:左滑时顶层卡片的 <strong>删除按钮会慢慢显现</strong> ,右滑时 <strong>爱心按钮会慢慢显现</strong> 。</li>    </ul>    <p>感觉也是炒鸡简单,来吧。五分钟撸完吃外卖。修改点:</p>    <ul>     <li>在layout布局添加『 X 』&『 爱心 』。</li>     <li>在 onChildDraw() 里,按比例修改TopView的Rotate & Alpha</li>    </ul>    <h3>监听方向</h3>    <p>还有一点小不同,上滑下滑不再能删除,所以我们构造时只传入左右即可:</p>    <pre>  <code class="language-java">ItemTouchHelper.Callback callback = new ItemTouchHelper.SimpleCallback(0,                  ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT)</code></pre>    <h3>布局添加两个按钮</h3>    <p>略</p>    <h3>onChildDraw()</h3>    <p>在上文人人影视的基础上扩展,上文的效果,对 TopView 是不做任何操作的。这里只需要再对 TopView 做额外操作即可:</p>    <pre>  <code class="language-java">@Override              public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {                  ...                  for (int i = 0; i < childCount; i++) {                      View child = recyclerView.getChildAt(i);                      //第几层,举例子,count =7, 最后一个TopView(6)是第0层,                      int level = childCount - i - 1;                      if (level > 0) {                          ...                      } else {                          //探探只是第一层加了rotate & alpha的操作                          //不过他区分左右                          float xFraction = dX / getThreshold(viewHolder);                          //边界修正 最大为1                          if (xFraction > 1) {                              xFraction = 1;                          } else if (xFraction < -1) {                              xFraction = -1;                          }                          //rotate                          child.setRotation(xFraction * MAX_ROTATION);                            //自己感受一下吧 Alpha                          if (viewHolder instanceof ViewHolder) {                              ViewHolder holder = (ViewHolder) viewHolder;                              if (dX > 0) {                                  //露出左边,比心                                  holder.setAlpha(R.id.iv_love, xFraction);                              } else {                                  //露出右边,滚犊子                                  holder.setAlpha(R.id.iv_del, -xFraction);                              }                          }                      }                  }              }</code></pre>    <p>实现完后,我以为结束了,结果比我们想象的还要复杂一丢丢。因为此时删除后, notifyDataSetChanged() 刷新界面,而 TopView 还是倾斜的,爱心、删除图标也是出现的。这显然与预期不符。所以我们需要在 onSwiped() 里将其复位:</p>    <pre>  <code class="language-java">@Override              public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {              ...                  //探探只是第一层加了rotate & alpha的操作                  //对rotate进行复位                  viewHolder.itemView.setRotation(0);                  //自己感受一下吧 Alpha                  if (viewHolder instanceof ViewHolder) {                      ViewHolder holder = (ViewHolder) viewHolder;                      holder.setAlpha(R.id.iv_love, 0);                      holder.setAlpha(R.id.iv_del, 0);                  }              }</code></pre>    <p>Ok,大功告成。效果和文首一样,尽情去跟产品UI嘚瑟吧。</p>    <h2>阈值的寻找之路</h2>    <p>阈值的寻找,花费了我一些时间,因为我想做到 <strong>和系统的行为保持一致</strong> 。</p>    <p>即,当删除、喜欢图标全显,当 Top-1View 显示完毕时,松手 TopView 会回收。</p>    <p>这就决定了我们的缩放、位移的阈值不能随便定,所以我们必须 <strong>去源代码里找答案</strong> 。</p>    <pre>  <code class="language-java">//水平方向是否可以被回收掉的阈值      public float getThreshold(RecyclerView.ViewHolder viewHolder) {          return mRv.getWidth() * getSwipeThreshold(viewHolder);      }</code></pre>    <p>因为滑动删除操作是touch事件导致的,且应该是ACTION_UP时,触发的,</p>    <p>所以在 ItemTouchHelper 源码里,搜索onTouch字样:</p>    <p>定位到: mOnItemTouchListener ,-></p>    <p>继续定位其中的 onTouchEvent() ,-></p>    <p>case MotionEvent.ACTION_UP: ,-></p>    <p>void select(ViewHolder selected, int actionState) -></p>    <p>在这里我注意到有一句代码: animationType = ANIMATION_TYPE_SWIPE_SUCCESS;</p>    <p>这说明删除成功,它的触发条件是: if (swipeDir > 0) -></p>    <p>swipeDir 的值: final int swipeDir = prevActionState == ACTION_STATE_DRAG ? 0 : swipeIfNecessary(prevSelected); -></p>    <p>int swipeIfNecessary(ViewHolder viewHolder) -></p>    <pre>  <code class="language-java">if ((swipeDir = checkHorizontalSwipe(viewHolder, flags)) > 0) {                  return swipeDir;    }</code></pre>    <p>如此返回1的话,则-> checkHorizontalSwipe(viewHolder, flags) -></p>    <p>在其中终于找到源码里阈值的获取之处:</p>    <pre>  <code class="language-java">final float threshold = mRecyclerView.getWidth() * mCallback          .getSwipeThreshold(viewHolder);</code></pre>    <p>于是我就直接复制出来。</p>    <h2>总结</h2>    <p>本文利用 LayoutManager 加载显示屏幕上 <strong>可见的数量的View</strong> ,搭配 ItemTouchHelper 处理拖拽&滑动删除逻辑,核心代码不超过50行。且经过封装,四行代码就可以用。</p>    <p>记住 LayoutManager ,我们写,只layout出界面上可能会看见的那些View即可。</p>    <p>关于 ItemTouchHelper ,它本身实现了拖拽&滑动删除逻辑,我们只需要在 onChildDraw() 中绘制动画和 onSwiped() 中处理数据集(循环or删除)即可。</p>    <p>以后老板让你做这种效果,你只需要:</p>    <pre>  <code class="language-java">mRv.setLayoutManager(new OverLayCardLayoutManager());          CardConfig.initConfig(this);          ItemTouchHelper.Callback callback = new RenRenCallback(mRv, mAdapter, mDatas);          ItemTouchHelper itemTouchHelper = new ItemTouchHelper(callback);          itemTouchHelper.attachToRecyclerView(mRv);</code></pre>    <p>如果需要定制特殊的参数,例如显示6层:</p>    <pre>  <code class="language-java">CardConfig.MAX_SHOW_COUNT = 6;</code></pre>    <p> </p>    <p> </p>