在坐地铁的时候,能看到有些地铁上是有地铁行进动画和站点到达动画的,最近在做项目的时候,就有一个类似这样的需求,不同的是展示的点有限制,多出来的点是需要是折贴起来的,当需要展示时再拉出来,大致动画如下:
看到这样一个动画,你有什么想法呢?对于这个动画,我有一个同事使用的是RecycleView去做的,做出来后差不多有十几个类,里面的逻辑还是比较复杂的,由于同事离职,这里面还有一些问题存在,由我接手修改,确实不太好改,没办法,尝试自己撸一个。对于这样一个动画,我首先想到的是使用自定义View来解决。先来说一下绘制这个View的大致思路:先绘制静态的,在绘制动态的。
1、如何绘制静态的呢,这个比较简单,不过这里需要考虑一个问题,站点抽出是向左抽出的,所以绘制时应该从右边开始画,主要的界面是由圆还有线绘制成的,这些都很简单,现在主要来说说这写点线位置的计算,首先一开始左右位置的间隙是对称的,这里可以根据预先设置的点的个数,根据屏宽和预先设置的参数计算出线的长短,这样就可以画出这些点还有线了,点还有线绘制完后,就差文字及其背景了,文字这个不多说,背景其实也就是一条线,只不过是比较宽而已。
2、比较难的是动态绘制的处理,可以看出,点一次可以拉出1~3个,这里将一个点和一条线看做是一段长,这里先上一段代码:
private void initAnimator() {
animator = new ValueAnimator();
animator.addUpdateListener(animation -> {
changeX = (float) animation.getAnimatedValue();
if ((int) (changeX / lineWithdotWidth) == flag) {
headPositionIndex++;
flag++;
}
changeX = changeX % lineWithdotWidth;
isScrolling = true;
invalidate();
});
animator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
isScrolling = false;
flag = 1;
}
});
}
后面动画用到的主要就是这个changeX,根据这个changX去绘制点和线,由于拉出的过程都是在一个点和线之间的距离变化的,所以这个changeX就是在这个范围内变化的,大致的思路就是这样,当然里面还有好多细节需要去处理,代码主要分为三个类,其中两个是处理点位数据的,比较简单:
public class IndicatorPositionDatas {
private List<PositionData> positionList = new ArrayList<>();
public IndicatorPositionDatas(List<String> list) {
boolean isTextUp;
int size = list.size();
for (int i = 0; i < size-1; i++) {
isTextUp = (i % 2 == 0);
PositionData positionData = new PositionData(list.get(i),isTextUp,i);
positionList.add(positionData);
}
PositionData positionData = new PositionData(list.get(size-1),true,size-1);
positionList.add(positionData);
}
public PositionData getLastPosition(){
return positionList.get(positionList.size()-1);
}
public PositionData getCurrentPosition(int position){
return positionList.get(position);
}
public int size(){
return positionList.size();
}
}
public class PositionData {
private String positionText;
private boolean isTextUp;
private int position;
public PositionData(String positionText, boolean isTextUp,int position) {
this.positionText = positionText;
this.isTextUp = isTextUp;
this.position = position;
}
public String getText() {
return positionText;
}
public boolean isTextUp() {
return isTextUp;
}
public int getPosition() {
return position;
}
public void setPosition(int position) {
this.position = position;
}
}
接下来就是自定义的View类:
public class IndicatorViewGroup extends View {
private static final String TAG = "IndicatorViewGroup";
private Paint mPaintText;
private Paint mPaintLine;
private Paint mPaintTextBg;
private Paint mPaintDot;
private Paint mPaintLoopDot;
private int lineNoWalkColor = 0xFF219BFF;
private int lineWalkedColor = 0xFF999999;
private int dotStartColor = 0xFFFCB300;
private int dotEndColor = 0xFFEA0075;
private int textBackground = 0xFFF4F4F9;
private int dotWidthAndHeight;
private int lineLength;
private int leftRightPadding;
private int lineWidth;
private int textBgWidth;
//绘制文字背景时,背景距离dot的距离
private int textBgMarginDot;
//绘制文字时定位的Y方向坐标
private float textTopBaseline;
private float textBottomBaseline;
//绘制文字背景定位的中心线
private float textBgTopCenter;
private float textBgBottomCenter;
private float changeX = 0;
private int animatorTime = 4000;
private int dotFlashTime = 500;
private ValueAnimator animator;
private int textSize = 16;
private IndicatorPositionDatas positionDatas;
private int headPositionIndex = 0;
private int arrivePosition = 0;
private String textMore = "更多(%d)";
//绘制在上边文本的位置信息
private int dotTopPosition;
//绘制下边文本的位置信息
private int dotBottomPosition;
private int flag = 1;
private boolean isScrolling;
//view的垂直居中位置
private int centerY;
private int lineWithdotWidth;
private boolean isWalking = false;
private long timeFlag;
//外圆环的宽度
private int loopDotStroke;
//绘制点所需要的参数,cx是点的中心点
private float cx;
private float dotRadius;
private float loopDotRadius;
private float lineWithCircleGap;
private float dotOffset;
/**
* 去往目的点的途中
*/
public void arriveNext() {
if (!isWalking && arrivePosition == positionDatas.size() - 1) {
Toast.makeText(getContext(), "已经到终点了", Toast.LENGTH_SHORT).show();
return;
}
if (isWalking) {
isWalking = false;
if (headPositionIndex + 3 == arrivePosition) {
if (arrivePosition < positionDatas.size() - 3) {
startAnimator();
}
}
return;
}
timeFlag = System.currentTimeMillis();
isWalking = true;
arrivePosition++;
invalidate();
}
/**
* 到达目的点
*/
public void arriveTo() {
if (!isWalking && arrivePosition == positionDatas.size() - 1) {
Toast.makeText(getContext(), "已经到终点了", Toast.LENGTH_SHORT).show();
return;
}
if (isWalking) {
isWalking = false;
if (headPositionIndex + 3 == arrivePosition) {
if (arrivePosition < positionDatas.size() - 3) {
startAnimator();
}
}
}
}
/**
* 开启展开动画
*/
public void startAnimator() {
int count = isScrolling ? 6 : 5;
int moreCount = positionDatas.size() - headPositionIndex - count;
if (moreCount == 3) {
animator.setFloatValues(2 * lineWithdotWidth);
animator.setDuration(animatorTime * 2 / 3);
} else if (moreCount == 2) {
animator.setFloatValues(lineWithdotWidth);
animator.setDuration(animatorTime / 3);
} else {
animator.setFloatValues(3 * lineWithdotWidth);
animator.setDuration(animatorTime);
}
animator.start();
}
/**
* @param arrivePosition 所在的位置点
*/
public void setArrivePosition(int arrivePosition) {
this.arrivePosition = arrivePosition;
}
/**
* @param list 点位数据
*/
public void setPositionDatas(ArrayList<String> list) {
positionDatas = new IndicatorPositionDatas(list);
}
/**
* @param size 位置点上文字的大小
*/
public void setTextSize(int size) {
textSize = size;
}
/**
* @param lineWidth 路线线条的宽度
*/
public void setLineWidth(int lineWidth) {
this.lineWidth = lineWidth;
}
/**
* @param textBgWidth 文字背景的宽度
*/
public void setTextBgWidth(int textBgWidth) {
this.textBgWidth = textBgWidth;
}
/**
* @param marginDot 文字距离原点的距离
*/
public void setTextBgMarginDot(int marginDot) {
textBgMarginDot = marginDot;
}
/**
* @param animatorTime 设置展开三个点所需的时间,单位是ms
*/
public void setAnimatorTime(int animatorTime) {
this.animatorTime = animatorTime;
}
/**
* @param dotFlashTime 设置点位闪动的时间间隔
*/
public void setDotFlashTime(int dotFlashTime) {
this.dotFlashTime = dotFlashTime;
}
/**
* @param lineWithCircleGap 线与点之间的间隔
*/
public void setLineWithCircleGap(float lineWithCircleGap) {
this.lineWithCircleGap = lineWithCircleGap;
}
public IndicatorViewGroup(Context context) {
super(context);
}
public IndicatorViewGroup(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public IndicatorViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initParams();
initDotParams();
initPaint();
initAnimator();
}
private void initPaint() {
mPaintText = new Paint();
mPaintText.setAntiAlias(true);
mPaintText.setColor(Color.BLACK);
mPaintText.setTextSize(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, textSize, getResources().getDisplayMetrics()));
mPaintLine = new Paint();
mPaintLine.setAntiAlias(true);
mPaintLine.setColor(lineNoWalkColor);
mPaintLine.setStrokeWidth(lineWidth);
mPaintLine.setStyle(Paint.Style.FILL);
mPaintLine.setStrokeCap(Paint.Cap.ROUND);
mPaintTextBg = new Paint();
mPaintTextBg.setAntiAlias(true);
mPaintTextBg.setColor(textBackground);
mPaintTextBg.setStrokeWidth(textBgWidth);
mPaintTextBg.setStyle(Paint.Style.FILL);
mPaintTextBg.setStrokeCap(Paint.Cap.ROUND);
mPaintDot = new Paint();
mPaintDot.setAntiAlias(true);
mPaintDot.setColor(lineNoWalkColor);
mPaintDot.setStyle(Paint.Style.FILL);
mPaintLoopDot = new Paint();
mPaintLoopDot.setAntiAlias(true);
mPaintLoopDot.setColor(Color.parseColor("#FFFFFF"));
mPaintLoopDot.setStrokeWidth(loopDotStroke);
mPaintLoopDot.setStyle(Paint.Style.STROKE);
}
private void initParams() {
leftRightPadding = (int) px2Dp(27);
lineWidth = (int) px2Dp(10);
textBgWidth = (int) px2Dp(30);
textBgMarginDot = (int) px2Dp(5);
dotRadius = px2Dp(14);
lineWithCircleGap = px2Dp(13);
loopDotStroke = (int) px2Dp(3);
dotOffset = px2Dp(6);
}
private float px2Dp(int px) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, px, getContext().getResources().getDisplayMetrics());
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int width = MeasureSpec.getSize(widthMeasureSpec);
lineLength = (width - 2 * leftRightPadding - 6 * dotWidthAndHeight) / 5;
lineWithdotWidth = dotWidthAndHeight + lineLength;
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = dotWidthAndHeight + textBgMarginDot * 2 + textBgWidth * 2 + (int) px2Dp(10);
if (heightSize > MeasureSpec.getSize(heightMeasureSpec)) {
heightMeasureSpec = MeasureSpec.makeMeasureSpec(heightSize, heightMode);
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
initArrivePosition();
}
/**
* 初始化所在的位置点是否需要展开
*/
private void initArrivePosition() {
if (!isMorePosition() || arrivePosition == 0) return;
if (arrivePosition % 3 == 0) {
headPositionIndex = (arrivePosition - 1) / 3 * 3;
postDelayed(this::startAnimator, 1000);
}
}
private boolean isMorePosition() {
return positionDatas.size() > 6;
}
private void initDotParams() {
dotWidthAndHeight = (int) (dotRadius + lineWithCircleGap) * 2;
loopDotRadius = dotRadius + loopDotStroke / 2f;
cx = dotWidthAndHeight / 2f;
}
private void initAnimator() {
animator = new ValueAnimator();
animator.addUpdateListener(animation -> {
changeX = (float) animation.getAnimatedValue();
if ((int) (changeX / lineWithdotWidth) == flag) {
headPositionIndex++;
flag++;
}
changeX = changeX % lineWithdotWidth;
isScrolling = true;
invalidate();
});
animator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
isScrolling = false;
flag = 1;
}
});
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
centerY = getHeight() / 2;
dotTopPosition = centerY - dotWidthAndHeight / 2;
dotBottomPosition = centerY + dotWidthAndHeight / 2;
mPaintText.measureText(positionDatas.getLastPosition().getText());
Paint.FontMetrics fm = mPaintText.getFontMetrics();
textTopBaseline = dotTopPosition - fm.descent - textBgMarginDot;
textBottomBaseline = dotBottomPosition + fm.descent - fm.ascent + textBgMarginDot - px2Dp(2);
textBgTopCenter = dotTopPosition - (fm.bottom - fm.top) / 2 - textBgMarginDot;
textBgBottomCenter = dotBottomPosition + (fm.bottom - fm.top) / 2 + textBgMarginDot;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
drawPosition(canvas);
}
private void drawPosition(Canvas canvas) {
if (isMorePosition()) {
canvas.translate(getWidth() - dotWidthAndHeight - leftRightPadding + dotOffset, 0);
} else {
canvas.translate(getWidth() - dotWidthAndHeight - leftRightPadding - (6 - positionDatas.size()) * lineWithdotWidth, 0);
}
drawDot(canvas, positionDatas.getLastPosition());
translateLine(canvas);
if (arrivePosition == positionDatas.size() - 1) {
mPaintLine.setColor(lineWalkedColor);
} else {
mPaintLine.setColor(lineNoWalkColor);
}
drawLine(canvas, positionDatas.getLastPosition());
int dotIndex = 3;
translateBit(canvas);
if (isMorePosition()) {
//绘制拉出的那个点
canvas.translate(-2 * dotOffset, 0);
if (changeX != 0) {
unfoldPosition(canvas);
}
} else {
dotIndex = positionDatas.size() - 3;
}
//绘制倒数第二个点
drawDot(canvas, positionDatas.getCurrentPosition(positionDatas.size() - 2));
int index;
for (int i = dotIndex; 0 <= i; i--) {
translateLine(canvas);
index = headPositionIndex + i;
int lineColor = arrivePosition <= index ? lineNoWalkColor : lineWalkedColor;
mPaintLine.setColor(lineColor);
PositionData currentPosition = positionDatas.getCurrentPosition(index);
drawLine(canvas, currentPosition);
translateBit(canvas);
drawDot(canvas, currentPosition);
}
if (headPositionIndex != 0) {
mPaintLine.setColor(lineWalkedColor);
mPaintLine.setShader(null);
canvas.drawLine(-lineLength / 2f, centerY, 0, centerY, mPaintLine);
}
if (!isScrolling) {
invalidate();
}
}
private void unfoldPosition(Canvas canvas) {
canvas.translate(-changeX, 0);
mPaintDot.setColor(lineNoWalkColor);
canvas.drawCircle(cx, centerY, dotRadius, mPaintDot);
canvas.drawCircle(cx, centerY, loopDotRadius, mPaintLoopDot);
if (changeX > dotWidthAndHeight) {
float stopX;
if (changeX > dotWidthAndHeight + lineLength) {
stopX = dotWidthAndHeight + lineLength;
} else {
stopX = changeX + 10;
}
canvas.drawLine(dotWidthAndHeight, centerY, stopX, centerY, mPaintLine);
}
if (lineLength > changeX) {
int alpha = (int) (changeX / lineLength * 255);
mPaintText.setAlpha(alpha);
}
drawText(canvas, positionDatas.getCurrentPosition(headPositionIndex + 4));
mPaintText.setAlpha(255);
}
private void translateLine(Canvas canvas) {
canvas.translate(-lineLength, 0);
}
private void translateBit(Canvas canvas) {
canvas.translate(-dotWidthAndHeight, 0);
}
//绘制线以及移动时相应的动画
private float offsetFlashX;
private void drawLine(Canvas canvas, PositionData msg) {
if (msg.getPosition() == arrivePosition - 1 || (arrivePosition == positionDatas.size() - 1 && msg.getPosition() == positionDatas.size() - 1)) {
if (isWalking) {
mPaintLine.setColor(dotStartColor);
LinearGradient backGradient = new LinearGradient(-lineLength + offsetFlashX, 0, offsetFlashX, 0, new int[]{dotStartColor, 0xffffffff, dotStartColor}, null, Shader.TileMode.CLAMP);
mPaintLine.setShader(backGradient);
}
} else {
mPaintLine.setShader(null);
}
offsetFlashX = offsetFlashX + 1f;
if (offsetFlashX > lineLength * 2) offsetFlashX = 0;
canvas.drawLine(0, centerY, lineLength, centerY, mPaintLine);
}
private void drawDot(Canvas canvas, PositionData msg) {
int position = msg.getPosition();
long time = System.currentTimeMillis();
long toLastTime = time - timeFlag;
Log.d(TAG, "drawDot: toLastTime = " + toLastTime);
if (position == arrivePosition) {
if (toLastTime / dotFlashTime % 2 == 1) {
mPaintDot.setColor(dotStartColor);
} else {
mPaintDot.setColor(0x00000000);
}
} else {
if (position == 0 && arrivePosition == 0) {
mPaintDot.setColor(dotStartColor);
} else if (position == positionDatas.size() - 1) {
mPaintDot.setColor(dotEndColor);
} else {
if (position < arrivePosition) {
mPaintDot.setColor(lineWalkedColor);
} else {
mPaintDot.setColor(lineNoWalkColor);
}
}
}
int count = isScrolling ? 6 : 5;
int moreCount = positionDatas.size() - headPositionIndex - count;
if (position == positionDatas.size() - 2) {
if (isMorePosition()) {
//绘制倒数第二个点
drawMoreDot(canvas, moreCount);
} else {
canvas.drawCircle(cx + changeX, centerY, dotRadius, mPaintDot);
canvas.drawCircle(cx + changeX, centerY, loopDotRadius, mPaintLoopDot);
}
if ((headPositionIndex + 6) < positionDatas.size()) {
String textName = String.format(textMore, moreCount);
float textWidth = mPaintText.measureText(textName);
//绘制文字背景色
canvas.drawLine(changeX + (-textWidth + dotWidthAndHeight) / 2 + dotOffset, textBgTopCenter,
changeX + (textWidth + dotWidthAndHeight) / 2 + dotOffset, textBgTopCenter, mPaintTextBg);
canvas.drawText(textName, (-textWidth + dotWidthAndHeight) / 2 + changeX + dotOffset, textTopBaseline, mPaintText);
} else {
float textWidth = mPaintText.measureText(msg.getText());
//绘制文字背景
if (msg.getPosition() == arrivePosition) {
mPaintTextBg.setColor(dotStartColor);
} else {
mPaintTextBg.setColor(textBackground);
}
if (msg.isTextUp()) {
canvas.drawLine((-textWidth + dotWidthAndHeight) / 2 + dotOffset, textBgTopCenter,
(textWidth + dotWidthAndHeight) / 2, textBgTopCenter, mPaintTextBg);
} else {
canvas.drawLine((-textWidth + dotWidthAndHeight) / 2 + dotOffset, textBgBottomCenter,
(textWidth + dotWidthAndHeight) / 2, textBgBottomCenter, mPaintTextBg);
}
//绘制文字
if (msg.isTextUp()) {
canvas.drawText(msg.getText(), (-textWidth + dotWidthAndHeight) / 2 + dotOffset, textTopBaseline, mPaintText);
} else {
canvas.drawText(msg.getText(), (-textWidth + dotWidthAndHeight) / 2 + dotOffset, textBottomBaseline, mPaintText);
}
}
} else {
canvas.drawCircle(cx, centerY, dotRadius, mPaintDot);
canvas.drawCircle(cx, centerY, loopDotRadius, mPaintLoopDot);
drawText(canvas, msg);
}
}
private void drawMoreDot(Canvas canvas, int moreCount) {
float factor1 = 2;
float factor2 = 0;
if (moreCount == 2) {
factor1 = 2 - changeX / lineWithdotWidth / 2;
factor2 = changeX / lineWithdotWidth / 2;
if (changeX == 0) {
factor1 = 1.5f;
factor2 = 0.5f;
}
}
if (moreCount == 1) {
if ((headPositionIndex + 6) < positionDatas.size()) {
factor1 = 1.5f - changeX / lineWithdotWidth / 2;
factor2 = changeX / lineWithdotWidth / 2 + 0.5f;
} else {
factor1 = factor2 = 1;
}
}
if (factor1 != 1) {
canvas.drawCircle(cx + changeX + factor1 * dotOffset, centerY, dotRadius, mPaintDot);
canvas.drawCircle(cx + changeX + factor1 * dotOffset, centerY, loopDotRadius, mPaintLoopDot);
}
canvas.drawCircle(cx + changeX + dotOffset, centerY, dotRadius, mPaintDot);
canvas.drawCircle(cx + changeX + dotOffset, centerY, loopDotRadius, mPaintLoopDot);
if (factor2 != 1) {
canvas.drawCircle(cx + changeX + factor2 * dotOffset, centerY, dotRadius, mPaintDot);
canvas.drawCircle(cx + changeX + factor2 * dotOffset, centerY, loopDotRadius, mPaintLoopDot);
}
}
private void drawText(Canvas canvas, PositionData msg) {
float textWidth = mPaintText.measureText(msg.getText());
drawTextBackground(canvas, textWidth, msg);
if (msg.isTextUp()) {
canvas.drawText(msg.getText(), (-textWidth + dotWidthAndHeight) / 2, textTopBaseline, mPaintText);
} else {
canvas.drawText(msg.getText(), (-textWidth + dotWidthAndHeight) / 2, textBottomBaseline, mPaintText);
}
}
private void drawTextBackground(Canvas canvas, float textWidth, PositionData msg) {
if (msg.getPosition() == arrivePosition) {
mPaintTextBg.setColor(dotStartColor);
} else {
mPaintTextBg.setColor(textBackground);
}
if (msg.isTextUp()) {
canvas.drawLine((-textWidth + dotWidthAndHeight) / 2, textBgTopCenter,
(textWidth + dotWidthAndHeight) / 2, textBgTopCenter, mPaintTextBg);
} else {
canvas.drawLine((-textWidth + dotWidthAndHeight) / 2, textBgBottomCenter,
(textWidth + dotWidthAndHeight) / 2, textBgBottomCenter, mPaintTextBg);
}
}
}
代码到这就算是完成了,代码量不多,用起来也很简单,首先就是在xml中进行添加:
<com.example.ubt.myapplication.view.IndicatorViewGroup
android:id="@+id/iv_animator"
android:layout_width="match_parent"
android:layout_height="wrap_content">
</com.example.ubt.myapplication.view.IndicatorViewGroup>
高度这里可以指定高度,但都是会居中绘制的,最后就是在Activity中进行使用了,也比较简单:
IndicatorViewGroup ivAnimator = (IndicatorViewGroup) findViewById(R.id.iv_animator);
ivAnimator.setPositionDatas(list);
findViewById(R.id.btn_start).setOnClickListener(v->ivAnimator.arriveNext());
这样完成后,点击按钮就可以动起来了,代码中还提供了一个arriveTo()方法,为了方便调试,直接将这个方法添加到了arriveNext()中,如果需要使用两个方法,直接把arriveNext()中相同的代码去掉即可,为了整体的视觉感,这里建议将屏幕设置成横屏。