前言
这篇文章的起因是,群里一个小伙伴去面试时被问到一个效果如何实现:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7POiiFUz-1586686264261)(https://wanandroid.com/blogimgs/81c87faf-49e4-4041-80c8-9e188d1390c4.gif)]
后来鸿洋看到后,就把这个效果放到wanandroid网站上作为一个问题。
然后我用闲暇时间对这个效果做了个简单的实现,本文就是介绍这个实现的思路的。
从图中的效果可以看出,小船是沿着固定的线路在移动,他的移动随着item的移动而移动的,所以我们先实现一个能够沿着固定路径移动的自定义控件,然后把这个控件作为RecyclerView的item就好。
PathView的实现
我们先看PathView的实现。
新建一个类继承View,重写起的onDraw方法,然后向外暴露传入图片以及Path路径的方法:
public class PathView extends View {
···
public void setPath(Path path) {
mPath = path;
}
public void setImage(int imageRes) {
mBitmap = BitmapFactory.decodeResource(getResources(), imageRes);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawPath(mPath, mPaint);
···
}
}
现在问题的关键是,如何通过外界传入一个进度值,将图片画到对应的路径的位置,并将其旋转到位。
这就要借助一个类:PathMeasure,通过其来确定图片的位置以及旋转的角度。
mPathMeasure = new PathMeasure(mPath, false);
mLength = mPathMeasure.getLength();
mPathMeasure.getPosTan(progress / mMax * mLength, pos, tan);
通过如上代码可以获取到path路径在指定百分比位置的坐标以及切线方向。其中pos的两个值分别为位置坐标,而tan的两个值为该点对应切线方向的两个坐标。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawPath(mPath, mPaint);
if (mProgress >= 0) {
canvas.save();
canvas.translate(pos[0], pos[1]);
double atan = Math.atan(tan[1] / tan[0]);
float degrees = (float) (atan * 180 / Math.PI);
Log.e(TAG, "degrees " + degrees);
degrees += 90;//我选取的图片是朝上的,而Android的极坐标系是朝右的,朝上的角度为-90度
if (tan[0] < 0) {
degrees += 180;
}
canvas.rotate(degrees);
canvas.drawBitmap(mBitmap, null, mRect, mPaint);
canvas.restore();
}
}
所以画具体的图片的方法如上,其中在切线的x分量小于0时要多旋转180度,是图片的朝向是向着左边,也就是x<0的方向。
PathView的完整代码如下:
public class PathView extends View {
public static final String TAG = "PathView";
private Path mPath;
private Paint mPaint;
private int mProgress = -1;
private Bitmap mBitmap;
private Rect mRect;
private float[] pos = new float[2];
private float[] tan = new float[2];
private PathMeasure mPathMeasure;
private float mLength;
private float mMax = 100f;
public PathView(Context context) {
this(context, null);
}
public PathView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public PathView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initPaint();
}
private void initPaint() {
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setColor(0xff123456);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(40);
mPaint.setStrokeJoin(Paint.Join.ROUND);
}
public void setPath(Path path) {
mPath = path;
mPathMeasure = new PathMeasure(mPath, false);
mLength = mPathMeasure.getLength();
postInvalidate();
}
public void setMax(int max) {
mMax = max;
}
public void setProgress(int progress) {
mProgress = progress;
if (mPath != null) {
mPathMeasure.getPosTan(progress / mMax * mLength, pos, tan);
Log.e(TAG, "progress " + progress + " pos " + pos[0] + " " + pos[1] + " " + "tan " + tan[0] + " " + tan[1]);
}
// postInvalidate();
invalidate();
}
public void setImage(int imageRes) {
mBitmap = BitmapFactory.decodeResource(getResources(), imageRes);
int width = mBitmap.getWidth();
int height = mBitmap.getHeight();
mRect = new Rect(-width / 2, -height / 2, width / 2, height / 2);
postInvalidate();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawPath(mPath, mPaint);
if (mProgress >= 0) {
canvas.save();
canvas.translate(pos[0], pos[1]);
double atan = Math.atan(tan[1] / tan[0]);
float degrees = (float) (atan * 180 / Math.PI);
Log.e(TAG, "pre degrees " + degrees);
degrees += 90;
if (tan[0] < 0) {
degrees = degrees + 180;
}
Log.e(TAG, "last degrees " + degrees);
canvas.rotate(degrees);
if (mBitmap != null) {
canvas.drawBitmap(mBitmap, null, mRect, mPaint);
}
canvas.restore();
}
}
}
可以用如下代码测试这个控件:
final PathView pathView = findViewById(R.id.path_view);
Path path = new Path();
path.addCircle(500, 800, 400, Path.Direction.CCW);
pathView.setImage(R.mipmap.airplane2);
pathView.setPath(path);
final Handler handler = new Handler();
handler.post(new Runnable() {
private int i = 0;
@Override
public void run() {
pathView.setProgress(i++ % 100);
handler.postDelayed(this,50);
}
});
第一种实现方式
有了如上控件之后,实现目标效果就相对简单了。
先用第一种方式,每个item都放一个PathView,适配器代码如下:
public class PathAdapter extends RecyclerView.Adapter<PathAdapter.PathViewHolder> {
private Context mContext;
private Path mLeftPath;
private Path mRightPath;
public PathAdapter(Context context, int width) {
mContext = context;
initLeftPath(width);
initRightPath(width);
}
@NonNull
@Override
public PathViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
PathView pathView = new PathView(mContext);
pathView.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 300));
if (viewType == 0) {
pathView.setPath(mLeftPath);
pathView.setBackgroundColor(0xff134334);
} else {
pathView.setPath(mRightPath);
pathView.setBackgroundColor(0xff752397);
}
pathView.setImage(R.mipmap.airplane2);
return new PathViewHolder(pathView);
}
private void initLeftPath(int width) {
mLeftPath = new Path();
mLeftPath.moveTo(width / 3, 0);
mLeftPath.lineTo(width / 3, 250);
mLeftPath.lineTo(width / 3 * 2, 250);
mLeftPath.lineTo(width / 3 * 2, 300);
}
private void initRightPath(int width) {
mRightPath = new Path();
mRightPath.moveTo(width / 3 * 2, 0);
mRightPath.lineTo(width / 3 * 2, 250);
mRightPath.lineTo(width / 3, 250);
mRightPath.lineTo(width / 3, 300);
}
@Override
public void onBindViewHolder(@NonNull PathViewHolder holder, int position) {
}
@Override
public int getItemViewType(int position) {
return position % 2;
}
@Override
public int getItemCount() {
return 100;
}
class PathViewHolder extends RecyclerView.ViewHolder {
public PathViewHolder(@NonNull View itemView) {
super(itemView);
}
}
}
逻辑很简单,根据奇偶位置来放置两种item就好。
然后最关键的,让小飞机随着RecyclerView的滑动而改变位置,并且只有一个item显示出小飞机,实现如下:
recyclerView.setAdapter(new PathAdapter(this, (int) (screenWidthDp * scale + 0.5f)));
recyclerView.setClipChildren(false);
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
Log.e(TAG, "" + dy);
int childCount = recyclerView.getChildCount();
for (int i = 0; i < childCount; i++) {
PathView child = (PathView) recyclerView.getChildAt(i);
float line = (getResources().getConfiguration().screenHeightDp * scale + 0.5f) / 3;
Log.e(TAG, "i " + i + " child.getTop() " + child.getTop());
if (child.getTop() > line) {
int progress = (int) ((child.getTop() - line) / child.getHeight() * 100);
Log.e(TAG, "progress " + progress);
if (progress < 0 || progress > 100) {
Log.e(TAG, "onScrolled: error progress " + progress);
}
if (child != currentPathView) {
if (currentPathView != null) {
child.setProgress(100 - progress);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
currentPathView.setZ(0);
}
currentPathView.setProgress(-1);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
child.setZ(1);
}
} else {
child.setProgress(100 - progress);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
child.setZ(1);
}
}
currentPathView = child;
} else {
child.setProgress(100 - progress);
}
return;
}
}
}
});
思路是,找到第一个top在某个基线之下的item,然后让他的飞机显示出来,进度的计算就是top与基线的距离与item高度的比值。其中对z的设置是为了让有飞机的item在其他item上面,从而实现飞机的顺畅显示。
最终的效果如下:
第二种实现方式
第一种实现有个弊端,就是飞机显示出来的只有一个,却在很多个item有了实例,现在用另一种方式来实现,只用一个PathView。
先初始化一个PathView:
mPathView = findViewById(R.id.path_view);
path = new Path();
path.moveTo(screenWidth / 3, 0);
for (int i = 0; i < 50; i++) {
path.lineTo(screenWidth / 3, 250 + 300 * i * 2);
path.lineTo(screenWidth / 3 * 2, 250 + 300 * i * 2);
path.lineTo(screenWidth / 3 * 2, 300 + 300 * i * 2);
path.lineTo(screenWidth / 3 * 2, 250 + 300 * (i * 2 + 1));
path.lineTo(screenWidth / 3, 250 + 300 * (i * 2 + 1));
path.lineTo(screenWidth / 3, 300 + 300 * (i * 2 + 1));
}
mPathView.setPath(path);
final int max = 10000;
mPathView.setMax(max);
然后在进度改变的时候,移动path,并改变progress:
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
int scrollY = 0;
long time = System.currentTimeMillis();
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
path.offset(dx, -dy);
mPathView.setPath(path);
scrollY += dy;
int progress = (int) (scrollY / (300 * 100 - screenHeightDp * scale) * max);
mPathView.setProgress(progress);
Log.e(TAG, "onScrolled: scrollY " + scrollY);
Log.e(TAG, "onScrolled: progress " + progress);
Log.e(TAG, "onScrolled: progress " + progress);
long currentTimeMillis = System.currentTimeMillis();
int dtime = (int) (currentTimeMillis - time);
Log.e(TAG, "onScrolled: dtime " + dtime + " dy " + dy);
mPathView.setAnimationSpeed(dy / dtime);
time = currentTimeMillis;
}
});
最终实现效果如下:
其中还将小船添加了进去。
完整e的代码参考github小船