仿网易LOFTER视差滚动列表

4,849 阅读5分钟

原博客链接

最初看到网易LOFTER的首页的视差滚动效果, 觉得很漂亮, 想要模仿一下
在写代码之前我先百度了一下, 看有没有人已经完成了类似的这种效果, 一看果然有.然后我就把他们的代码clone了下来, 看了一下, 理解之后自己去实现了一番.所以本篇不是原创, 只记录原理和实现.以下是参考资料:
Android视图滚动差—ParallaxScrollImageView
高仿寺库View滑动页面
ParallaxRecyclerView

实现原理

首先需要写一个图片列表, 用listView或者recyclerView都可以.然后监听列表的滚动, 计算出图片的中心线和recyclerView的中心线之间的距离, 用这个距离乘以一个比例(这个比例自己定义, 效果合适即可)得到一个偏移量, 然后使用matrix给图片内容加上偏移量.

设置滚动监听

首先以recyclerView举例来说, 给它设置滚动监听

mRv.addOnScrollListener(new RecyclerView.OnScrollListener() {
            @Override
            public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
            }

            @Override
            public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
                RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
                // 获取第一个可见条目的position
                int firstVisibleItem = ((LinearLayoutManager) layoutManager).findFirstVisibleItemPosition();
                // 获取所有可见条目的数量
                int visibleItemCount = ((LinearLayoutManager) layoutManager).findLastVisibleItemPosition() - firstVisibleItem + 1;
                for (int i = 0; i < visibleItemCount; i++) {
                    View childView = recyclerView.getChildAt(i);
                    RecyclerView.ViewHolder viewHolder = recyclerView.getChildViewHolder(childView);
                    if (viewHolder instanceof ParallaxViewHolder) {
                        ParallaxViewHolder parallaxViewHolder = (ParallaxViewHolder) viewHolder;
                        parallaxViewHolder.animateImage();
                    }
                }

            }
        });

上面代码中的ParallaxViewHolder是一个继承了RecyclerView.ViewHolder的自定义ViewHolder

public abstract class ParallaxViewHolder extends RecyclerView.ViewHolder implements ParallaxImageView.ParallaxImageListener {
    private ParallaxImageView mParallaxImageView;

    public abstract int getParallaxImageId();
    public ParallaxViewHolder(View itemView) {
        super(itemView);
        mParallaxImageView = itemView.findViewById(getParallaxImageId());
        mParallaxImageView.setListener(this);

    }

    public void animateImage() {
        mParallaxImageView.doTranslate();
    }

    @Override
    public int[] requireValuesForTranslate() {
        if (itemView.getParent() == null) {
            return null;
        } else {
            int[] itemPosition = new int[2];
            // 获取itemView左上角在屏幕上的坐标
            itemView.getLocationOnScreen(itemPosition);
            int[] recyclerViewPosition = new int[2];
            // 获取recyclerView在屏幕上的坐标
            ((RecyclerView) itemView.getParent()).getLocationOnScreen(recyclerViewPosition);
            // 将参数传递过去
            // itemView的高度, itemView在屏幕上的y坐标, recyclerView的高度, recyclerView在屏幕上的y坐标
            return new int[]{itemView.getMeasuredHeight(), itemPosition[1], ((RecyclerView) itemView.getParent()).getHeight(), recyclerViewPosition[1]};
        }
    }
}

这里首先ParallaxViewHolder会获取ParallaxImageView(下面会说明)的id, 然后根据id获取parallaxImageView.然后给parallaxImageView设置回调方法, ParallaxViewHolder实现requireValuesForTranslate()方法, 在滚动的时候parallaxImageView会调用这个方法, 获取条目的高度, 条目的在屏幕上的y坐标, recyclerView的高度, recyclerView在屏幕上的高度这四个参数

ParallaxImageView

这个自定义控件是实现效果的重点. 首先继承ImageView

public class ParallaxImageView extends AppCompatImageView {
   private static final String TAG = "ParallaxImageView";
   private int itemHeight;
   private int itemYPos;
   private int rvHeight;
   private int rvYPos;

   public ParallaxImageView(Context context) {
       super(context);
       init();
   }

   public ParallaxImageView(Context context, AttributeSet attrs) {
       super(context, attrs);
       init();
   }

   public ParallaxImageView(Context context, AttributeSet attrs, int defStyle) {
       super(context, attrs, defStyle);
       init();
   }

   private void init() {
       setScaleType(ScaleType.MATRIX);
   }
   ......

可以看到初始化的时候, 给它的scaleType设置了matrix, 这是为什么呢? 因为我们可以看到, 想要lofter那种效果, 是需要图片只露出一部分, 如下图中所示, 红框中代表ParallaxImageView, 蒙层部分表示不可见

系统提供的几种scaleType中, 没有一个能实现这种效果, 那就只能设置scaleType为ScaleType.MATRIX, 然后自己使用maxtrix做变换了. 关于scaleType, 如果还不熟悉, 可以看这篇文章 Android ImageView的scaleType属性与adjustViewBounds属性 ImageView的默认scaleType是fitcenter. 设置scaleType为matrix之后, 会从ImageView的左上角开始绘制原图, 大概是像这个样子, 红色区域代表ParallaxImageView, 黑色区域代表图像.

设置缩放

首先要做的是计算一个缩放比例, 使缩放之后的drawable的宽度等于ParallaxImageView的宽度

    /**
     * 重新计算ImageView的变换矩阵
     * @return
     */
    private float recomputeImageMatrix() {
        float scale;
        // 获取imageView的宽度减去padding之后的部分
        final int viewWidth = getWidth() - getPaddingLeft() - getPaddingRight();
        // 获取imageView的高度减去padding之后的部分
        final int viewHeight = getHeight() - getPaddingTop() - getPaddingBottom();
        // 获取drawable的宽度
        final int drawableWidth = getDrawable().getIntrinsicWidth();
        // 获取drawable的高度
        final int drawableHeight = getDrawable().getIntrinsicHeight();

        // 如果drawable的宽高比大于view的宽高比
        // drawableWidth / drawableHeight > viewWidth / viewHeight
        if (drawableWidth * viewHeight > drawableHeight * viewWidth) {
            // 如果drawable的宽高比大于view的宽高比
            // 那么就让drawable乘以一个scale, 使得drawable的高度能够等于view的高度, 使得drawable能够填充整个view
            // drawableHeight * (scale = viewHeight/ drawableHeight) = viewHeight
            scale = (float) viewHeight / (float) drawableHeight;
        } else { // 如果drawable的宽高比小于view的宽高比  <------  代码会走这里

            // 为了使drawable能够填充整个view, 需要使drawable的宽度能够等于view的宽度
            // drawableWidth * (scale = viewWidth / drawableWidth) = viewWidth
            scale = (float) viewWidth / (float) drawableWidth;
        }

        return scale;
    }

然后按照这个比例进行变换

        Matrix imageMatrix = getImageMatrix();
        if (scale != 1) {
            imageMatrix.setScale(scale, scale);
        }
        setImageMatrix(imageMatrix);
        invalidate();

现在的效果是这样

使图片居中

接下来要做的是这种变换, 是视图内容居于ImageView的中间

首先计算出视图内容的中心线和ImageView中心线之间的距离

    private float computeDistance(float scale) {
        // 获取imageView的高度减去padding之后的部分
        final int viewHeight = getHeight() - getPaddingTop() - getPaddingBottom();
        // 获取drawable的高度
        int drawableHeight = getDrawable().getIntrinsicHeight();

        // 按照比例变换后的drawableHeight
        drawableHeight *= scale;
        return viewHeight * 0.5f - drawableHeight * 0.5f;

    }

然后使用matrix的postTranslate()方法进行y方向上的偏移

        Matrix imageMatrix = getImageMatrix();
        if (scale != 1) {
            imageMatrix.setScale(scale, scale);
        }
        float[] matrixValues = new float[9];
        imageMatrix.getValues(matrixValues);
        // 获取当前的y值, 比如一开始y值是0, 目标是让当前的y值变为distance
        // 那么就在y方向上偏移 distance - currentY
        float currentY = matrixValues[Matrix.MTRANS_Y];
        float dy = distance - currentY;
        imageMatrix.postTranslate(0, dy);
        setImageMatrix(imageMatrix);

变换后的效果, 可以看到已经居中了

加上偏移量

然后我们就可以计算每张图片的中线与列表的中线之间的距离, 然后乘以一个适当的比例设置给matrix

    // translate是recyclerView中心线和itemView中心线之间的距离
    float translate = (rvYPos + rvHeight * 0.5f) - (itemYPos + itemHeight * 0.5f);
    translate *= 0.2f;
    transform(scale, distance, translate);
        
    private void transform(float scale, float distance, float translate) {
        Matrix imageMatrix = getImageMatrix();
        if (scale != 1) {
            imageMatrix.setScale(scale, scale);
        }
        float[] matrixValues = new float[9];
        imageMatrix.getValues(matrixValues);
        // 获取当前的y值, 比如一开始y值是0, 目标是让当前的y值变为distance
        // 那么就在y方向上偏移 distance - currentY
        float currentY = matrixValues[Matrix.MTRANS_Y];
        float dy = translate + distance - currentY;
        int position = (int) getTag(R.id.tag_position);
        if (position == 1) {
            Log.d(TAG, "translate = " + translate);
        }
        imageMatrix.postTranslate(0, dy);
        setImageMatrix(imageMatrix);
    }

现在的效果

边界修正

但是看上图的第二个条目, 把ImageView的红色背景露出来了(我给ImageView设置的红色的background).

如上图所示, 视图内容不断往下偏移(红色框框看成不动), 当在这种边界条件下视图内容继续往下偏移时, 就会把ImageView的背景露出来.所以计算然后限制边界条件

        float maxTranslate = drawableHeight * 0.5f - viewHeight * 0.5f;
        float minTranslate = -maxTranslate;
        // translate是recyclerView中心线和itemView中心线之间的距离
        float translate = (rvYPos + rvHeight * 0.5f) - (itemYPos + itemHeight * 0.5f);
        if (translate >= maxTranslate) {
            translate = maxTranslate;
        } else if (translate <= minTranslate) {
            translate = minTranslate;
        }

最终效果

github地址

github.com/mundane7996…