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())
}
}
······
}
那么最终效果就如下图
这样就完全实现了需求