安卓开发自定义stackLayout实现卡片滑动效果

问题背景

安卓日常学习和开发过程中经常会涉及到卡片切换的效果,本文将介绍实现卡片滑动效果的一种方案。

问题分析

话不多说,直接上效果图: 1684371497506.gif

问题解决

话不多说直接上代码: (1)自定义StackLayoutManager.java文件如下:

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.os.Build;
import android.util.Log;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewConfiguration;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import androidx.annotation.RequiresApi;
import androidx.recyclerview.widget.RecyclerView;
import static androidx.recyclerview.widget.RecyclerView.NO_POSITION;

//自定义RecyclerView.LayoutManager布局管理类,实现卡片布局
public class StackLayoutManager extends RecyclerView.LayoutManager {

    private static final String TAG = "StackLayoutManager";

    //the space unit for the stacked item
    private int mSpace = 60;
    /**
     * the offset unit,deciding current position(the sum of  {@link #mItemWidth} and {@link #mSpace})
     */
    private int mUnit;
    //item width
    private int mItemWidth;
    private int mItemHeight;
    //the counting variable ,record the total offset including parallex
    private int mTotalOffset;
    //record the total offset without parallex
    private int mRealOffset;
    private ObjectAnimator animator;
    private int animateValue;
    private int duration = 300;
    private RecyclerView.Recycler recycler;
    private int lastAnimateValue;
    //the max stacked item count;
    private int maxStackCount = 4;
    //initial stacked item
    private int initialStackCount = 4;
    private float secondaryScale = 0.8f;
    private float scaleRatio = 0.4f;
    private float parallex = 1f;
    private int initialOffset;
    private boolean initial;
    private int mMinVelocityX;
    private VelocityTracker mVelocityTracker = VelocityTracker.obtain();
    private int pointerId;
    private Align direction = LEFT;
    private RecyclerView mRV;
    private Method sSetScrollState;
    private int mPendingScrollPosition = NO_POSITION;

    public StackLayoutManager(Config config) {
        this();
        this.maxStackCount = 2;
        this.mSpace = 100;
        this.initialStackCount = config.initialStackCount;
        this.secondaryScale = config.secondaryScale;
        this.scaleRatio = config.scaleRatio;
        this.direction = config.align;
        this.parallex = config.parallex;
    }


    @SuppressWarnings("unused")
    public StackLayoutManager() {
        setAutoMeasureEnabled(true);
    }

    @Override
    public boolean isAutoMeasureEnabled() {
        return  true;
    }

    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        if (getItemCount() <= 0) {
            return;
        }
        this.recycler = recycler;
        detachAndScrapAttachedViews(recycler);
        //got the mUnit basing on the first child,of course we assume that  all the item has the same size
        View anchorView = recycler.getViewForPosition(0);
        measureChildWithMargins(anchorView, 0, 0);
        mItemWidth = anchorView.getMeasuredWidth();
        mItemHeight = anchorView.getMeasuredHeight();

        mSpace = (getWidth() - mItemWidth) / 2;
//        if (canScrollHorizontally())
//            mUnit = mItemWidth + mSpace;
//        else mUnit = mItemHeight + mSpace;
        // TODO: 2019/2/14 test
        if (canScrollHorizontally()) {
            // mItemWidth < mSpace时可能出现问题。
            mUnit = mItemWidth - mSpace;
        }
        //because this method will be called twice
        initialOffset = resolveInitialOffset();
        mMinVelocityX = ViewConfiguration.get(anchorView.getContext()).getScaledMinimumFlingVelocity();
        fill(recycler, 0);

    }

    //we need take direction into account when calc initialOffset
    private int resolveInitialOffset() {
        int offset = initialStackCount * mUnit;
        if (mPendingScrollPosition != NO_POSITION) {
            offset = mPendingScrollPosition * mUnit;
            mPendingScrollPosition = NO_POSITION;
        }

        return offset;
    }

    @Override
    public void onLayoutCompleted(RecyclerView.State state) {
        super.onLayoutCompleted(state);
        if (getItemCount()<=0) {
            return;
        }
        if (!initial) {
            fill(recycler, initialOffset, false);
            initial = true;
        }
    }

    @Override
    public void onAdapterChanged(RecyclerView.Adapter oldAdapter, RecyclerView.Adapter newAdapter) {
        initial = false;
        mTotalOffset = mRealOffset = 0;
    }

    /**
     * the magic function :).all the work including computing ,recycling,and layout is done here
     *
     * @param recycler ...
     */
    private int fill(RecyclerView.Recycler recycler, int dy, boolean apply) {
        int delta = direction.layoutDirection * dy;
        // multiply the parallex factor
        if (apply) {
            delta = (int) (delta * parallex);
        }
        return fillFromLeft(recycler, delta);
    }

    public int fill(RecyclerView.Recycler recycler, int dy) {
        return fill(recycler, dy, true);
    }


    private int fillFromLeft(RecyclerView.Recycler recycler, int dy) {
        if (mTotalOffset + dy < 0 || (mTotalOffset + dy + 0f) / mUnit > getItemCount() - 1) {
            return 0;
        }
        detachAndScrapAttachedViews(recycler);
        mTotalOffset += direction.layoutDirection * dy;
        int count = getChildCount();
        //removeAndRecycle  views
        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);
            if (recycleHorizontally(child, dy)) {
                removeAndRecycleView(child, recycler);
            }
        }


        int currPos = mTotalOffset / mUnit;
        int leavingSpace = getWidth() - (left(currPos) + mUnit);
        int itemCountAfterBaseItem = leavingSpace / mUnit + 2;
        int e = currPos + itemCountAfterBaseItem;

        int start = currPos - maxStackCount >= 0 ? currPos - maxStackCount : 0;
        int end = e >= getItemCount() ? getItemCount() - 1 : e;

        //layout view
        for (int i = start; i <= end; i++) {
            View view = recycler.getViewForPosition(i);

            float scale = scale(i);
            float alpha = alpha(i);

            addView(view);
            measureChildWithMargins(view, 0, 0);
            int left = (int) (left(i) - (1 - scale) * view.getMeasuredWidth() / 2);
            int top = 0;
            int right = left + view.getMeasuredWidth();
            int bottom = top + view.getMeasuredHeight();
            layoutDecoratedWithMargins(view, left, top, right, bottom);
            view.setAlpha(alpha);
            view.setScaleY(scale);
            view.setScaleX(scale);
        }

        return dy;
    }

    private View.OnTouchListener mTouchListener = new View.OnTouchListener() {
        @Override
        public boolean onTouch(View v, MotionEvent event) {
            mVelocityTracker.addMovement(event);
            if (event.getAction() == MotionEvent.ACTION_DOWN) {
                if (animator != null && animator.isRunning()) {
                    animator.cancel();
                }
                pointerId = event.getPointerId(0);

            }
            if (event.getAction() == MotionEvent.ACTION_UP) {
                if (v.isPressed()) {
                    v.performClick();
                }
                mVelocityTracker.computeCurrentVelocity(1000, 14000);
                float xVelocity = mVelocityTracker.getXVelocity(pointerId);
                int o = mTotalOffset % mUnit;
                int scrollX;
                if (Math.abs(xVelocity) < mMinVelocityX && o != 0) {
                    if (o >= mUnit / 2) {
                        scrollX = mUnit - o;
                    } else {
                        scrollX = -o;
                    }
                    int dur = (int) (Math.abs((scrollX + 0f) / mUnit) * duration);
                    Log.i(TAG, "onTouch: ======BREW===");
                    brewAndStartAnimator(dur, scrollX);
                }
            }
            return false;
        }

    };

    private RecyclerView.OnFlingListener mOnFlingListener = new RecyclerView.OnFlingListener() {
        @RequiresApi(api = Build.VERSION_CODES.KITKAT)
        @Override
        public boolean onFling(int velocityX, int velocityY) {
            int o = mTotalOffset % mUnit;
            int s = mUnit - o;
            int scrollX;
            int vel = absMax(velocityX, velocityY);
            if (vel * direction.layoutDirection > 0) {
                scrollX = s;
            } else {
                scrollX = -o;
            }
            int dur = computeSettleDuration(Math.abs(scrollX), Math.abs(vel));
            brewAndStartAnimator(dur, scrollX);
            setScrollStateIdle();
            return true;
        }
    };

    private int absMax(int a, int b) {
        if (Math.abs(a) > Math.abs(b)) {
            return a;
        } else {
            return b;
        }
    }

    @Override
    public void onAttachedToWindow(RecyclerView view) {
        super.onAttachedToWindow(view);
        mRV = view;
        //check when raise finger and settle to the appropriate item
        view.setOnTouchListener(mTouchListener);

        view.setOnFlingListener(mOnFlingListener);
    }

    private int computeSettleDuration(int distance, float xvel) {
        float sWeight = 0.5f * distance / mUnit;
        float velWeight = xvel > 0 ? 0.5f * mMinVelocityX / xvel : 0;

        return (int) ((sWeight + velWeight) * duration);
    }

    private void brewAndStartAnimator(int dur, int finalXorY) {
        animator = ObjectAnimator.ofInt(StackLayoutManager.this, "animateValue", 0, finalXorY);
        animator.setDuration(dur);
        animator.start();
        animator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                lastAnimateValue = 0;
            }

            @Override
            public void onAnimationCancel(Animator animation) {
                lastAnimateValue = 0;
            }
        });
    }

    /******************************precise math method*******************************/
    private float alpha(int position) {
        float alpha;
        int currPos = mTotalOffset / mUnit;
        float n = (mTotalOffset + .0f) / mUnit;
        if (position > currPos) {
            alpha = 1.0f;
        } else {
            //temporary linear map,barely ok
            alpha = 1 - (n - position) / maxStackCount;
        }
        //for precise checking,oh may be kind of dummy
        return alpha <= 0.001f ? 0 : alpha;
    }

    private float scale(int position) {
        switch (direction) {
            default:
            case LEFT:
//            case RIGHT:
                return scaleDefault(position);
        }
    }

    private float scaleDefault(int position) {

        float scale;
        int currPos = this.mTotalOffset / mUnit;
        float n = (mTotalOffset + .0f) / mUnit;
        float x = n - currPos;
        // position >= currPos+1;
        if (position >= currPos) {
            if (position == currPos) {
                scale = 1 - scaleRatio * (n - currPos) / maxStackCount;
            } else if (position == currPos + 1)
            //let the item's (index:position+1) scale be 1 when the item slide 1/2 mUnit,
            // this have better visual effect
            {
//                scale = 0.8f + (0.4f * x >= 0.2f ? 0.2f : 0.4f * x);
                scale = secondaryScale + (x > 0.5f ? 1 - secondaryScale : 2 * (1 - secondaryScale) * x);
            } else {
                scale = secondaryScale;
            }
        } else {//position <= currPos
            if (position < currPos - maxStackCount) {
                scale = 0f;
            } else {
                scale = 1f - scaleRatio * (n - currPos + currPos - position) / maxStackCount;
            }
        }
        return scale;
    }

    /**
     * @param position the index of the item in the adapter
     * @return the accurate left position for the given item
     */
    private int left(int position) {
        int currPos = mTotalOffset / mUnit;
        int tail = mTotalOffset % mUnit;
        float n = (mTotalOffset + .0f) / mUnit;
        float x = n - currPos;
        switch (direction) {
            default:
            case LEFT:
//            case TOP:
                //from left to right or top to bottom
                //these two scenario are actually same
                return ltr(position, currPos, tail, x);
        }
    }

    private int ltr(int position, int currPos, int tail, float x) {
        int left;

        if (position <= currPos) {

            if (position == currPos) {
                left = (int) (mSpace * (maxStackCount - x));
                left = left - mSpace;
            } else {
                left = (int) (mSpace * (maxStackCount - x - (currPos - position)));
                left = left - mSpace;
            }
        } else {
            if (position == currPos + 1) {
                left = mSpace * maxStackCount + mUnit - tail;
            } else {
                float closestBaseItemScale = scale(currPos + 1);

                //调整因为scale导致的left误差
//                left = (int) (mSpace * maxStackCount + (position - currPos) * mUnit - tail
//                        -(position - currPos)*(mItemWidth) * (1 - closestBaseItemScale));

                int baseStart = (int) (mSpace * maxStackCount + mUnit - tail + closestBaseItemScale * (mUnit - mSpace) + mSpace);
                left = (int) (baseStart + (position - currPos - 2) * mUnit - (position - currPos - 2) * (1 - secondaryScale) * (mUnit - mSpace));
//                if (BuildConfig.DEBUG) {
//                    Log.i(TAG, "ltr: currPos " + currPos
//                            + "  pos:" + position
//                            + "  left:" + left
//                            + "   baseStart" + baseStart
//                            + " currPos+1:" + left(currPos + 1));
//                }

            }
//            left = left - 8;
            left = left <= 0 ? 0 : left;
        }
        return left;
    }


    @SuppressWarnings("unused")
    public void setAnimateValue(int animateValue) {
        this.animateValue = animateValue;
        int dy = this.animateValue - lastAnimateValue;
        fill(recycler, direction.layoutDirection * dy, false);
        lastAnimateValue = animateValue;
    }

    @SuppressWarnings("unused")
    public int getAnimateValue() {
        return animateValue;
    }

    /**
     * should recycle view with the given dy or say check if the
     * view is out of the bound after the dy is applied
     *
     * @param view ..
     * @param dy   ..
     * @return ..
     */
    private boolean recycleHorizontally(View view/*int position*/, int dy) {
        return view != null && (view.getLeft() - dy < 0 || view.getRight() - dy > getWidth());
    }

    private boolean recycleVertically(View view, int dy) {
        return view != null && (view.getTop() - dy < 0 || view.getBottom() - dy > getHeight());
    }


    @Override
    public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
        return fill(recycler, dx);
    }

    @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
        return fill(recycler, dy);
    }

    @Override
    public boolean canScrollHorizontally() {
        return direction == LEFT || direction == RIGHT;
    }

    @Override
    public boolean canScrollVertically() {
        return direction == TOP || direction == BOTTOM;
    }

    @Override
    public RecyclerView.LayoutParams generateDefaultLayoutParams() {
        return new RecyclerView.LayoutParams(RecyclerView.LayoutParams.WRAP_CONTENT, RecyclerView.LayoutParams.WRAP_CONTENT);
    }

    /**
     * we need to set scrollstate to {@link RecyclerView#SCROLL_STATE_IDLE} idle
     * stop RV from intercepting the touch event which block the item click
     */
    @RequiresApi(api = Build.VERSION_CODES.KITKAT)
    private void setScrollStateIdle() {
        try {
            if (sSetScrollState == null) {
                sSetScrollState = RecyclerView.class.getDeclaredMethod("setScrollState", int.class);
            }
            sSetScrollState.setAccessible(true);
            sSetScrollState.invoke(mRV, RecyclerView.SCROLL_STATE_IDLE);
        } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void scrollToPosition(int position) {
        if (position > getItemCount() - 1) {
            Log.i(TAG, "position is " + position + " but itemCount is " + getItemCount());
            return;
        }
        int currPosition = mTotalOffset / mUnit;
        int distance = (position - currPosition) * mUnit;
        int dur = computeSettleDuration(Math.abs(distance), 0);
        brewAndStartAnimator(dur, distance);
    }

    @Override
    public void requestLayout() {
        super.requestLayout();
        initial = false;
    }

    @SuppressWarnings("unused")
    public interface CallBack {

        float scale(int totalOffset, int position);

        float alpha(int totalOffset, int position);

        float left(int totalOffset, int position);
    }
}

(2)使用到的Align.java类如下:

public enum Align {


    LEFT(1),
    RIGHT(-1),
    TOP(1),
    BOTTOM(-1);

    public int layoutDirection;

    Align(int sign) {
        this.layoutDirection = sign;
    }
}

(3)使用到的Config.java类如下:

public class Config {
    public int space = 60;
    public int maxStackCount = 3;
    public int initialStackCount = 0;
    public float secondaryScale;
    public float scaleRatio;
    /**
     * the real scroll distance might meet requirement,
     * so we multiply a factor fro parallex
     */
    public float parallex = 1f;
    // 布局方向
    public Align align;
}

(4)布局layout文件如下(项目使用了databinding,可以直接去除最外层使用):

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <ImageView
            android:id="@+id/iv_account_top"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:scaleType="fitXY"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:srcCompat="@drawable/bg_account_top" />

        <TextView
            android:id="@+id/title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="31dp"
            android:text="协同办公"
            android:textSize="19sp"
            android:textColor="@color/white"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"/>

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_marginTop="110dp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:orientation="vertical"
            android:layout_height="wrap_content">
            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/recyclerview"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:overScrollMode="never"
                app:layoutManager="@string/stacklayoutmanager"
                android:layout_gravity="center_horizontal"
                tools:listitem="@layout/layout_work_together_view"/>

        </LinearLayout>

        <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@drawable/ic_scroll_left_right"
            android:layout_marginBottom="20dp"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintBottom_toBottomOf="parent" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

(5)java层调用核心代码如下:

    private void initView() {
        Config config = new Config();
        config.secondaryScale = 0.7f;
        config.scaleRatio = 0.6f;
        config.maxStackCount = 2;
        config.initialStackCount = 1;
        config.space = 100;
        config.align = Align.LEFT;

        layoutManager = new StackLayoutManager(config);

        mDataBinding.recyclerview.setLayoutManager(layoutManager);
        adapter = new TogetherWorkAdapter(this.getContext(), datas);
        adapter.setOnItemClickLitener(this);
        mDataBinding.recyclerview.setAdapter(adapter);
    }

问题总结

本文介绍了安卓开发中实现卡片左右滑动效果的一种方案,有兴趣的同学可以进一步深入研究。