众所周知,RecyclerView是个强大的控件,拥有很大的可扩展性,但是使用起来比ListView的难度会大一点。
今天我们就使用RecyclerView结合她的ItemDecoration来实现一个可左右滑动的折线图,静态效果图如下:
想想如何来实现呢?首先分析下要解决几个问题:
1、每个点的位置坐标如何计算;
2、每个点,及点与点之间的连线通过什么方法绘制;
3、每个点都有点击事件,如何去实现;
我们想想,点与点之间的距离是一样的,每个点所在的区域都可以看成一个item,如图:
每个点显示在每个item的中间,但是因为每个点的y坐标不一样,点与点之间的连接线也不一样,所以每个item并不能直接用原生控件来显示点和连接线,需要我们自己绘制出来。那我们该怎么绘制,自定义一个view来绘制可以吗?每个点显示在中间好解决,但是连接线会被分成来两段,这样就要计算好多坐标,很麻烦不可取。
通常我们会使用ItemDecoration来给RecyclerView的item绘制间隙,但她不仅仅用来干这个,她可以在item绘制之前(onDraw)或绘制之后(onDrawOver)绘制任何东西到RecyclerView的画布上,配合getItemOffsets方法可以优雅的解决很多问题。所以我们可以给item填充一个空白的View,使用ItemDecoration来绘制每点和连接线。这样就解决了第2个和第3个问题。
我们看第1个问题,如何计算每个点的坐标。我们可以给最大数值设置一个比例,比如说最大数值的高度占item高度的0.85,其他数值的高度为除以最大数值的结果乘以最大值的高度。但这样可能对于某些需求不是很合理,比如说体重的变化。因为体重的浮动一般不会很大,通过上面方法计算出来的高度会很相近,导致折线的变化不会很明显。所以我们需要给最大数值和最小数值都设置一个比例,其他数值在这个范围之间分布。计算代码如下:
private final static float MAX_RATE = 0.85f;//最大值比例
private final static float MIN_RATE = 0.15f;//最小值比例
public static float getPointY(int viewHeight, float value, float maxValue, float minValue) {
if (maxValue == minValue) {
return value / maxValue * viewHeight * MAX_RATE;
}
float ratio = (value - minValue) / (maxValue - minValue) * (MAX_RATE - MIN_RATE) + MIN_RATE;
return viewHeight * ratio;
}
好,既然思路有了,那就开始实现
public class LinkedView extends LinearLayoutCompat {
private RecyclerView mRecyclerView;
private LinkedViewAdapter mAdapter;
private LinkedViewDecoration mDecoration;
private boolean mClickable;
private LoadPreviousPageListener mLoadPreviousPageListener;
public LinkedView(Context context) {
this(context, null);
}
public LinkedView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public LinkedView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//获取自定义属性
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.LinkedView);
int nodeDescribeSize = (int) typedArray.getDimension(R.styleable.LinkedView_nodeDescribeSize, DimensionUtil.dp2px(12));
int nodeDescribeColor = typedArray.getColor(R.styleable.LinkedView_nodeDescribeColor, Color.BLACK);
int lineColor = typedArray.getColor(R.styleable.LinkedView_lineColor, nodeDescribeColor);
int nodeGap = (int) typedArray.getDimension(R.styleable.LinkedView_nodeGap, DimensionUtil.dp2px(60));
boolean hasItemText = typedArray.getBoolean(R.styleable.LinkedView_hasItemText, true);
mClickable = typedArray.getBoolean(R.styleable.LinkedView_clickable, true);
Drawable nodeDrawable = typedArray.getDrawable(R.styleable.LinkedView_node);
Drawable checkedNodeDrawable = typedArray.getDrawable(R.styleable.LinkedView_nodeChecked);
typedArray.recycle();
if (nodeDrawable == null || checkedNodeDrawable == null) {
throw new NullPointerException("LinkedView node drawable or nodeChecked drawable is null ");
}
mAdapter = new LinkedViewAdapter(nodeGap);
//初始化recyclerView
mRecyclerView = new RecyclerView(context);
mRecyclerView.setClipToPadding(false);
//设置left padding防止最左边文字绘制到屏幕外,设置right padding让点居中显示
mRecyclerView.setPadding(DimensionUtil.dp2px(12), 0, (int) (DimensionUtil.getDisplayWidth() * 0.4f), 0);
LayoutParams params = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
addView(mRecyclerView, params);
LinearLayoutManager linearLayoutManager = new LinearLayoutManager(getContext());
linearLayoutManager.setOrientation(LinearLayoutManager.HORIZONTAL);
//recyclerView会从最后面开始显示,list会被反序添加,最后面显示list中的第一个;
linearLayoutManager.setReverseLayout(true);
mRecyclerView.setLayoutManager(linearLayoutManager);
mRecyclerView.setOverScrollMode(OVER_SCROLL_NEVER);
if (hasItemText) {
mDecoration = new LinkedViewDecoration(nodeDrawable, checkedNodeDrawable, nodeDescribeColor, lineColor, nodeDescribeSize);
} else {
mDecoration = new LinkedViewDecoration(nodeDrawable, checkedNodeDrawable, lineColor);
}
mRecyclerView.addItemDecoration(mDecoration);
//监听recyclerView滑动到最后面
mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
int range = recyclerView.computeHorizontalScrollRange();//整体长度
int extent = recyclerView.computeHorizontalScrollExtent();//屏幕显示的长度
if (range > extent) {//如果整体长度大于显示的长度既可以滑动
//已经滑动的长度=0,表示滑动到了最后面
if (recyclerView.computeHorizontalScrollOffset() == 0) {
if (mLoadPreviousPageListener != null) {
mLoadPreviousPageListener.onLoad();
}
}
}
}
});
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
final View childView = getChildAt(0);
if (childView != null) {
measureChild(childView, widthMeasureSpec, heightMeasureSpec);
int h = childView.getMeasuredHeight();
setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), h);
}
}
public void setCheckedNode(int position) {
mAdapter.setCheckedPosition(position);
mRecyclerView.scrollToPosition(position);
}
//设置最大数值和最小数值
public void setReferValue(float maxValue, float minValue) {
mDecoration.setMaxValue(maxValue, minValue);
}
public void notifyDataSetChanged() {
mAdapter.notifyDataSetChanged();
}
public void setNodeData(ArrayList<Float> nodeData) {
mAdapter.setList(nodeData);
mRecyclerView.setAdapter(mAdapter);
}
public void setNodeOnClickListener(INodeOnClickListener nodeOnClickListener) {
mAdapter.setNodeOnClickListener(nodeOnClickListener);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return !mClickable || super.onInterceptTouchEvent(ev);
}
public void setLoadPreviousPageListener(LoadPreviousPageListener loadPreviousPageListener) {
mLoadPreviousPageListener = loadPreviousPageListener;
}
public interface LoadPreviousPageListener {//加载前一页
void onLoad();
}
}
LinkedView是最终被使用的view,里面只包含RecyclerView,并自定义了一些属性,设置监听等等,其中
linearLayoutManager.setReverseLayout(true)
是设置recyclerView从最后面开始显示,数据集合反序添加Adapter中,最后面的item显示数据集合中的第一个数据。这样设置是因为,数据集合的第一个数据是距离当前日期最近的数据,而需求是折线图加载出来时显示日期最近的数据,且向前滑动时(手指向右滑动),显示前面日期的数据。
接着看Adapter的代码
class LinkedViewAdapter extends RecyclerView.Adapter<LinkedViewAdapter.LVHolder> {
private ArrayList<Float> mList;
private int checkedPosition;
private INodeOnClickListener mNodeOnClickListener;
private int nodeGap;
LinkedViewAdapter(int nodeGap) {
this.nodeGap = nodeGap;
}
public void setList(ArrayList<Float> list) {
this.mList = list;
}
public void setNodeOnClickListener(INodeOnClickListener rvItemOnClickListener) {
this.mNodeOnClickListener = rvItemOnClickListener;
}
@NonNull
@Override
public LVHolder onCreateViewHolder(@Nullable ViewGroup parent, int viewType) {
View view = new View(parent.getContext());
view.setLayoutParams(new LinearLayout.LayoutParams(nodeGap, ViewGroup.LayoutParams.MATCH_PARENT));
final LVHolder holder = new LVHolder(view);
holder.point.setOnClickListener(v -> {
int position = holder.getAdapterPosition();
if (position < 0) {
return;
}
if (checkedPosition == position) {
return;
}
int tem = checkedPosition;
checkedPosition = position;
notifyItemChanged(tem, false);
notifyItemChanged(position, false);//刷新时不启用动画,有动画时显示会有问题
if (mNodeOnClickListener != null) {
mNodeOnClickListener.onClick(position);
}
});
return holder;
}
void setCheckedPosition(int position) {
checkedPosition = position;
}
@Override
public void onBindViewHolder(@NonNull LVHolder holder, int position) {
//将数据放到NodeBean中通过Tag中传递给decoration,让decoration画出来
NodeBean bean = (NodeBean) holder.point.getTag();
bean.number = mList.get(position);
bean.isChecked = checkedPosition == position;
}
@Override
public int getItemCount() {
return mList.size();
}
static class LVHolder extends RecyclerView.ViewHolder {
View point;
LVHolder(View itemView) {
super(itemView);
point = itemView;
NodeBean nodeBean = new NodeBean();
point.setTag(nodeBean);
}
}
static class NodeBean {
boolean isChecked;
float number;
}
}
NodeBean 用来保存数值和选中状态,并通过view.setTag来存储起来,这样做的好处是ItemDecoration在绘制每个点时可以获取每个点的数据。onCreateViewHolder方法中,创建了一个空的view为itemView,并设置点击事件处理选中的点。注意这里并没有在onBindViewHolder方法中计算每个点的高度传给ItemDecoration,因为在onBindViewHolder中获取recyclerView的高度是不可靠的,某些时刻获取的高度为0,所以我们在绘制点的时候再去计算点的高度。
接着就是关键的ItemDecoration的实现
class LinkedViewDecoration extends RecyclerView.ItemDecoration {
private Paint paint;
private int textColor;
private int textSize;
private int lineColor;
private int gap;
private Drawable nodeDrawable;
private Drawable nodeCheckedDrawable;
private float maxValue;//最大的数值
private float minValue;//最小的数值
private boolean whetherDrawText;//是否绘制文字
LinkedViewDecoration(Drawable nodeDrawable, Drawable nodeCheckedDrawable, int textColor,
int lineColor, int textSize) {
this.nodeDrawable = nodeDrawable;
this.nodeCheckedDrawable = nodeCheckedDrawable;
this.lineColor = lineColor;
this.textColor = textColor;
this.textSize = textSize;
paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setStrokeWidth(DimensionUtil.dp2px(1));
gap = DimensionUtil.dp2px(6);
whetherDrawText = true;
}
LinkedViewDecoration(Drawable nodeDrawable, Drawable nodeCheckedDrawable, int lineColor) {
this.nodeDrawable = nodeDrawable;
this.nodeCheckedDrawable = nodeCheckedDrawable;
this.lineColor = lineColor;
paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setStrokeWidth(DimensionUtil.dp2px(1));
gap = DimensionUtil.dp2px(6);
}
void setMaxValue(float max, float min) {
maxValue = max;
minValue = min;
}
@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDraw(c, parent, state);
final int childCount = parent.getChildCount();
LinkedViewAdapter.NodeBean bean0, bean1;
int height = parent.getHeight();
float pointX, pointY;
float childCw, childCh;
float dCx, dCy;
View childView0, childView1;
// 因为linearLayoutManager.setReverseLayout(true),list被反序添加;所以这里反向遍历
for (int i = childCount - 1; i >= 0; i--) {
childView0 = parent.getChildAt(i);
bean0 = (LinkedViewAdapter.NodeBean) childView0.getTag();
childCw = childView0.getWidth() / 2.0f;
childCh = childView0.getHeight() / 2.0f;
pointX = childView0.getRight() - childCw;
pointY = childView0.getBottom() - CalcPointY.getPointY(height, bean0.number, maxValue, minValue);
// Log.i("TAG", "onDraw: pointY " + pointY);
//画连接线,最后一个不用画
if (i != 0) {
paint.setColor(lineColor);
childView1 = parent.getChildAt(i - 1);
bean1 = (LinkedViewAdapter.NodeBean) childView1.getTag();
float x1 = pointX + childView0.getWidth();
float y1 = childView1.getBottom() - CalcPointY.getPointY(height, bean1.number, maxValue, minValue);
c.drawLine(pointX, pointY, x1, y1, paint);
}
if (bean0.isChecked) {
dCx = nodeCheckedDrawable.getIntrinsicWidth() / 2;
//如果drawable的高度或宽度比childView的大,就改为childView的高宽
if (dCx > childCw) {
dCx = childCw;
}
dCy = nodeCheckedDrawable.getIntrinsicHeight() / 2;
if (dCy > childCh) {
dCy = childCh;
}
nodeCheckedDrawable.setBounds((int) (pointX - dCx), (int) (pointY - dCy),
(int) (pointX + dCx), (int) (pointY + dCy));
nodeCheckedDrawable.draw(c);
if (!whetherDrawText) {
continue;
}
String number = String.valueOf(bean0.number);
paint.setTextAlign(Paint.Align.CENTER);
paint.setColor(textColor);
paint.setTextSize(textSize * 2f);
//根据不同情况计算文字纵坐标
float y0 = pointY - dCy - gap;
Rect rect = new Rect();
paint.getTextBounds(number, 0, 1, rect);
if (y0 < rect.height()) {
y0 = pointY + dCy + gap + rect.height();
}
c.drawText(number, pointX, y0, paint);
} else {
dCx = nodeDrawable.getIntrinsicWidth() / 2;
//如果drawable的高度或宽度比childView的大,就改为childView的高宽
if (dCx > childCw) {
dCx = childCw;
}
dCy = nodeDrawable.getIntrinsicHeight() / 2;
if (dCy > childCh) {
dCy = childCh;
}
nodeDrawable.setBounds((int) (pointX - dCx), (int) (pointY - dCy),
(int) (pointX + dCx), (int) (pointY + dCy));
nodeDrawable.draw(c);
}
}
}
}
因为item是一个空的view,所以在onDraw或onDrawOver里面绘制都一样,这里不需要偏移item,不用实现getItemOffsets方法。另外这三个方法的调用顺序是:
getItemOffsets -> onDraw -> onDrawOver
我们来看onDraw方法,很简单,就是遍历所有可见的itemView,获取每个点的数据,然后计算坐标,绘制出来。
这样折线图就完成了。相比自定义view来实现,会简单很多,因为RecuclerView已经帮我们处理好了滑动,回收等等功能,如果自己去实现这些功能难度可能会大很多。