五行代碼實現 炫動滑動 卡片層疊布局,仿探探、人人影視訂閱界面 簡單&優雅:LayoutManager+ItemTo...

lizhenalex 8年前發布 | 11K 次閱讀 Android開發 移動開發 RecyclerView

概述

前幾天看有人實現了仿人人美劇的訂閱界面,不過在細節之處以及實現方式我個人認為都不是最佳的姿勢。

于是我也動手擼了一個,還順帶擼了個探探的界面,先看GIF:

探探皇帝翻牌子即視感

人人美劇訂閱界面

這里吐個槽,探探這種設計真的像皇帝翻牌子的感覺,不喜歡左滑,喜歡右滑。

人人影視版特點(需求):

  • 動畫:最多可見的這四層,在頂層卡片滑動時, 每一層都會位移&放大動畫,有種補充到頂層的感覺
  • 動畫:松手時,如果未被判定為刪除,則會有頂層以下每一層卡片 收縮回原位 的動畫。
  • 無限循環:模仿人人影視,頂層卡片被刪除后,補充到最底層。

除上述動畫特點,探探版特點(需求):

  • Roate的變化:左右滑動時,頂層卡片會慢慢 旋轉 ,到閾值max大概十五度。
  • Alpha的變化:左滑時頂層卡片的 刪除按鈕會慢慢顯現 ,右滑時 愛心按鈕會慢慢顯現
  • 顯然,松手時,以上動畫也需要復位。

我們的效果,基本上和原版一致了,寫起來怎么樣呢?

我不是標題黨,如標題所說:

  • 簡單:思路 簡單清晰易理解
  • 優雅: 性能 沒有任何隱患, LayoutManager 只會加載顯示屏幕上 可見的數量的View
  • 快速:利用 ItemTouchHelper 處理拖拽&滑動刪除邏輯,核心代碼不超過50行。且經過封裝,四行代碼就可以用。

伸手黨福利:

如果懶得看這么多文字只想用,直接移步gayhub,gradle導入相關文件or復制。然后如下,搞定。

mRv.setLayoutManager(new OverLayCardLayoutManager());
        CardConfig.initConfig(this);
        ItemTouchHelper.Callback callback = new RenRenCallback(mRv, mAdapter, mDatas);
        ItemTouchHelper itemTouchHelper = new ItemTouchHelper(callback);
        itemTouchHelper.attachToRecyclerView(mRv);

而且我將一些參

數都以變量形式計算,這樣就做到了可配置,假如老板讓你一開始多顯示幾層卡片,例如6層,你只需要修改一個參數即可,效果如圖:

6層View

正確的姿勢

正確的姿勢就是:

  • 利用 LayoutManager 實現卡片層疊布局,值得注意的是,只layout出界面上可能會看見的那些View。
  • 搭配 ItemTouchHelper ,它本身實現了拖拽&滑動刪除邏輯,我們只需要在 onChildDraw() 中繪制動畫和 onSwiped() 中處理數據集(循環or刪除)。

所以本文也算是填了 LayoutManger系列 的坑,實現了一個酷炫效果的布局。

Let's Go!

LayoutManager的實現卡片層疊

其實本例中的 LayoutManager 十分簡單,因為 ItemTouchHelper 的存在, LayoutManager 根本不需要處理它的滑動事件,而 LayoutManager 中最難寫的就是在滑動時的 View 回收和復用,以及 layout 新 View 的處理。

唯一注意事項

但是即便如此,還是有一個唯一的注意事項。我們只 layout 出界面上 可能會看見 的那些 View 即可。

因為考慮到動畫,所以是 可能會看見

我們看人人美劇的界面:

底部細節

初始化時,界面上可見三個 View ,我們分別起名: TopView,Top-1View,Top-2View 。其中 TopView 完全可見, Top-1View,Top-2View 只有下邊緣可見。

如文首GIF,滑動 TopView 時, Top-1View,Top-2View 開始慢慢放大,并且向上位移,直至填充至它們各自上層的View。這時候露出了 Top-3View 。

所以我們在書寫 LayoutManager 的 onLayoutChildren() 方法時,只要 layout 出當前數據集 最后四個View 即可。

前文提到的參數配置如下:

包括一些配置

  • 界面最多顯示幾個View
  • 每一級View之間的Scale差異、translationY等等

    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>

    LayoutManager全部代碼如下,布滿注釋,如果看不懂,建議閱讀前置文章 LayoutManger系列 :

    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>

    擼到這里,我們的靜態界面已經成型,下面讓我們動起來:

    靜態界面

    ItemTouchHelper實現炫動滑動:

    ItemTouchHelper 的基礎知識,建議大家自行學習,網上文章很多,我簡單介紹一下,

    This is a utility class to add swipe to dismiss and drag & drop support to RecyclerView.

    It works with a RecyclerView and a Callback class, which configures what type of interactions

    are enabled and also receives events when user performs these actions.

    Depending on which functionality you support, you should override

    <p>{@link Callback#onMove(RecyclerView, ViewHolder, ViewHolder)} and / or</p> <p>{@link Callback#onSwiped(ViewHolder, int)}.</p>

    翻譯 + 總結:

    這貨是一個工具類,為RecyclerView擴展滑動消失(刪除)和drag & drop效果的。

    它需要和RecyclerView、Callback 一起工作。Callback 類里定義了 允許哪些交互,并且會接收到對應的交互事件

    根據你需要哪種功能(滑動消失(刪除)和drag & drop),你需要重寫

    Callback#onMove(RecyclerView, ViewHolder, ViewHolder)-----drag & drop

    Callback#onSwiped(ViewHolder, int) 方法。 -----滑動消失(刪除)

    總結一下入門級用法如下,三個步驟:

    • 定義一個Callback: ItemTouchHelper.Callback callback = new ItemTouchHelper.SimpleCallback(int,int) ,這兩個int分別代表要 監聽哪幾個方向上的拖拽、滑動事件 。 常用: ItemTouchHelper.DOWN | ItemTouchHelper.UP | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT

    • 將Callback傳給ItemTouchHelper: ItemTouchHelper itemTouchHelper = new ItemTouchHelper(callback);

    • 關聯ItemTouchHelper和RecyclerView: itemTouchHelper.attachToRecyclerView(mRv)

    這三個步驟做完后,ItemTouchHelper就會自動幫我們完成 滑動消失(刪除)和drag & drop 的功能。

    滑動刪除

    我們本例中,需要的是 滑動消失(刪除) ,所以我們的 Callback 不需要關注 onMove() 方法。

    且我們需要上下左右滑動都可以刪除的效果。

    則如下構造Callback,傳入上下左右:

    ItemTouchHelper.Callback callback = new ItemTouchHelper.SimpleCallback(0,
                    ItemTouchHelper.DOWN | ItemTouchHelper.UP | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT)

    onSwiped() 方法,是滑動刪除動作 已經發生后回調 的,即,我們先滑動卡片,然后松手,此時 ItemTouchHelper 判斷我們的手勢是刪除手勢,會 自動對這個卡片執行丟出屏幕外的動畫 ,同時回調 onSwiped() 方法。

    所以我們需要在其中如下寫:

    @Override
                public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
                    //★實現循環的要點
                    SwipeCardBean remove = mDatas.remove(viewHolder.getLayoutPosition());
                    mDatas.add(0, remove);
                    mAdapter.notifyDataSetChanged();
                }

    在這里我們完成了循環的操作:

    • 利用當前被刪除的 View 的 ViewHolder 拿到 Position
    • 刪除數據集中對應 Position 的數據源
    • 同時將該數據源插入數據集中的首位。
    • 調用 notifyDataSetChanged() ,通知列表刷新
      如此我們便完成了, 循環列表的需求

    這里提一下為什么我們要調用 notifyDataSetChanged() 。

    看官方文檔:

    ItemTouchHelper moves the items' translateX/Y properties to reposition them

    即ItemTouchHelper實現的滑動刪除,其實只是 隱藏了這個滑動的View 。并不是真的刪除了。

    在 LayoutManager實現流式布局 一文第五節中,我們已經提到, notifyDataSetChanged() 會回調 onLayoutChildren() 這個函數,而在這個函數中,我們會 重新布局 ,即真正的移除(不再layout)滑動掉的View,同時 會補充進新的最底層的View

    嗯,JavaBean也看一眼吧,沒亮點:

    public class SwipeCardBean {
        private int postition;//位置
        private String url;
        private String name;
        }

    我們寫到這里已經完成了滑動刪除的功能,其實我們什么都沒有寫是吧,復雜的判斷都由ItemTouchHelper幫我們處理掉了,例如速度、滑動距離是否到達刪除閾值,刪除成功移除的動畫、取消刪除復位的動畫等等。

    所以我說利用ItemTouchHelper才是正確的姿勢,因為很簡單&快速。

    下面我們來實現滑動時的動畫。

    滑動時動畫

    我們需要重寫 Callback 的 onChildDraw() 方法,這個方法參數較多:

    * @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> 
    

    對我們比較有用的有 dX dX ,可以 判斷滑動方向,以及計算滑動的比例,從而控制縮放、位移動畫的程度 。

    本文如下編寫,對View的縮放、位移,其實是對LayoutManager里的操作的 逆操作 ,值得注意的是最后一層,即 top-3View 在Y軸上是保持不變的:

    @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));
                            }
                        }
                    }
                }

    getThreshold(viewHolder) 函數,返回 是否可以被回收掉的閾值 ,關于它為什么這么寫,我是從源碼里找到的,本末會講解:

    //水平方向是否可以被回收掉的閾值
                public float getThreshold(RecyclerView.ViewHolder viewHolder) {
                    return mRv.getWidth() * getSwipeThreshold(viewHolder);
                }

    探探效果的實現

    一開始文章擼到這里應該結束了,群里出來一個馬小跳,告訴我探探和這略有不同,希望我一并實現。

    嗯,好吧。表示沒聽說過探探,那我先去下載一個看看吧。

    loading-install-open........

    哎喲呵,十分鐘過去了,我還在滑動看美女 忘記了要干什么,被女票看到胖揍了我一頓。

    好的,我捂著臉繼續分析。

    探探和人人影視有 兩點不同 :

    • Roate的變化:左右滑動時,頂層卡片會慢慢 旋轉 ,到閾值max大概十五度。
    • Alpha的變化:左滑時頂層卡片的 刪除按鈕會慢慢顯現 ,右滑時 愛心按鈕會慢慢顯現

    感覺也是炒雞簡單,來吧。五分鐘擼完吃外賣。修改點:

    • 在layout布局添加『 X 』&『 愛心 』。
    • 在 onChildDraw() 里,按比例修改TopView的Rotate & Alpha

    監聽方向

    還有一點小不同,上滑下滑不再能刪除,所以我們構造時只傳入左右即可:

    ItemTouchHelper.Callback callback = new ItemTouchHelper.SimpleCallback(0,
                    ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT)

    布局添加兩個按鈕

    onChildDraw()

    在上文人人影視的基礎上擴展,上文的效果,對 TopView 是不做任何操作的。這里只需要再對 TopView 做額外操作即可:

    @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);
                                }
                            }
                        }
                    }
                }

    實現完后,我以為結束了,結果比我們想象的還要復雜一丟丟。因為此時刪除后, notifyDataSetChanged() 刷新界面,而 TopView 還是傾斜的,愛心、刪除圖標也是出現的。這顯然與預期不符。所以我們需要在 onSwiped() 里將其復位:

    @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);
                    }
                }

    Ok,大功告成。效果和文首一樣,盡情去跟產品UI嘚瑟吧。

    閾值的尋找之路

    閾值的尋找,花費了我一些時間,因為我想做到 和系統的行為保持一致

    即,當刪除、喜歡圖標全顯,當 Top-1View 顯示完畢時,松手 TopView 會回收。

    這就決定了我們的縮放、位移的閾值不能隨便定,所以我們必須 去源代碼里找答案

    //水平方向是否可以被回收掉的閾值
        public float getThreshold(RecyclerView.ViewHolder viewHolder) {
            return mRv.getWidth() * getSwipeThreshold(viewHolder);
        }

    因為滑動刪除操作是touch事件導致的,且應該是ACTION_UP時,觸發的,

    所以在 ItemTouchHelper 源碼里,搜索onTouch字樣:

    定位到: mOnItemTouchListener ,->

    繼續定位其中的 onTouchEvent() ,->

    case MotionEvent.ACTION_UP: ,->

    void select(ViewHolder selected, int actionState) ->

    在這里我注意到有一句代碼: animationType = ANIMATION_TYPE_SWIPE_SUCCESS;

    這說明刪除成功,它的觸發條件是: if (swipeDir > 0) ->

    swipeDir 的值: final int swipeDir = prevActionState == ACTION_STATE_DRAG ? 0 : swipeIfNecessary(prevSelected); ->

    int swipeIfNecessary(ViewHolder viewHolder) ->

    if ((swipeDir = checkHorizontalSwipe(viewHolder, flags)) > 0) {
                    return swipeDir;
      }

    如此返回1的話,則-> checkHorizontalSwipe(viewHolder, flags) ->

    在其中終于找到源碼里閾值的獲取之處:

    final float threshold = mRecyclerView.getWidth() * mCallback
            .getSwipeThreshold(viewHolder);

    于是我就直接復制出來。

    總結

    本文利用 LayoutManager 加載顯示屏幕上 可見的數量的View ,搭配 ItemTouchHelper 處理拖拽&滑動刪除邏輯,核心代碼不超過50行。且經過封裝,四行代碼就可以用。

    記住 LayoutManager ,我們寫,只layout出界面上可能會看見的那些View即可。

    關于 ItemTouchHelper ,它本身實現了拖拽&滑動刪除邏輯,我們只需要在 onChildDraw() 中繪制動畫和 onSwiped() 中處理數據集(循環or刪除)即可。

    以后老板讓你做這種效果,你只需要:

    mRv.setLayoutManager(new OverLayCardLayoutManager());
            CardConfig.initConfig(this);
            ItemTouchHelper.Callback callback = new RenRenCallback(mRv, mAdapter, mDatas);
            ItemTouchHelper itemTouchHelper = new ItemTouchHelper(callback);
            itemTouchHelper.attachToRecyclerView(mRv);

    如果需要定制特殊的參數,例如顯示6層:

    CardConfig.MAX_SHOW_COUNT = 6;

     

     

 本文由用戶 lizhenalex 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!