一加天气的截图

复式折线图Python 复式折线图图片气温_初始化

模仿的效果图

复式折线图Python 复式折线图图片气温_折线_02

本文目的

为了学习带有折线的自定义控件的编写,以及巩固自定义控件的基础知识,以模仿一加天气中的6日温度折线图控件来达到学习目的。

原理

WeatherBean:

首先我们需要定义好数据的实体类,通过上面的图片可以看出,每一天的数据包含了日期、星期几、天气情况、最高温度以及最低温度。

public class WeatherBean {

    //定义天气的标识
    public static final int SUN = 1;               //晴
    public static final int CLOUDY = 2;            //阴
    public static final int SNOW = 3;              //雪
    public static final int RAIN = 4;              //雨天
    public static final int SUN_CLOUDY = 5;        //多云
    public static final int THUNDER = 6;           //雷

    private int weatherId;          //天气标识,取值为上面6种
    private String date;            //日期
    private String week;            //周,星期
    private int maxTemperature;     //最高温度
    private int minTemperature;     //最低温度

    public WeatherBean(int weatherId, String date, String week, int maxTemperature, int minTemperature) {
        this.weatherId = weatherId;
        this.date = date;
        this.week = week;
        this.maxTemperature = maxTemperature;
        this.minTemperature = minTemperature;
    }

    public int getWeatherId() {
        return weatherId;
    }

    public void setWeatherId(int weatherId) {
        this.weatherId = weatherId;
    }

    public String getDate() {
        return date;
    }

    public void setDate(String date) {
        this.date = date;
    }

    public String getWeek() {
        return week;
    }

    public void setWeek(String week) {
        this.week = week;
    }

    public int getMaxTemperature() {
        return maxTemperature;
    }

    public void setMaxTemperature(int maxTemperature) {
        this.maxTemperature = maxTemperature;
    }

    public int getMinTemperature() {
        return minTemperature;
    }

    public void setMinTemperature(int minTemperature) {
        this.minTemperature = minTemperature;
    }

    public static int[] getAllWeatherId() {
        int[] weatherId = {SUN, CLOUDY, SNOW, RAIN, SUN_CLOUDY, THUNDER};
        return weatherId;
    }
}

控件参数

private List<WeatherBean> data = new ArrayList<>(); //6组元数据
    private Map<Integer, Bitmap> icons = new HashMap<>(); //天气图标集合
    ArrayList<PointF> points = new ArrayList<>();   //点的集合
    private int interval;    //每一天所占的宽度,应为屏幕宽度的1/6
    private int screenWidth;
    private int screenHeight;
    private int maxPointHeight;     //所有最高温度点的最高高度
    private int minPointHeight;     //所有最低温度点的最低高度
    private int maxTemperature;     //元数据中所有温度的的最高和最低温度
    private int minTemperature;
    private float pointRadius; //折线拐点的半径,就是小圆点
    private int viewHeight;
    private int viewWidth;
    private float pointUnitH;     //折线拐点的单位高度
    private float iconWidth;        //天气图标的边长

    private Paint linePaint;       //画线的笔
    private Paint textPaint;       //画文字的笔
    private Paint circlePaint;     //画拐点的笔

部分参数图示

复式折线图Python 复式折线图图片气温_折线_03


可以从图中看出一些参数的具体含义,以及控件的坐标轴方便后面理解。上下两条黑色横线是控件的上边和下边。

初始化

由于硬件加速会引起自定义view出现问题,我们这里需要关闭硬件加速。
关闭硬件加速的方法是在AndroidManifest.xml里加入一句
android:hardwareAccelerated=”false”
放在< application />节点下表示关闭整个项目的硬件加速
放在< activity />下表示关闭该组件硬件加速

创建一个OnePlusWeatherView类并继承View,实现1~3个参数的构造,并在1和2个参数的构造函数中依次调用。

public OnePlusWeatherView(Context context) {
        //这地方改为this,使得不管怎么初始化都会进入第三个构造函数中
        this(context, null);
    }

    public OnePlusWeatherView(Context context, @Nullable AttributeSet attrs) {
        //这地方改为this,使得不管怎么初始化都会进入第三个构造函数中
        this(context, attrs, 0);
    }

    public OnePlusWeatherView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        initSize(context);

        initPaint(context);

        initIcons();
    }

在第三个构造函数中依次调用:initSize(context)、initPaint(context)、initIcons()。

/**
     * 初始化一些固定数据,如长宽,间隔之类的
     *
     * @param context
     */
    private void initSize(Context context) {
        //拿到屏幕的宽高,单位是像素
        screenWidth = getResources().getDisplayMetrics().widthPixels;
        screenHeight = getResources().getDisplayMetrics().heightPixels;

        //控件高度为屏幕宽度 - 100dp
        viewHeight = (int) (screenWidth - dp2pxF(getContext(), 100));
        //控件宽度为屏幕宽度
        viewWidth = screenWidth;

        //间隔为屏幕的1/6
        interval = (int) (screenWidth / 6.0f);

        //所有最高温度点的最高高度,默认是控件的高度的1/2
        maxPointHeight = viewHeight / 2;
        //所有最低温度点的最低高度,默认给个15dp,就是从控件下边到最低点的距离
        minPointHeight = (int) dp2pxF(getContext(), 20f);

        //天气图标的边长,默认是间隔的一半
        iconWidth = interval / 2.0f;

        //默认折线拐点圆的半径为3dp
        pointRadius = dp2pxF(context, 3f);
    }
/**
     * 初始化画笔
     *
     * @param context
     */
    private void initPaint(Context context) {
        //创建一个画线的笔
        linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        //设置线宽度
        linePaint.setStrokeWidth(dp2px(context, 1f));

        //创建一个画文字的笔
        textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        textPaint.setTextAlign(Paint.Align.CENTER);  //设置文字居中
        textPaint.setColor(Color.WHITE);

        //创建一个画折线拐点(圆)的笔
        circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        circlePaint.setColor(Color.WHITE);
    }
/**
     * 根据天气ID获取对应的图标并缩放到指定大小
     *
     * @param weatherId
     * @param width     指定宽度
     * @param height    指定高度
     * @return
     */
    private Bitmap getWeatherIconForId(int weatherId, float width, float height) {
        int iconResId = getIconResId(weatherId);
        Bitmap bmp;
        BitmapFactory.Options options = new BitmapFactory.Options();
        //options.inJustDecodeBounds = true 表示只读图片,不加载到内存中,
        // 设置这个参数为ture,就不会给图片分配内存空间,但是可以获取到图片的大小等属性; 设置为false, 就是要加载这个图片.
        options.inJustDecodeBounds = true;
        //拿到图片的参数,图片参数会传到options中
        BitmapFactory.decodeResource(getResources(), iconResId, options);
        int outWidth = options.outWidth;
        int outHeight = options.outHeight;
        options.inSampleSize = 1; //先设置采样大小为1
        if (outWidth > width || outHeight > height) {  //如果图片的实际宽或高要比指定的宽或高大就要缩小
            // 计算出实际宽高和目标宽高的比率 ,四舍五入
            int ratioW = Math.round(outWidth / width);
            int ratioH = Math.round(outHeight / height);
            //取大的,对原图进行降采样
            options.inSampleSize = Math.max(ratioW, ratioH);
        }
        //设置为false了,就是要加载图片了
        options.inJustDecodeBounds = false;
        bmp = BitmapFactory.decodeResource(getResources(), iconResId, options);
        return bmp;
    }

    /**
     * 通过天气Id获取对应的图片ResId
     *
     * @param weatherId
     * @return
     */
    private int getIconResId(int weatherId) {
        int resId;
        switch (weatherId) {
            case WeatherBean.SUN:
                resId = R.mipmap.ic_sun;
                break;
            case WeatherBean.CLOUDY:
                resId = R.mipmap.ic_cloudy;
                break;
            case WeatherBean.RAIN:
                resId = R.mipmap.ic_rain;
                break;
            case WeatherBean.SNOW:
                resId = R.mipmap.ic_snow;
                break;
            case WeatherBean.SUN_CLOUDY:
                resId = R.mipmap.ic_sun_cloudy;
                break;
            case WeatherBean.THUNDER:
                resId = R.mipmap.ic_thunder;
                break;
            default:
                resId = R.mipmap.ic_sun;
        }
        return resId;
    }

注释的很详细了,这里不做过多的解释。不过需要注意的是,在getWeatherIconForId()方法中我们对图片进行了压缩,具体可参考郭神的:Android高效加载大图、多图解决方案,有效避免程序OOM

接下来我们需要给view设置元数据,所以提供一个对外公开方法:setData()

/**
     * 传入数据
     *
     * @param data
     */
    public void setData(List<WeatherBean> data) {
        if (data == null || data.isEmpty() || data.size() < 6) {
            return;
        }

        this.data = data;

        //View本身调用迫使view重画
        invalidate();
    }

本方法需要传入一个类型为WeatherBean的list,来设置元数据,最后别忘了要使view重绘。

重新onMeasure

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        //设置控件的最终视图大小(宽高)
        setMeasuredDimension(viewWidth, viewHeight);
        //计算温度点的单位高度
        calculatePointUnitHeight();
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        initSize(getContext());
        calculatePointUnitHeight();
    }

看注释,在onSizechanged中,如果我们的控件大小改变我们就重新初始化一些参数。

/**
     * 计算温度点的单位高度
     */
    private void calculatePointUnitHeight() {
        int lastMaxTem = -1000;   //临时存储当前遍历到的最高温度和最低温度
        int lastMinTem = 1000;

        for (WeatherBean bean : data) {
            if (bean.getMaxTemperature() > lastMaxTem) {
                lastMaxTem = bean.getMaxTemperature();
            }

            if (bean.getMinTemperature() < lastMinTem) {
                lastMinTem = bean.getMinTemperature();
            }
        }

        maxTemperature = lastMaxTem;
        minTemperature = lastMinTem;

        //计算出温差值
        float gap = (maxTemperature - minTemperature) / 1.0f;
        gap = (gap == 0.0f ? 1.0f : gap);      //保证分母不为0
        //计算出折线拐点的单位高度
        pointUnitH = (maxPointHeight - minPointHeight) / gap;
    }

这个方法中,我们遍历了所有温度点的找到最高温度和最低温度,然后计算出最大温差,进而求出了折线拐点的单位高度。

onDraw

好了,到了我们最重要的地方onDraw方法,也是编写核心逻辑的地方,这里就是用来绘制控件的地方。

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        drawVerticalLines(canvas);

        drawDateAndWeekText(canvas);

        drawWeatherIcon(canvas);

        drawBrokenLine(canvas);

        drawTemperatureText(canvas);
    }

在onDraw中我调用了5个方法,也就是我分了5步来绘制这个控件,接下来我们一步一步的分开讲。

1.drawVerticalLines(canvas)

/**
     * 画竖线 5根
     *
     * @param canvas
     */
    private void drawVerticalLines(Canvas canvas) {
        canvas.save();//保存画布当前状态(平移、放缩、旋转、错切、裁剪等),和canvas.restore()配合使用

        linePaint.setColor(Color.WHITE);
        linePaint.setAlpha(50);

        float starX, starY, stopX, stopY;
        starY = 0;
        stopY = viewHeight;
        for (int i = 0; i < 5; i++) {
            starX = stopX = (i + 1) * interval;

            canvas.drawLine(starX, starY, stopX, stopY, linePaint);
        }
        canvas.restore();
    }

这里简单,就是把屏幕宽分成了6分,然后用5根竖线隔开。每一根竖线的starX和stopX是一样的,而startX坐标通过我们前面初始化好的interval就能计算出来。

效果图:

复式折线图Python 复式折线图图片气温_初始化_04

2.drawDateAndWeekText(canvas)

/**
     * 画日期的文字
     *
     * @param canvas
     */
    private void drawDateAndWeekText(Canvas canvas) {
        canvas.save();

        //画最上面那一行的日期
        textPaint.setTextSize(sp2pxF(getContext(), 14f));
        //拿到字体测量器
        Paint.FontMetrics metrics = textPaint.getFontMetrics();
        float x;
        //ascent:上坡度,是文字的基线到文字的最高处的距离
        //descent:下坡度,,文字的基线到文字的最低处的距离
        float dateY = dp2pxF(getContext(), 10f) - (metrics.ascent + metrics.descent) / 2;
        for (int i = 0; i < 6; i++) {
            String dateText = data.get(i).getDate();
            x = i * interval + interval / 2;
            canvas.drawText(dateText, x, dateY, textPaint);
        }

        //画第二行的星期
        textPaint.setTextSize(sp2pxF(getContext(), 12f));
        //ascent:上坡度,是文字的基线到文字的最高处的距离
        //descent:下坡度,,文字的基线到文字的最低处的距离
        float weekY = dp2pxF(getContext(), 30f) - (metrics.ascent + metrics.descent) / 2;
        for (int i = 0; i < 6; i++) {
            String weekText = data.get(i).getWeek();
            x = i * interval + interval / 2;
            canvas.drawText(weekText, x, weekY, textPaint);
        }

        canvas.restore();
    }

这里我们先画上面一个的日期,就是10/19…这些,然后再画第二行星期几。而每一行的Y坐标是相同的,X坐标通过前面初始化好的interval算出来,这里简单讲一下Paint.FontMetrics,这个类根据当前画笔设置的文字大小封装了绘制文字时的各种参考线和基线,ascent代表的是上坡度,descent代表的下坡度。

具体参考图:

复式折线图Python 复式折线图图片气温_初始化_05

编写完后的效果图:

复式折线图Python 复式折线图图片气温_折线_06

3.drawWeatherIcon(canvas)

/**
     * 画天气图标
     *
     * @param canvas
     */
    private void drawWeatherIcon(Canvas canvas) {
        canvas.save();
        float iconX, iconY;   //图标的坐标
        //Y坐标都是一样的,默认为控件高度的3/8再往上一点
        iconY = (viewHeight * 3) / 8 - dp2pxF(getContext(), 10f);
        for (int i = 0; i < 6; i++) {
            //拿到每一天的天气对应的图标
            Bitmap icon = icons.get(data.get(i).getWeatherId());
            iconX = i * interval + interval / 2;

            //创建一个用来绘制图标的矩形区域
            RectF iconRect = new RectF(iconX - iconWidth / 2.0f,
                    iconY - iconWidth / 2.0f,
                    iconX + iconWidth / 2.0f,
                    iconY + iconWidth / 2.0f);
            canvas.drawBitmap(icon, null, iconRect, null);
        }


        canvas.restore();
    }

这步由于我们前面已经初始化好了图片,所以这里只需算出左边点,然后创建矩形区域,再绘制出来就可以了。而X坐标还是通过interval算出来的,Y坐标默认为控件高度的3/8再往上一点,而且每一组是一样的,因为是一行嘛。

效果图:

复式折线图Python 复式折线图图片气温_控件_07

4.drawBrokenLine(canvas)

/**
     * 画折线图了
     *
     * @param canvas
     */
    private void drawBrokenLine(Canvas canvas) {
        canvas.save();

        //初始化点的集合,放在这里是因为传入了数据才有点,也遍历出了最高温度和最低温度,才可以初始化
        initPoints();

        linePaint.setStyle(Paint.Style.FILL_AND_STROKE);  //设置为填充且描边
        linePaint.setColor(Color.WHITE);
        linePaint.setAlpha(120);

        //定义一个用于绘制折线的Path
        //Path()类用于画线(直线,曲线都可以),有时也用于画轮廓,就是描述路径的类
        //用Canvas中的drawPath能把这个路径画出来
        Path linePath = new Path();
        for (int i = 0; i < points.size(); i++) {
            float x = points.get(i).x;
            float y = points.get(i).y;
            if (i == 0) {
                linePath.moveTo(x, y);
            } else {
                linePath.lineTo(x, y);
            }
        }
        //画折线
        canvas.drawPath(linePath, linePaint);

        //画拐点
        for (PointF point : points) {
            if (point.x != 0 && point.x != screenWidth) {
                //不在屏幕边框上的点就画出来
                canvas.drawCircle(point.x, point.y, pointRadius, circlePaint);
            }
        }

        canvas.restore();
    }

    /**
     * 初始化点的集合,总共有16个点, 温度点12个,屏幕边缘上4个
     */
    private void initPoints() {
        points.clear();
        float x;
        float y;

        //先初始化上段折线的点
        for (int i = 0; i < 6; i++) {
            //拿到最高温度与最低温度的温度差
            int temGap = data.get(i).getMaxTemperature() - minTemperature;
            x = i * interval + interval / 2.0f;
            y = (viewHeight - (minPointHeight + temGap * pointUnitH));  //计算出拐点的Y坐标
            if (i == 0) {
                //先添加屏幕左边框上面的一个点,x=0,高度就比第一今日最高点低10dp
                points.add(new PointF(0, y + dp2pxF(getContext(), 10f)));
            }
            //再把这个点添加上
            points.add(new PointF(x, y));

            if (i == 5) {
                //最后添加屏幕右框上面的一个点,x=屏幕宽度,高度就比最右边那天的最高点低10dp
                points.add(new PointF(screenWidth, y + dp2pxF(getContext(), 10f)));
            }
        }

        //再初始化下段折线的点,这次顺序是从右往左了,注意这里是i--
        for (int i = 5; i >= 0; i--) {
            //拿到最低温度与最低温度的温度差
            int temGap = data.get(i).getMinTemperature() - minTemperature;
            x = i * interval + interval / 2.0f;
            y = (viewHeight - (minPointHeight + temGap * pointUnitH));  //计算出拐点的Y坐标
            if (i == 5) {
                //先添加屏幕右边框下面的一个点,x=屏幕宽度,高度就比最右边那天的最低点低10dp,形成平行效果
                points.add(new PointF(screenWidth, y + dp2pxF(getContext(), 10f)));
            }
            //再把这个点添加上
            points.add(new PointF(x, y));

            if (i == 0) {
                //最后添加屏幕左边框下面的一个点,x=0,高度就比最右边那天的最低点低10dp
                points.add(new PointF(0, y + dp2pxF(getContext(), 10f)));
            }
        }
    }

这步代码稍微多了一点,不过没关系,我们慢慢看。先初始化了点的集合,这个集合中会包含16个点

图示:

复式折线图Python 复式折线图图片气温_初始化_08


按顺时针顺序初始化点,每个点的X坐标相信大家都会算了,就是通过interval就简单的算出来了,而Y坐标是通过每一天的最高温度或最低温度减去这6天里的最低温度再乘以前面算好的点的单位高度pointUnitH就能得到每一天的最高温度或最低温度对应的点的高度,然后用控件的高度viewHeight减去这个高度就得到了相应的Y坐标。点集合初始化完了也就是得到了所有点的坐标值,然后就能很轻松的画出折线,以及画出拐点圆。

这里简单的介绍一下Path()这个类,Path:英文意思就是道路、路径的意思,而这个类就是用于画线(直线,曲线都可以),所以通俗点讲就是用于描述路径,或者描述轮廓的。还要注意一点的是我们要调用用linePaint.setStyle(Paint.Style.FILL_AND_STROKE);,这样我们画完Path后里面也会相应的填充起来,也就是实心的意思。

然后我们再画拐点圆、这里需要注意的是我们还是按顺时针的方向依次对应画的,并且要判断一下X坐标,如何是落在了屏幕边框上,即X=0或X=screenWidth,我们就不要画。

效果图:

复式折线图Python 复式折线图图片气温_初始化_09

5.drawTemperatureText(canvas)

/**
     * 画出与点对应的温度
     *
     * @param canvas
     */
    private void drawTemperatureText(Canvas canvas) {
        canvas.save();

        textPaint.setTextSize(sp2pxF(getContext(), 12f));

        String text;
        float x;
        float y;

        //先画上面6个点的温度,数据对应的是0~5,坐标对应的是points集合中的1~6
        for (int i = 0; i < 6; i++) {
            //上面的是最高温度
            text = data.get(i).getMaxTemperature() + "°";
            x = points.get(i + 1).x;
            y = points.get(i + 1).y - dp2pxF(getContext(), 12f); //要比拐点高一点,注意是减
            Paint.FontMetrics metrics = textPaint.getFontMetrics();
            canvas.drawText(text, x, y - (metrics.ascent + metrics.descent) / 2, textPaint);
        }

        //再画下面6个点的温度,数据对应的是0~5,坐标对应的是points集合中的14~9
        for (int i = 0; i < 6; i++) {
            //下面的是最低温度,注意这里是5-i
            text = data.get(5 - i).getMinTemperature() + "°";
            x = points.get(i + 9).x;
            y = points.get(i + 9).y + dp2pxF(getContext(), 12f);  //要比拐点低一点,注意是加
            Paint.FontMetrics metrics = textPaint.getFontMetrics();
            canvas.drawText(text, x, y - (metrics.ascent + metrics.descent) / 2, textPaint);
        }

        canvas.restore();
    }

由于我们上面一步拿到了点的集合,所以这一步也相应的比较简单了,因为我们坐标都有了,而无非就是调整一下Y坐标,上面的点对应的温度值的Y坐标上移一点,下面的点对有的温度值的Y坐标下移一样,而X坐标与点的X坐标是一样的。还需注意一点的是元数据的温度点与上一步我们拿到的点的集合的对应关系。

上面6个点:数据对应的是0~5,坐标对应的是points集合中的1~6

下面6个点:数据对应的是0~5,坐标对应的是points集合中的14~9

效果图:

复式折线图Python 复式折线图图片气温_控件_10


到了这里我们对自定义view的编写的算是大功告成了!

最后

编写完了自定义View的类别忘了添加到Activity里
activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#4B97D1"
    android:orientation="vertical"
    tools:context="com.fu.oneplusweather.MainActivity">

    <com.fu.oneplusweather.OnePlusWeatherView
        android:id="@+id/weather_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"/>

</LinearLayout>

MainActivity

public class MainActivity extends AppCompatActivity {

    private OnePlusWeatherView weatherview;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        this.weatherview = (OnePlusWeatherView) findViewById(R.id.weather_view);

        List<WeatherBean> data = new ArrayList<>();
        WeatherBean w1 = new WeatherBean(1, "10/19", "今日", 20, 15);
        WeatherBean w2 = new WeatherBean(2, "10/20", "周五", 23, 16);
        WeatherBean w3 = new WeatherBean(3, "10/21", "周六", 21, 16);
        WeatherBean w4 = new WeatherBean(4, "10/22", "周日", 18, 16);
        WeatherBean w5 = new WeatherBean(5, "10/23", "周一", 18, 14);
        WeatherBean w6 = new WeatherBean(6, "10/24", "周二", 21, 14);

        data.add(w1);
        data.add(w2);
        data.add(w3);
        data.add(w4);
        data.add(w5);
        data.add(w6);

        weatherview.setData(data);

    }
}