仿网易LOFTER视差滚动列表

最初看到网易LOFTER的首页的视差滚动效果, 觉得很漂亮, 想要模仿一下

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

实现原理

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

设置滚动监听

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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的宽度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 重新计算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;
}

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

1
2
3
4
5
6
Matrix imageMatrix = getImageMatrix();
if (scale != 1) {
imageMatrix.setScale(scale, scale);
}
setImageMatrix(imageMatrix);
invalidate();

现在的效果是这样

使图片居中

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

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

1
2
3
4
5
6
7
8
9
10
11
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方向上的偏移

1
2
3
4
5
6
7
8
9
10
11
12
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 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的背景露出来.所以计算然后限制边界条件

1
2
3
4
5
6
7
8
9
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地址

https://github.com/mundane799699/AndroidProjects/tree/master/ParallaxRecyclerView

0%