從零開始的堆疊卡片控件

摘要

第一次看見堆疊卡片的效果是在「探探app」上,網上也有不少實現。前段時間因爲工做須要,我也實現了一個。趁此總結一下本身的心路歷程,也但願對控件感興趣的童鞋有所幫助。java

效果圖

StackLayout
StackLayout

功能

  • 自定義卡片的堆疊效果
  • 自定義卡片移除動畫
  • 支持加載更多

源碼及使用方式

詳見 github.com/fashare2015…android

實現步驟

Adapter: 提供View

盜用了RecyclerView的方式,因而咱們能夠這麼使用:mStackLayout.setAdapter(...);git

public class StackLayout extends FrameLayout {
    private Adapter mAdapter;
    public void setAdapter(Adapter adapter) {
        mAdapter = adapter;
        onSetAdapter(adapter);
    }

    private void onSetAdapter(Adapter adapter) {...}

    public static abstract class Adapter<VH extends ViewHolder>{
        public abstract VH onCreateViewHolder(ViewGroup parent, int position);

        public abstract void onBindViewHolder(VH holder, int position);

        public abstract int getItemCount();

        private VH getViewHolder(ViewGroup parent, int position){
            VH viewHolder = onCreateViewHolder(parent, position);
            onBindViewHolder(viewHolder, position);
            return viewHolder;
        }
    }

    public static abstract class ViewHolder {
        public final View itemView;
        public ViewHolder(View itemView) {
            this.itemView = itemView;
        }
    }
}複製代碼


DataSetObserver: 觀察者,刷新View

一樣盜用了RecyclerView的方式,因而咱們能夠這麼使用:mAdapter.notifyDataSetChanged();github

public class StackLayout extends FrameLayout {
    private void onSetAdapter(Adapter adapter) {
        adapter.registerDataSetObserver(mItemObserver);
        mItemObserver.dataChanged(adapter);
    }

    ItemObserver mItemObserver = new ItemObserver();
    private class ItemObserver extends DataSetObserver {
        @Override public void onChanged() { dataChanged(mAdapter); }

        @Override public void onInvalidated() { dataChanged(mAdapter); }

        private void dataChanged(Adapter adapter) {
            // 移除先前的 view, 並從 adapter 裏拿到新的 view。
            StackLayout.this.removeAllViews();
            for(int i=getCurrentItem(); i<adapter.getItemCount(); i ++) {
                ViewHolder viewHolder = adapter.getViewHolder(StackLayout.this, i);
                StackLayout.this.addView(viewHolder.itemView, 0);
            }
        }
    }

    public static abstract class Adapter<VH extends ViewHolder>{
        ...
        private final DataSetObservable mObservable = new DataSetObservable();

        public void notifyDataSetChanged() {
            mObservable.notifyChanged();
        }

        public void registerDataSetObserver(DataSetObserver observer) {
            mObservable.registerObserver(observer);
        }

        public void unregisterDataSetObserver(DataSetObserver observer) {
            mObservable.unregisterObserver(observer);
        }
    }
}複製代碼

到此爲止,咱們擁有一個能夠刷新數據的FrameLayout,且全部的子View重疊在一個位置。如圖:
app

StackLayout雛形
StackLayout雛形


ViewDragHelper: 卡片滑動事件

ViewDragHelper 實乃神器,能實現全部的滑動需求。主要的邏輯在它的Callback裏。ide

public class StackLayout extends FrameLayout {
    private ViewDragHelper mViewDragHelper = ViewDragHelper.create(this, new ViewDragHelper.Callback(){
        private ScrollManager mScrollManager;
        private View mParent = StackLayout.this;

        // 僅捕獲 topChild, 即 最頂上的卡片 可拖動
        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            return mViewDragHelper.getViewDragState() == ViewDragHelper.STATE_IDLE  // 空閒狀態
                    && ViewHolder.getPosition(child) == getCurrentItem();           // 且是最上面的卡片, 纔可捕獲
        }
        ...

        // 手指釋放的時候回調
        @Override
        public void onViewReleased(final View releasedChild, float xvel, float yvel) {
            final int totalRange = mParent.getWidth();
            final int left = releasedChild.getLeft();
            if(Math.abs(left - 0) < totalRange/2) { // 移動 < 半屏,回到原位
                getScrollManager().smoothScrollTo(releasedChild, 0, 0, new ScrollManager.Callback() {...});

            }else { // 移動 > 半屏,移除卡片
                getScrollManager().smoothScrollTo(releasedChild, totalRange * (left < 0 ? -1 : 1), releasedChild.getTop(), new ScrollManager.Callback() {
                    @Override
                    public void onComplete(View view) {
                        removeView(view);
                        setCurrentItem(getCurrentItem() + 1);
                    }
                });
            }
        }
    });

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        return getViewDragHelper().shouldInterceptTouchEvent(event); // 轉發事件給 ViewDragHelper
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        getViewDragHelper().processTouchEvent(event); // 轉發事件給 ViewDragHelper
        return true;
    }
}複製代碼

到此爲止,已經實現了60%了。

佈局

PageTransformer: 堆疊卡片

堆疊效果,這應該是該控件最難處理的一部分了,當時也是bug不斷,調試了很久。實現方式借鑑了ViewPager.PageTransformer,外面能夠這樣調用mStackLayout.addPageTransformer(...);動畫

public class StackLayout extends FrameLayout {
    private ViewDragHelper mViewDragHelper = ViewDragHelper.create(this, new ViewDragHelper.Callback(){
        ...
        @Override
        public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
            int totalRange = mParent.getWidth();
            float position = (1.0f * (left - 0))/totalRange;
            transformPage(position, left < 0);  // 在 view 爲止變化的時候,回調動畫接口
        }
    }

    private List<PageTransformer> mPageTransformerList = new ArrayList<>();

    public void addPageTransformer(PageTransformer... pageTransformerList) {
        mPageTransformerList.addAll(Arrays.asList(pageTransformerList));
    }

    /** * 卡片滑動動畫接口, 相似 {@link ViewPager.PageTransformer} */
    public static abstract class PageTransformer {
        /** * 根據 position 作相應的動畫, 其中 position: * [-1, -1] -> 徹底移出屏幕, 待remove狀態 * (-1, 0) -> 手指拖動狀態 * [0, 棧內頁面數) -> 棧中狀態 * [棧內頁面數, 總頁面數) -> 顯示不下, 待顯示狀態 * * @param page 各卡片的根佈局, 即 {@link ViewHolder#itemView } * @param position 各卡片的位置 * @param isSwipeLeft 向左滑動 */
        public abstract void transformPage(View page, float position, boolean isSwipeLeft);
    }

    private void transformPage(float topPagePos, boolean isSwipeLeft) {
        List<PageTransformer> list = new ArrayList<>(mPageTransformerList); // 保護性複製, 防止污染原來的list
        if(list.isEmpty())
            list.add(new StackPageTransformer());   // default PageTransformer

        int itemCount = mAdapter.getItemCount();
        for(int i=0; i<itemCount; i++) {
            View page = getChildAt(i);
            if(page == null)
                return ;
            for (PageTransformer pageTransformer : list) {
                pageTransformer.transformPage(page, -Math.abs(topPagePos) + ViewHolder.getPosition(page) - getCurrentItem(), isSwipeLeft);
            }
        }
    }
}複製代碼



正如ViewPager.PageTransformer對於position分了4段(-INF, -1)、[-1, 0]、(0, 1]、(1, INF)。咱們也分了相應的幾個狀態:this

position 狀態
[-1, -1] 徹底移出屏幕, 待remove狀態
(-1, 0) 手指拖動狀態
[0, 棧內頁面數) 棧中狀態
[棧內頁面數, 總頁面數) 顯示不下, 待顯示狀態

注意到,PageTransformer是一個接口,經過實現它,咱們給出堆疊效果的真正邏輯。經過view.setTranslationY()view.setScale()調整卡片的位置和大小,使之堆疊在一塊兒。spa

外面能夠這樣調用mStackLayout.addPageTransformer(new StackPageTransformer());

public final class StackPageTransformer extends StackLayout.PageTransformer {
    private float mMinScale;    // 棧底: 最小頁面縮放比
    private float mMaxScale;    // 棧頂: 最大頁面縮放比
    private int mStackCount;    // 棧內頁面數

    private float mPowBase;     // 基底: 相鄰兩 page 的大小比例

    public StackPageTransformer(float minScale, float maxScale, int stackCount) {
        mMinScale = minScale;
        mMaxScale = maxScale;
        mStackCount = stackCount;

        if(mMaxScale < mMinScale)
            throw new IllegalArgumentException("The Argument: maxScale must bigger than minScale !");
        mPowBase = (float) Math.pow(mMinScale/mMaxScale, 1.0f/mStackCount);
    }

    public StackPageTransformer() {
        this(0.8f, 0.95f, 5);
    }

    public final void transformPage(View view, float position, boolean isSwipeLeft) {
        View parent = (View) view.getParent();

        int pageWidth = parent.getMeasuredWidth();
        int pageHeight = parent.getMeasuredHeight();

        view.setPivotX(pageWidth/2);
        view.setPivotY(pageHeight);

        float bottomPos = mStackCount-1;

        if (view.isClickable())
            view.setClickable(false);

        if (position == -1) { // [-1]: 徹底移出屏幕, 待刪除
            view.setVisibility(View.GONE);

        } else if (position < 0) { // (-1,0): 拖動中
            view.setVisibility(View.VISIBLE);

            view.setTranslationX(0);
            view.setScaleX(mMaxScale);
            view.setScaleY(mMaxScale);

        } else if (position <= bottomPos) { // [0, mStackCount-1]: 堆棧中
            int index = (int)position;  // 整數部分
            float minScale = mMaxScale * (float) Math.pow(mPowBase, index+1);
            float maxScale = mMaxScale * (float) Math.pow(mPowBase, index);
            float curScale = mMaxScale * (float) Math.pow(mPowBase, position);

            view.setVisibility(View.VISIBLE);

            // 從上至下, 調整堆疊位置
            view.setTranslationY(- pageHeight * (1-mMaxScale) * (bottomPos-position) / bottomPos);

            // 從上至下, 調整卡片大小
            float scaleFactor = minScale + (maxScale - minScale) * (1 - Math.abs(position - index));
            view.setScaleX(scaleFactor);
            view.setScaleY(scaleFactor);

            // 只有最上面一張可點擊
            if(position == 0){
                if(!view.isClickable())
                    view.setClickable(true);
            }

        } else { // (mStackCount-1, +Infinity]: 待顯示(堆棧中展現不下)
            view.setVisibility(View.GONE);
        }
    }
}複製代碼

你也能夠仿照這個類,實現自定義的堆疊效果。

PageTransformer: 側滑動畫效果

估計你也猜到了,滑動動畫仍是由它實現的。看一下內置的漸變更畫:

public final class AlphaTransformer extends StackLayout.PageTransformer {
    private float mMinAlpha = 0f;
    private float mMaxAlpha = 1f;

    public AlphaTransformer(float minAlpha, float maxAlpha) {
        mMinAlpha = minAlpha;
        mMaxAlpha = maxAlpha;
    }

    public AlphaTransformer() {
        this(0f, 1f);
    }

    @Override
    public void transformPage(View view, float position, boolean isSwipeLeft) {
        if (position > -1 && position <= 0) { // (-1,0]
            view.setVisibility(View.VISIBLE);

            view.setAlpha(mMaxAlpha - (mMaxAlpha-mMinAlpha) * Math.abs(position));
        } else{
            view.setAlpha(mMaxAlpha);
        }
    }
}複製代碼

外面則這麼使用:

mStackLayout.addPageTransformer(
        new StackPageTransformer(),     // 堆疊
        new AlphaTransformer(),         // 漸變
        ...
);複製代碼

到此爲止,已經實現了90%,再對外提供一個移除狀態回調。

OnSwipeListener: 移除狀態回調

當卡片被移除觸發回調,能夠在此時區分左滑和右滑,以及決定是否加載更多。

public class StackLayout extends FrameLayout {
    private ViewDragHelper mViewDragHelper = ViewDragHelper.create(this, new ViewDragHelper.Callback(){
        // 手指釋放的時候回調
        @Override
        public void onViewReleased(final View releasedChild, float xvel, float yvel) {
            if(Math.abs(left - 0) < totalRange/2) {
                ...
            }else {
                getScrollManager().smoothScrollTo(releasedChild, totalRange * (left < 0 ? -1 : 1), releasedChild.getTop(), new ScrollManager.Callback() {
                    @Override public void onComplete(View view) {
                        ...
                        mOnSwipeListener.onSwiped(view, ViewHolder.getPosition(view), left < 0, mAdapter.getItemCount() - getCurrentItem());
                    }
                });
            }
        }
    });

    private OnSwipeListener mOnSwipeListener;
    public static abstract class OnSwipeListener{
        /** * 已被移除屏幕時回調. 另外, 能夠根據 itemLeft, 決定什麼時候加載更多. * * @param swipedView 被移除屏幕的view * @param swipedItemPos swipedView 對應的 AdapterPos * @param isSwipeLeft 往左滑動 * @param itemLeft 當前剩餘的item個數 (棧中的 + 待顯示的) */
        public abstract void onSwiped(View swipedView, int swipedItemPos, boolean isSwipeLeft, int itemLeft);
    }
}複製代碼


其餘細節

  • dataChanged()中,注意在刷新數據延遲到上一次動畫結束以後。
  • 在上一次動畫徹底結束以前,不可滑動下一張卡片。
  • 某些時候,view.getWidth()可能爲0。

總結,Api設計上基本上致敬了RecyclerViewViewPager。總的來講,本身也有很大的收穫。

參考

github.com/flschweiger…

github.com/xiepeijie/S…

github.com/mcxtzhang/Z…

github.com/yuqirong/Ca…

github.com/xmuSistone/…

相關文章
相關標籤/搜索