Bootstrap

Android RecyclerView实现卡片堆叠视图

Android RecyclerView实现卡片堆叠视图

前言

最近需求里面要实现一个卡片堆叠的视图,通过卡片上下移动来切换阅读的条目,github和csdn找了不少都不能完全符合需求,所以最后还是看着别人代码一步步学着怎么实现的

需要完全实现主要是需要自定义以下几个类:
LayoutManager 用于实现堆叠视图以及视图的滑动
SnapHelper 用于帮助视图定位,可以让视图能够向前或者向后停留在刚好一页的位置

实现RecyclerView的卡片视图

我这里直接通过CardView来实现

<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="360dp"
    android:layout_height="500dp"
    app:cardCornerRadius="20dp"
    app:cardElevation="20dp"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <ImageView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/isa_iv"
        android:scaleType="fitStart"
        android:src="@drawable/xm9"
        />
</androidx.cardview.widget.CardView>

然后写个简单的Adapter

class StackCardAdapter: RecyclerView.Adapter<StackCardViewHolder>() {
    public var data = arrayListOf(
        R.drawable.xm1,
        R.drawable.xm2,
        R.drawable.xm3,
        R.drawable.xm4,
        R.drawable.xm5,
        R.drawable.xm6,
        R.drawable.xm7,
        R.drawable.xm8,
        R.drawable.xm9,
    )
    override fun onCreateViewHolder(viewgroup: ViewGroup, type: Int): StackCardViewHolder {
        return StackCardViewHolder( LayoutInflater.from(viewgroup.context)
            .inflate(R.layout.item_sc_ad, viewgroup, false))
    }

    override fun getItemCount(): Int {
        return data.size
    }

    override fun onBindViewHolder(viewHolder: StackCardViewHolder, position: Int) {
        viewHolder.itemView.findViewById<ImageView>(R.id.isa_iv).setImageResource(data.get(position))
    }
}
class StackCardViewHolder(itemView: View): RecyclerView.ViewHolder(itemView){
}

但目前用的还是基本的LinearLayoutManager,这样排列就会是线性布局,那么就需要我们自定义LayoutManager来实现不同的排列方式

LayoutManager会在每次视图发生变化的时候调用onLayoutChildren,所以我们在这里通过measureChild和measureChildWithMargins来确认子view的高宽的信息,前者不会测量分割线长度,后者是带上分割线的测量

实现代码如下

class StackCardLayoutTestManager: RecyclerView.LayoutManager(){
    private val TAG = "StackCardLayoutTestManager"
    //子视图的高宽
    private var mChildHeight = 0
    private var mChildWidth = 0
    //基准中心坐标
    private var mBaseCenterX = 0
    private var mBaseCenterY = 0
    //每个视图的偏移量
    private var mBaseOffSetY = 50
    
    //必须要实现此方法
    override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
        return RecyclerView.LayoutParams(
            RecyclerView.LayoutParams.WRAP_CONTENT,
            RecyclerView.LayoutParams.WRAP_CONTENT
        )
    }
    override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
        initialize(recycler)
        drawChildren(recycler, state)
    }
    private fun initialize(recycler: Recycler){
        //移除所绘制的所有view
        detachAndScrapAttachedViews(recycler)
        //这里视图里面默认每个子视图都是相同的高宽大小
        var itemView = recycler.getViewForPosition(0)
        addView(itemView)
        measureChildWithMargins(itemView, 0, 0)
        mChildHeight = getDecoratedMeasuredHeight(itemView)
        mChildWidth = getDecoratedMeasuredWidth(itemView)
        mBaseCenterX = width/2
        mBaseCenterY = height/2
        detachAndScrapAttachedViews(recycler)
    }
    private fun drawChildren(recycler: Recycler, state: State){
        detachAndScrapAttachedViews(recycler)
        /**
         * 这里后绘制的视图会重叠在先绘制的视图之上
         * 所以这里采用倒序,先绘制后面的视图,再绘制之前的
         */
        for (i in  state.itemCount -1 downTo 0){
            var itemView = recycler.getViewForPosition(i)
            addView(itemView)
            measureChildWithMargins(itemView, 0, 0)
            layoutDecoratedWithMargins(itemView,
                mBaseCenterX - mChildWidth/2,
                mBaseCenterY - mChildHeight/2 + mBaseOffSetY * i,
                mBaseCenterX + mChildWidth/2,
                mBaseCenterY + mChildHeight/2 + mBaseOffSetY * i)
            itemView.alpha = 1f - 0.05f * i
            itemView.scaleX = 1f - 0.05f * i
            itemView.scaleY = 1f - 0.05f * i
        }
    }
}

这样刷新一下界面,子视图排列就会变成这样基础堆叠视图
这样就算是一个基础的视图了,但目前来说还不能进行滑动,所以接下来我们要实现滑动子视图的滑动

实现滑动

LayoutManager会通过canScrollHorizontallx和canScrollHorizontally方法来判断是否能够进行横向或者纵向滑动,然后分别在scrollVerticallyBy和scrollHorizontallyBy来分别处理纵向和横向的滑动事件,我们可以根据传入的dx和dy参数来更改绘制子视图的位置信息,从而达到滑动的效果,因此代码修改如下:

class StackCardLayoutTestManager: RecyclerView.LayoutManager(){
    private val TAG = "StackCardLayoutTestManager"
    //目前就只考虑纵向滑动了
    private val mScrollDirection = LinearLayout.VERTICAL
    //子视图的高宽
    private var mChildHeight = 0
    private var mChildWidth = 0
    //基准中心坐标
    private var mBaseCenterX = 0
    private var mBaseCenterY = 0
    //每个视图的偏移量
    private var mBaseOffSetY = 50
    //滑动的总距离
    private var mTotalScrollY = 0
    //当前的卡片位置
    private var mCurrentPosition = 0
    //当前卡片滑动的百分比
    private var mCurrentRatio = 0f
    //基础的透明度
    private val mBaseAlpha = 1.0f
    //基础的缩放值
    private val mBaseScale = 1.0f
    //每张堆叠卡片的透明度变化
    private val mBaseAlphaChange = 0.05f
    //每张堆叠卡片的缩放变化
    private val mBaseScaleChange = 0.05f
    //每张卡片绘制时的Y轴位移
    private val mBaseOffSetYChange = 60
    
    //必须要实现此方法
    override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
        return RecyclerView.LayoutParams(
            RecyclerView.LayoutParams.WRAP_CONTENT,
            RecyclerView.LayoutParams.WRAP_CONTENT
        )
    }
    override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
        initialize(recycler)
        drawChildren(recycler, state)
    }
    //是否能够水平滑动
    override fun canScrollHorizontally(): Boolean {
        return mScrollDirection == LinearLayoutManager.HORIZONTAL
    }
    //是否能够竖向滑动
    override fun canScrollVertically(): Boolean {
        return mScrollDirection == LinearLayoutManager.VERTICAL
    }

    private fun initialize(recycler: Recycler){
        //移除所绘制的所有view
        detachAndScrapAttachedViews(recycler)
        //这里视图里面默认每个子视图都是相同的高宽大小
        var itemView = recycler.getViewForPosition(0)
        addView(itemView)
        measureChildWithMargins(itemView, 0, 0)
        mChildHeight = getDecoratedMeasuredHeight(itemView)
        mChildWidth = getDecoratedMeasuredWidth(itemView)
        mBaseCenterX = width/2
        mBaseCenterY = height/2
        detachAndScrapAttachedViews(recycler)
    }

    private fun drawChildren(recycler: Recycler, state: State , dy: Int = 0): Int{
        detachAndScrapAttachedViews(recycler)
        //向上滑动,滑动距离为负数 
        mTotalScrollY += dy * -1
        //第一张图禁止向下滑动
        if (mTotalScrollY >= 0) mTotalScrollY = 0
        //最后一张图禁止向上滑动
        if (mTotalScrollY <= -(state.itemCount-1) * mChildHeight) mTotalScrollY = -(state.itemCount-1) * mChildHeight
        mCurrentPosition = Math.abs(mTotalScrollY / mChildHeight)
        //偏移量
        var offSetY = 0
        //透明度
        var alpha = 1.0f
        //缩放大小
        var scale = 1.0f
        //百分比,当前卡片剩余进行长度占总长度的比例
        mCurrentRatio = 1- Math.abs((mTotalScrollY + mCurrentPosition* mChildHeight).toFloat() / mChildHeight.toFloat())
        /**
         * 这里后绘制的视图会重叠在先绘制的视图之上
         * 所以这里采用倒序,先绘制后面的视图,再绘制之前的
         * 关于回收问题,直接仅绘制所在位置以及之后的四张即可
         */
        for (i in state.itemCount -1 downTo  0){
            /**
             * 以当前堆叠卡片的最上方视图为基准
             * 从这里往上的视图需要跟随滑动事件向上进行滑动处理
             * 大于当前视图的会是堆叠视图,就跟随最上方视图滑动的百分比,同步向上传递
             */
            if (i <= mCurrentPosition){
                offSetY = mTotalScrollY - -1 *mChildHeight * i
            }else{
                offSetY = (mBaseOffSetYChange * (i-1) - mCurrentRatio * mBaseOffSetYChange * -1 + (mCurrentPosition) * mBaseOffSetYChange * -1).toInt()
            }
            alpha = mBaseAlpha - mBaseAlphaChange * i
            scale = mBaseScale - mBaseScaleChange * i
            var view = recycler.getViewForPosition(i)
            measureChildWithMargins(view, 0, 0)
            addView(view)
            layoutDecoratedWithMargins(view, mBaseCenterX - mChildWidth/2, mBaseCenterY - mChildHeight/2 + offSetY, mBaseCenterX + mChildWidth/2, mBaseCenterY+ mChildHeight/2 + offSetY)

            view.alpha = alpha
            view.scaleX = scale
            view.scaleY = scale
        }
        //位置滑动到最底部的时候不再进行滑动处理,返回值为0
        return if (mTotalScrollY == 0 || mTotalScrollY == -(state.itemCount -1) * mChildHeight) 0 else dy
    }

    override fun scrollVerticallyBy(dy: Int, recycler: Recycler, state: State): Int {
        return drawChildren(recycler, state, dy)
    }

    override fun scrollHorizontallyBy(dx: Int, recycler: Recycler, state: State): Int {
        return super.scrollHorizontallyBy(dx, recycler, state)
    }
}

这样就能够实现滑动效果

基本滑动效果

以上就能实现基础的堆叠视图效果和滑动事件的处理,但这样并不能达成需求
所以添上一些优化,包括绘制数量、卡片的透明度缩放值等都跟随堆叠顶部的卡片位置变化
可以修改成以下代码


class StackCardLayoutTestManager: RecyclerView.LayoutManager(){
    private val TAG = "StackCardLayoutTestManager"
    //目前就只考虑纵向滑动了
    private val mScrollDirection = LinearLayout.VERTICAL
    //子视图的高宽
    private var mChildHeight = 0
    private var mChildWidth = 0
    //基准中心坐标
    private var mBaseCenterX = 0
    private var mBaseCenterY = 0
    //每个视图的偏移量
    private var mBaseOffSetY = 50
    //滑动的总距离
    private var mTotalScrollY = 0
    //当前的卡片位置
    private var mCurrentPosition = 0
    //当前卡片滑动的百分比
    private var mCurrentRatio = 0f
    //基础的透明度
    private val mBaseAlpha = 1.0f
    //基础的缩放值
    private val mBaseScale = 1.0f
    //当卡片滑动出去时的缩放值
    private val mOutCardScale = 0.8f
    //当卡片滑动出去时的透明度
    private val mOutCardAlpha = 0.0f
    //每张堆叠卡片的透明度变化
    private val mBaseAlphaChange = 0.3f
    //每张堆叠卡片的缩放变化
    private val mBaseScaleChange = 0.05f
    //每张卡片绘制时的Y轴位移
    private val mBaseOffSetYChange = 60
    
    //必须要实现此方法
    override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
        return RecyclerView.LayoutParams(
            RecyclerView.LayoutParams.WRAP_CONTENT,
            RecyclerView.LayoutParams.WRAP_CONTENT
        )
    }
    override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
        initialize(recycler)
        drawChildren(recycler, state)
    }
    //是否能够水平滑动
    override fun canScrollHorizontally(): Boolean {
        return mScrollDirection == LinearLayoutManager.HORIZONTAL
    }
    //是否能够竖向滑动
    override fun canScrollVertically(): Boolean {
        return mScrollDirection == LinearLayoutManager.VERTICAL
    }

    private fun initialize(recycler: Recycler){
        //移除所绘制的所有view
        detachAndScrapAttachedViews(recycler)
        //这里视图里面默认每个子视图都是相同的高宽大小
        var itemView = recycler.getViewForPosition(0)
        addView(itemView)
        measureChildWithMargins(itemView, 0, 0)
        mChildHeight = getDecoratedMeasuredHeight(itemView)
        mChildWidth = getDecoratedMeasuredWidth(itemView)
        mBaseCenterX = width/2
        mBaseCenterY = height/2
        detachAndScrapAttachedViews(recycler)
    }

    private fun drawChildren(recycler: Recycler, state: State , dy: Int = 0): Int{
        detachAndScrapAttachedViews(recycler)
        //向上滑动,滑动距离为负数 
        mTotalScrollY += dy * -1
        //第一张图禁止向下滑动
        if (mTotalScrollY >= 0) mTotalScrollY = 0
        //最后一张图禁止向上滑动
        if (mTotalScrollY <= -(state.itemCount-1) * mChildHeight) mTotalScrollY = -(state.itemCount-1) * mChildHeight
        mCurrentPosition = Math.abs(mTotalScrollY / mChildHeight)
        //偏移量
        var offSetY = 0
        //透明度
        var alpha = 1.0f
        //缩放大小
        var scale = 1.0f
        //百分比,当前卡片剩余进行长度占总长度的比例
        mCurrentRatio = 1- Math.abs((mTotalScrollY + mCurrentPosition* mChildHeight).toFloat() / mChildHeight.toFloat())
        /**
         * 这里后绘制的视图会重叠在先绘制的视图之上
         * 所以这里采用倒序,先绘制后面的视图,再绘制之前的
         * 关于回收问题,直接仅绘制所在位置以及之后的四张即可
         */
        for (i in Math.min(mCurrentPosition + 4, state.itemCount -1) downTo mCurrentPosition){
            if (i == mCurrentPosition){
                offSetY = mTotalScrollY - -1 *mChildHeight *i
                alpha = mBaseAlpha
                scale = mOutCardScale + (mBaseScale - mOutCardScale)* mCurrentRatio
            } else if (i < mCurrentPosition){
                offSetY = mTotalScrollY - -1 *mChildHeight *i
                alpha = mOutCardAlpha
                scale = mOutCardScale
            }else{
                alpha = mBaseAlpha - mBaseAlphaChange * (i-1) - mCurrentRatio* mBaseAlphaChange + mCurrentPosition* mBaseAlphaChange
                scale = mBaseScale- mBaseScaleChange * (i-1) - mCurrentRatio* mBaseScaleChange + (mCurrentPosition)* mBaseScaleChange
                offSetY = (mBaseOffSetYChange * (i-1) - mCurrentRatio * mBaseOffSetYChange * -1 + (mCurrentPosition) * mBaseOffSetYChange * -1).toInt()
            }
            var view = recycler.getViewForPosition(i)
            measureChildWithMargins(view, 0, 0)
            addView(view)
            layoutDecoratedWithMargins(view, mBaseCenterX - mChildWidth/2, mBaseCenterY - mChildHeight/2 + offSetY, mBaseCenterX + mChildWidth/2, mBaseCenterY+ mChildHeight/2 + offSetY)

            view.alpha = alpha
            view.scaleX = scale
            view.scaleY = scale
        }
        //位置滑动到最底部的时候不再进行滑动处理,返回值为0
        return if (mTotalScrollY == 0 || mTotalScrollY == -(state.itemCount -1) * mChildHeight) 0 else dy
    }

    override fun scrollVerticallyBy(dy: Int, recycler: Recycler, state: State): Int {
        return drawChildren(recycler, state, dy)
    }

    override fun scrollHorizontallyBy(dx: Int, recycler: Recycler, state: State): Int {
        return super.scrollHorizontallyBy(dx, recycler, state)
    }
}

滑动效果现在就会修改成这样
请添加图片描述
这样离要求就很接近了,但还有不足,这里还需要让滑动之后的RecyclerView自动对齐位置
所以接下来就需要处理自动对齐的问题

自动对齐

Android将手指离开屏幕后视图自动滑动的事件定义为fling事件,也就是抛掷事件实现自动对齐可以有两个方法,一是直接使用RecyclerView.SetOnFlingListener来处理抛掷事件,二是通过SnapHelper来处理抛掷事件,这两个方法实际上是同一事件的处理,使用了SnapHelper就不能在设置抛掷监听,否则要么不生效要么会闪退

我这里继承SnapHelper来处理
继承之后要实现三个方法,分别是 findSnapView,calculateDistanceToFinalSnap, findTargetSnapPosition三个方法,第一个方法将会根据LayoutManager来确定目标视图在子视图组里的位置,然后需要通过 calculateDistanceToFinalSnap 来计算当前位置和目标视图位置距离,之后SnapHelper在滑动开始和结束时分别调用一次 calculateDistanceToFinalSnap 如果不能得到距离为0就会持续进行调用, 最后是findTargetSnapPosition 方法,这里传入的值意思是抛掷本身应该滑动到的位置,可以根据这个值来确定是否需要滑动到下一页,同时,使用SnapHelper会要求Layoutmanager必须继承RecyclerView.SmoothScroller.ScrollVectorProvider

以代码如下

class StackCardSnapHelper_2: PagerSnapHelper() {
    override fun calculateDistanceToFinalSnap(
        layoutManager: RecyclerView.LayoutManager,
        targetView: View
    ): IntArray? {
        var out = intArrayOf(0, 0)
        if (layoutManager.canScrollVertically()){
            out[0] = 0
            out[1] = (layoutManager as StackCardLayoutTestManager)
                .getDistanceToCenter(layoutManager.getPosition(targetView))
            return out
        }
        return null
    }
    override fun findSnapView(layoutManager: RecyclerView.LayoutManager?): View? {
        if (layoutManager is StackCardLayoutTestManager){
            var position = layoutManager.confirmTargetPosition()
            if (position != RecyclerView.NO_POSITION){
                return layoutManager.findViewByPosition(position)
            }
        }
        return null
    }
    override fun findTargetSnapPosition(
        layoutManager: RecyclerView.LayoutManager?,
        velocityX: Int,
        velocityY: Int
    ): Int {
        var position = (layoutManager as StackCardLayoutTestManager).findTargetPosition(velocityY)
        return position
    }
}
//在Layoutmanager中添加
class StackCardLayoutTestManager:
    RecyclerView.LayoutManager(),
    RecyclerView.SmoothScroller.ScrollVectorProvider
{
	······

    fun getDistanceToCenter(targetPosition: Int): Int{
        var distance = 0
        distance = mTotalScrollY - mChildHeight * targetPosition * -1
        return distance
    }
    fun findTargetPosition(velocityY: Int):Int{
        val pos = Math.abs(Math.floor(mTotalScrollY.toDouble() / mChildHeight).toInt())
        if (velocityY >=600) return  pos
        if (velocityY <= -600) return  pos -1
        return if (mCurrentRatio > 0.5f) {
            pos - 1
        } else {
            pos
        }
    }
    fun confirmTargetPosition(): Int{
        var positionOffset = if(mCurrentRatio >= 0.5f) 0 else 1
        return mCurrentPosition + positionOffset
    }
    override fun computeScrollVectorForPosition(targetPosition: Int): PointF? {
        if (childCount == 0) {
            return null
        }
        val firstChildPos = getPosition(getChildAt(0)!!)
        val direction = if (targetPosition < firstChildPos != true) -1 else 1
        return if (mScrollOrientation == LinearLayoutManager.HORIZONTAL) {
            PointF(direction.toFloat(), 0f)
        } else {
            PointF(0f, direction.toFloat())
        }
    }

	······
}

那么最终效果就如下图
请添加图片描述
这样就完全实现了需求

;