新手学自定义View系列(一)Demo

简述:

  1. 为什么要另写这篇Demo博客?

上篇博客最后给出了一个折线图的例子,记得当时是说下篇博客给出其源码,但是后来我又想了下,咱们既然是新手系列的自定义View,内容就需要做到详细,清楚和明白。如果随意丢出一坨源码对于新手来说太坑爹了,我们每一位老司机都是从新手走过来,相信大家对于动不动就扔出一坨代码博主,心中很是无奈。今天咱们就简单分析下这个demo。

  1. 回答上篇博客的问题?

上篇博客说到如何在一个自定义View中对某个特定区域实现点击触摸反馈。我们都知道对于View独立单一组件唯一可以设置点击的就是对整个View设置点击事件。对于一个View控件的某个区域也能实现点击触摸反馈吗?答案是可以的,本期的例子柱状图也是只有点击矩形才会有事件触发,而不是整个View控件。这实际上就是用到了一个特殊的API的Region区域。它可以框出某个VIew的某个特定的区域,然后在View的OnTouchEvent事件中,监听手指按下和抬起点是否落入对应的Region区域,如果落入就会给出回调,就这么简单粗暴。

  1. 自定义View直方图思路分析?

《新手学自定义View系列(一)Demo》 m3.png

  1. 首先从整个控件角度出发,任何控件实现都得包括了数据接口定义和UI渲染绘制两个方面。

  2. 对于数据接口定义,从本控件可以看到就两个数据因素,一是类型对应的名称(String),二是对应类型占最大值得比例(float),用比例更加直观反映出绘制的高度。两个数据因素,很多人认为写个类包一下,个人感觉对于View层直接渲染数据越是简单数据类型,它的通用性越强。所以我想到的时候通过外部传入一个List < Pair < String, float> >即可。

  3. 对于UI渲染绘制,考虑有两个方面静态绘制和点击交互。

  4. 对于静态绘制可以分为纯直方图的绘制和折线图绘制。

  5. 直方图的绘制包括坐标系绘制、文字集合绘制、矩形集合绘制。

  6. 对于坐标系绘制为了计算绘制坐标方便,将原来默认为View左上角的坐标原点,通过translate位移canvas画布正好移动到直方图坐标的原点位置。 文字的绘制就是上期博客讲的,不过是文字集合通过一个循环来绘制,矩形集合的绘制使用的是循环 + path.addRect方式,最后绘制整个Path就OK。

  7. 折线图的绘制包括点的集合绘制、线段的集合绘制。

  8. 点集合绘制采用的是drawPoints绘制多个点,线段集合绘制采用drawLines绘制多条线。

  9. 对于点击交互就比较麻烦点,思路是定义一个和View控件画布一样大小的Region,姑且定义为globalRegion.然后针对每一个矩形都定义一个Path和Region.把每个矩形通过path.addRect加入对应的Path中,通过region.setPath(path, globalRegion)将path区域和clip区域取交集,捕获出对应path的region区域坐标范围。最后把每个path添加到全局的mPath中,把每个region区域加入mRegionList集合中。

  10. 通过重写onTouchEvent,获得点击的点的坐标,通过遍历mRegionList集合判断触摸点是否落入对应的区域的Region的position。然后再次重绘,在onDraw方法中针对相应的position绘制不同颜色背景矩形即可。

  11. 最后一个坑就是坐标转换,因为在绘制的时候我们为了计算坐标简单将坐标系原点移到直方图坐标原点位置,但是在onTouchEvent触摸坐标系却还是以View左上角为原点的坐标系,所以需要有个触摸点坐标系向绘制坐标系的转换。利用了Matrix矩阵中逆矩阵的知识。

  1. 本篇博客包含哪些核心知识点内容?

    1. Canvas中绘制矩形(上篇博客已经讲过)

    2. Canvas的绘制多条线段(上篇博客已经讲过)

    3. Canvas的绘制多个点(上篇博客已经讲过)

    4. Canvas绘制文字(上篇博客已经讲过)

    5. Canvas的几何变换使用translate移动绘图坐标系(后期博客会详细讲解,可以先记住一下,下次讲的时候不会太陌生)

    6. Canvas的绘制Path(下期博客会详细讲解,可以先记住一下,下次讲的时候不会太陌生)

    7. Canvas中的Path与Region的组合使用裁切矩形区域。(难点,下期会深入讲解)

    8. Region区域的使用判断触摸点是否落入柱状图形的范围区域并给出触摸反馈的回调。(难点,下期会深入讲解)

    9. 变换坐标系后,通过Matrix类的逆矩阵实现,触摸坐标系的坐标向绘图坐标的坐标转换。(难点,下期会深入讲解)

1、数据接口定义List < Pair < String, float> >类型

  • 使用基本数据类型,有利于提高View的通用性
List<Pair<String,Float>> pairList = new ArrayList<>();      pairList.add(new Pair<>("Java",0.71f));
pairList.add(new Pair<>("Swift",0.61f));
pairList.add(new Pair<>("C",0.26f));
pairList.add(new Pair<>("C++",0.37f));
pairList.add(new Pair<>("Python",0.84f));
pairList.add(new Pair<>("Go",0.6f));
setContentView(new RectChart(this, pairList));//直接给contentView设置自定义View

2、坐标轴的绘制

  • 为了绘制方便和坐标计算方便,采用translate位移画布将原来View左上角原点移动到直方图原点(控件中两个坐标轴交点位置)
//初始化坐标系画布
    private void initCoordinateCanvas(Canvas canvas) {
    
        canvas.drawColor(Color.parseColor("#eeeeee"));//绘制画布纯色背景
        canvas.translate(100, mHeight - 100);//平移坐标系,原来View左上角原点移动到直方图原点
        // 获取测量矩阵(逆矩阵) 由于绘制画布的View坐标系被平移了,
        // 但是所处的触摸坐标系并没有改变,需要利用Matrix矩阵实现触摸坐标系向绘制坐标系转化
        if (mMatrix.isIdentity()) {
            canvas.getMatrix().invert(mMatrix);
        }
        
        float[] opts = {0, 0, mWidth - 200, 0, 0, 0, 0, -(mHeight - 200)};//两条线8个点坐标数据
        canvas.drawLines(opts, mCoordinatePaint);//绘制两条线

        mCoordinatePaint.setStrokeCap(Paint.Cap.ROUND);//绘制点为圆点
        mCoordinatePaint.setStrokeWidth(20f);//设置点的大小
        canvas.drawPoint(0, 0, mCoordinatePaint);//绘制两条线段
    }

3、文字集合的绘制

  • 文字绘制采用了paint.setTextBounds方式拿到文字的尺寸
        //初始化文字画笔
        mTextPaint = new Paint();
        mTextPaint.setAntiAlias(true);
        mTextPaint.setColor(Color.parseColor("#C2185B"));
        mTextPaint.setTextSize(40);
        if (mDataList == null || mDataList.isEmpty()) {
            return;
        }
        //先取出数据中所有的文字测量每个文字尺寸,并把每个文字尺寸信息记录在对应的rect中,通过paint.getTextBounds(text,start,end,rect),
        // 最后把每个文字的rect保存在集合中
        for (Pair<String, Float> pair : mDataList) {
            Rect textBound = new Rect();
            mTextPaint.getTextBounds(pair.first, 0, pair.first.length(), textBound);
            mTextBounds.add(textBound);
        }
        //绘制对应矩形居中的文字,通过每个矩形region中的left,left + (矩形宽度 - 文字宽度) / 2 作为文字绘制的起点x, y 取文字高度。因为文字绘制起点很怪异是第一个字的左下角还要向左偏移一点距离。
            private void drawTextList(Canvas canvas) {
        for (int i = 0; i < mRegionList.size(); i++) {
            canvas.drawText(mDataList.get(i).first, mRegionList.get(i).getBounds().left + (mRectWidth / 2 - mTextBounds.get(i).width() / 2), mTextBounds.get(i).height() + 20F, mTextPaint);
        }
    }

3、矩形集合的绘制

  • 矩形集合绘制比较麻烦,需要裁剪每个rect的region,以及添加对应的path
mGlobalRegion = new Region(-w, -h, w, h);//创建全局的region

        mPointList.clear();//重置折线图中点集合
        mRegionList.clear();//重置region集合
        for (int i = 0; i < mDataList.size(); i++) {
            //根据每个矩形需要添加一个间隔距离,第一个矩形left就是间隔,第二个就是第一个矩形的left + 间隔
            //反推第N个就是第 N-1 个矩形的left + 间隔。那么这个left =  mGap * (i + 1) + mRectWidth * i
            float left = mGap * (i + 1) + mRectWidth * i;
            //top就是按比例算,负数因为绘制处于Y的负半轴,mHeight-200是为了留出坐标轴顶点距离控件底部距离为200.
            float top = -mDataList.get(i).second * (mHeight - 200);
            float right = left + mRectWidth;
            //-mCoordinatePaint.getStrokeWidth()一个细微处理为了防止矩形绘制,会盖住底部坐标轴
            float bottom = -mCoordinatePaint.getStrokeWidth();

            //创建折线图中每个点,每个点位置也正处于矩形宽度中点位置
            Point point = new Point(left + mRectWidth / 2, top);
            mPointList.add(point);

            //为每个矩形创建一个path
            Path path = new Path();
            //向每个path中添加一个矩形
            path.addRect(left, top, right, bottom, Path.Direction.CW);
            //创建一个region
            Region region = new Region();
            //在全局的mGlobalRegion中裁剪出对应path的矩形范围,并把相应的范围信息保存在region
            region.setPath(path, mGlobalRegion);
            //把每个path添加全局的mPath中
            mPath.addPath(path);
            //保存每个region信息到集合中
            mRegionList.add(region);
        }
    //绘制直方图
    private void drawHistogram(Canvas canvas) {
        canvas.drawPath(mPath, mRectPaint);//绘制最后mPath
        if (mClickPosition != -1) {//判断点击mClickPosition,根据位置重新绘制点击色的矩形
            mRectPaint.setColor(mPressColor);
            canvas.drawRect(mRegionList.get(mClickPosition).getBounds(), mRectPaint);
            mClickPosition = -1;//重置ClickPosition
        }
    }

4、折线图的绘制

  • 折线图绘制比较简单,就是对一组点和一组线段的绘制,关键就是坐标的计算,我们在计算矩形的位置顺带就把点的信息计算好,最后保存在pointList集合,直接拿来用即可。

//计算点的坐标,创建折线图中每个点,每个点位置也正处于矩形宽度中点位置
            Point point = new Point(left + mRectWidth / 2, top);
            mPointList.add(point);

//绘制折线图
    private void drawPolyline(Canvas canvas) {
        for (int i = 0; i < mPointList.size(); i++) {
            //绘制点交点
            mCoordinatePaint.setStrokeWidth(20f);
            canvas.drawPoint(mPointList.get(i).x, mPointList.get(i).y, mCoordinatePaint);
            //绘制连线
            if (i < mPointList.size() - 1) {
                mCoordinatePaint.setStrokeWidth(5f);
                canvas.drawLine(mPointList.get(i).x, mPointList.get(i).y, mPointList.get(i + 1).x, mPointList.get(i + 1).y, mCoordinatePaint);
            }
        }
    }

5、点击交互的实现

  • 它实现核心在于region中提供一个region.contain(x, y)方法可以判断传入点的坐标是否落入当前的region内,返回true or false
@Override
    public boolean onTouchEvent(MotionEvent event) {
        float[] pts = new float[2];
        pts[0] = event.getX();
        pts[1] = event.getY();
        mMatrix.mapPoints(pts);//利用Matrix矩阵实现触摸坐标系向绘制坐标系转化

        int touchX = (int) pts[0];
        int touchY = (int) pts[1];

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_UP:
                mClickPosition = findTouchPoint(touchX, touchY);
                if (mClickPosition != -1) {
                    invalidate();
                    Toast.makeText(getContext(), String.format(Locale.US, "当前选中: %s 数据为: %f", mDataList.get(mClickPosition).first, mDataList.get(mClickPosition).second), Toast.LENGTH_SHORT).show();
                }
                break;
        }
        return super.onTouchEvent(event);
    }

    //根据触摸点的坐标找到对应的position
    private int findTouchPoint(int touchX, int touchY) {
        int position = -1;
        for (int i = 0; i < mRegionList.size(); i++) {
            Region region = mRegionList.get(i);
            if (region.contains(touchX, touchY)) {//region中提供一个非常不错的API,region.contains(x,y),可以判断传入点的坐标是否落入当前的region内,返回true or false
                position = i;//触摸点落入对应region也就找到对应region所在position
                return position;
            }
        }
        return position;
    }

6、坐标转换的坑

  • 由于一开始为了绘制方便变换了绘图坐标系,可是触摸坐标又不能变换,手指触摸的坐标系和画布坐标系不统一,就可能引起手指触摸位置和绘制位置不统一,只能将触摸的坐标转化成绘图坐标系。这里需要用到Matrix矩阵知识,Matrix最大功能之一就是坐标映射,数值转换,后期会专门了解Matrix。
canvas.translate(100, mHeight - 100);//平移坐标系
        // 获取测量矩阵(逆矩阵) 由于绘制画布的View坐标系被平移了,
        // 但是所处的触摸坐标系并没有改变,需要利用Matrix矩阵实现触摸坐标系向绘制坐标系转化
        if (mMatrix.isIdentity()) {
            canvas.getMatrix().invert(mMatrix);
        }
        
mMatrix.mapPoints(pts);//利用Matrix矩阵实现触摸坐标系向绘制坐标系转化

7、最后附上全部源码

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Rect;
import android.graphics.Region;
import android.util.AttributeSet;
import android.util.Pair;
import android.view.MotionEvent;
import android.widget.Toast;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

public class RectChart extends CanvasView {

    private int mNormalColor;
    private int mPressColor;

    private Paint mRectPaint;//绘制矩形的画笔
    private Paint mTextPaint;//绘制文字的画笔
    private Paint mCoordinatePaint;//绘制坐标轴的画笔

    private Matrix mMatrix;//用于坐标转换的矩阵Matrix的对象
    private Path mPath = new Path();//定义全局所有矩形path集合,最后所有矩形的Path会加入到mPath中
    private Region mGlobalRegion;//定义全局的Region

    private List<Rect> mTextBounds = new ArrayList<>();//为了绘制文字的集合,每个文字都用rect框住,便于拿到文字的宽度和高度。
    private List<Point> mPointList = new ArrayList<>();//为了绘制折线图中点集合,每个point对象包含了x,y坐标
    private List<Region> mRegionList = new ArrayList<>();//为每个矩形集合定义的region集合

    private int mWidth;//控件的宽度
    private int mHeight;//控件的高度
    private float mGap = 40f;//每个矩形之间间隔大小
    private float mRectWidth = 80f;//每个矩形宽度大小
    private boolean isShowHistogram = true;//是否绘制矩形图
    private boolean isShowPolyline = true;//是否绘制折线图
    private int mClickPosition = -1;//用于记录刷选出点击落入对应的region的position

    private List<Pair<String, Float>> mDataList;//数据pair集合,pair对象第一个是用于底部文字绘制的内容,第二个是比例对应所画实际矩形高度。

    public RectChart(Context context, List<Pair<String, Float>> mDataList) {
        super(context);
        this.mDataList = mDataList;
    }

    @Override
    protected void initDrawTools() {
        //初始化矩形画笔
        mRectPaint = new Paint();
        mRectPaint.setAntiAlias(true);
        mRectPaint.setColor(mNormalColor);
        mRectPaint.setStyle(Paint.Style.FILL);

        //初始化坐标系画笔
        mCoordinatePaint = new Paint();
        mCoordinatePaint.setAntiAlias(true);
        mCoordinatePaint.setColor(Color.RED);
        mCoordinatePaint.setStyle(Paint.Style.STROKE);
        mCoordinatePaint.setStrokeWidth(5f);

        //初始化文字画笔
        mTextPaint = new Paint();
        mTextPaint.setAntiAlias(true);
        mTextPaint.setColor(Color.parseColor("#C2185B"));
        mTextPaint.setTextSize(40);
        if (mDataList == null || mDataList.isEmpty()) {
            return;
        }
        //先取出数据中所有的文字测量每个文字尺寸,并把每个文字尺寸信息记录在对应的rect中,通过paint.getTextBounds(text,start,end,rect),
        // 最后把每个文字的rect保存在集合中
        for (Pair<String, Float> pair : mDataList) {
            Rect textBound = new Rect();
            mTextPaint.getTextBounds(pair.first, 0, pair.first.length(), textBound);
            mTextBounds.add(textBound);
        }
        //创建矩阵对象
        mMatrix = new Matrix();
    }

    @Override
    protected void fetchDefAttrValues(Context context, AttributeSet attrs, int defStyleAttr) {
        //该方法用于接收自定义属性的值,本例子还没来得及添加自定义属性
        mNormalColor = Color.parseColor("#ff9900");
        mPressColor = Color.parseColor("#ff0000");
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//      super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        setMeasuredDimension(1080, 1000);//测量这里指定大小尺寸的画布即控件大小
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        if (mMatrix != null) {
            mMatrix.reset();
        }

        mWidth = w;
        mHeight = h;

        mGlobalRegion = new Region(-w, -h, w, h);//创建全局的region

        mPointList.clear();//重置折线图中点集合
        mRegionList.clear();//重置region集合
        for (int i = 0; i < mDataList.size(); i++) {
            //根据每个矩形需要添加一个间隔距离,第一个矩形left就是间隔,第二个就是第一个矩形的left + 间隔
            //反推第N个就是第 N-1 个矩形的left + 间隔。那么这个left =  mGap * (i + 1) + mRectWidth * i
            float left = mGap * (i + 1) + mRectWidth * i;
            //top就是按比例算,负数因为绘制处于Y的负半轴,mHeight-200是为了留出坐标轴顶点距离控件底部距离为200.
            float top = -mDataList.get(i).second * (mHeight - 200);
            float right = left + mRectWidth;
            //-mCoordinatePaint.getStrokeWidth()一个细微处理为了防止矩形绘制,会盖住底部坐标轴
            float bottom = -mCoordinatePaint.getStrokeWidth();

            //创建折线图中每个点,每个点位置也正处于矩形宽度中点位置
            Point point = new Point(left + mRectWidth / 2, top);
            mPointList.add(point);

            //为每个矩形创建一个path
            Path path = new Path();
            //向每个path中添加一个矩形
            path.addRect(left, top, right, bottom, Path.Direction.CW);
            //创建一个region
            Region region = new Region();
            //在全局的mGlobalRegion中裁剪出对应path的矩形范围,并把相应的范围信息保存在region
            region.setPath(path, mGlobalRegion);
            //把每个path添加全局的mPath中
            mPath.addPath(path);
            //保存每个region信息到集合中
            mRegionList.add(region);
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        initDrawTools();//初始化绘制工具
        initCoordinateCanvas(canvas);//初始化画布坐标轴

        drawTextList(canvas);//绘制文字集合
        if (isShowHistogram) {//绘制直方图
            drawHistogram(canvas);
        }

        if (isShowPolyline) {//绘制折线图
            drawPolyline(canvas);
        }

    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        float[] pts = new float[2];
        pts[0] = event.getX();
        pts[1] = event.getY();
        mMatrix.mapPoints(pts);//利用Matrix矩阵实现触摸坐标系向绘制坐标系转化

        int touchX = (int) pts[0];
        int touchY = (int) pts[1];

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_UP:
                mClickPosition = findTouchPoint(touchX, touchY);
                if (mClickPosition != -1) {
                    invalidate();
                    Toast.makeText(getContext(), String.format(Locale.US, "当前选中: %s 数据为: %f", mDataList.get(mClickPosition).first, mDataList.get(mClickPosition).second), Toast.LENGTH_SHORT).show();
                }
                break;
        }
        return super.onTouchEvent(event);
    }

    //根据触摸点的坐标找到对应的position
    private int findTouchPoint(int touchX, int touchY) {
        int position = -1;
        for (int i = 0; i < mRegionList.size(); i++) {
            Region region = mRegionList.get(i);
            if (region.contains(touchX, touchY)) {//region中提供一个非常不错的API,region.contains(x,y),可以判断传入点的坐标是否落入当前的region内,返回true or false
                position = i;//触摸点落入对应region也就找到对应region所在position
                return position;
            }
        }
        return position;
    }

    private void drawTextList(Canvas canvas) {
        for (int i = 0; i < mRegionList.size(); i++) {
            canvas.drawText(mDataList.get(i).first, mRegionList.get(i).getBounds().left + (mRectWidth / 2 - mTextBounds.get(i).width() / 2), mTextBounds.get(i).height() + 20F, mTextPaint);
        }
    }

    //初始化坐标系画布
    private void initCoordinateCanvas(Canvas canvas) {
        canvas.drawColor(Color.parseColor("#eeeeee"));
        canvas.translate(100, mHeight - 100);//平移坐标系
        // 获取测量矩阵(逆矩阵) 由于绘制画布的View坐标系被平移了,
        // 但是所处的触摸坐标系并没有改变,需要利用Matrix矩阵实现触摸坐标系向绘制坐标系转化
        if (mMatrix.isIdentity()) {
            canvas.getMatrix().invert(mMatrix);
        }

        float[] opts = {0, 0, mWidth - 200, 0, 0, 0, 0, -(mHeight - 200)};
        canvas.drawLines(opts, mCoordinatePaint);//绘制两条线

        mCoordinatePaint.setStrokeCap(Paint.Cap.ROUND);
        mCoordinatePaint.setStrokeWidth(20f);
        canvas.drawPoint(0, 0, mCoordinatePaint);
    }

    //绘制直方图
    private void drawHistogram(Canvas canvas) {
        canvas.drawPath(mPath, mRectPaint);//绘制最后mPath
        if (mClickPosition != -1) {//判断点击mClickPosition,根据位置重新绘制点击色的矩形
            mRectPaint.setColor(mPressColor);
            canvas.drawRect(mRegionList.get(mClickPosition).getBounds(), mRectPaint);
            mClickPosition = -1;//重置ClickPosition
        }
    }

    //绘制折线图
    private void drawPolyline(Canvas canvas) {
        for (int i = 0; i < mPointList.size(); i++) {
            //绘制点交点
            mCoordinatePaint.setStrokeWidth(20f);
            canvas.drawPoint(mPointList.get(i).x, mPointList.get(i).y, mCoordinatePaint);
            //绘制连线
            if (i < mPointList.size() - 1) {
                mCoordinatePaint.setStrokeWidth(5f);
                canvas.drawLine(mPointList.get(i).x, mPointList.get(i).y, mPointList.get(i + 1).x, mPointList.get(i + 1).y, mCoordinatePaint);
            }
        }
    }

    class Point {
        private float x;
        private float y;

        public Point(float x, float y) {
            this.x = x;
            this.y = y;
        }
    }

}

《新手学自定义View系列(一)Demo》 image.png
《新手学自定义View系列(一)Demo》 image.png
《新手学自定义View系列(一)Demo》 image.png

结束

这个例子就算是讲完,下篇博客咱们一起讲下Path相关的内容。一下子水了这么多,好渴喝水去了。对于还不是很不知道知识,可以先记住。

    原文作者:mikyou
    原文地址: https://www.jianshu.com/p/d17d56bd0343
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞