使用RecyclerView优雅的实现折线图

众所周知,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已经帮我们处理好了滑动,回收等等功能,如果自己去实现这些功能难度可能会大很多。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值