自定义View之仿小米MIUI天气24小时预报折线图

本篇文章已授权微信公众号 hongyangAndroid(鸿洋)独家发布。

效果图

自定义View之仿小米MIUI天气24小时预报折线图_第1张图片

本控件是仿MIUI8天气24小时预报折线图,用小米手机的可以打开天气软件看一下。本文是对自定义View的练手作品,要有写自定义view的基础知识。

使用方法

xml:

    <com.example.ccy.miuiweatherline.MiuiWeatherView
        android:id="@+id/weather"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:line_interval="60dp"
        app:min_point_height="60dp"
        app:background_color="#ffffff"/>

line_interval 表示折线单位长度
min_point_height 表示折线最低高度
background_color 表示背景颜色

在代码里,使用WeatherBean作为数据项:

        weatherView = (MiuiWeatherView) findViewById(R.id.weather);
        List<WeatherBean> data = new ArrayList<>();
                WeatherBean b1 = new WeatherBean(WeatherBean.SUN,20,"05:00");
        WeatherBean b2 = new WeatherBean(WeatherBean.RAIN,22,"日出","05:30");
        //...b3、b4......bn
        data.add(b1);
        data.add(b2);

        weatherView.setData(data);

原理

源码地址

本文源码:https://github.com/CCY0122/miuiweatherline

WeatherBean

在进入主体编写之前,我们先把数据实体给定义好,很简单,我们这个view每项数据包含了天气、温度、时间三个值,那么可以写出的WeatherBean如下:

public class WeatherBean {

    public static final String SUN = "晴";
    public static final String CLOUDY ="阴";
    public static final String SNOW = "雪";
    public static final String RAIN = "雨";
    public static final String SUN_CLOUD = "多云";
    public static final String THUNDER = "雷";

    public String weather;  //天气,取值为上面6种
    public int temperature; //温度值
    public String temperatureStr; //温度的描述值
    public String time; //时间值

    public WeatherBean(String weather, int temperature,String time) {
        this.weather = weather;
        this.temperature = temperature;
        this.time = time;
        this.temperatureStr = temperature + "°";
    }

    public WeatherBean(String weather, int temperature, String temperatureStr, String time) {
        this.weather = weather;
        this.temperature = temperature;
        this.temperatureStr = temperatureStr;
        this.time = time;
    }

    public static String[] getAllWeathers(){
        String[] str = {SUN,RAIN,CLOUDY,SUN_CLOUD,SNOW,THUNDER};
        return str;
    }

通过看上面效果图知道,温度值也可以是文字的(效果图中就有“日落”文字),故额外定义了一个temperatureStr,其值默认为温度加上一个符号(°)

定义参数

仔细观看gif效果图(或直接看小米天气),分析分析都要定义哪些参数。经过分析,下面列出本控件主要用到的参数:

    private int backgroundColor;
    private int minViewHeight; //控件的最小高度
    private int minPointHeight;//折线最低点的高度
    private int lineInterval; //折线线段长度
    private float pointRadius; //折线点的半径
    private float textSize; //字体大小
    private float pointGap; //折线单位高度差
    private int defaultPadding; //折线坐标图四周留出来的偏移量
    private float iconWidth;  //天气图标的边长
    private int viewHeight;
    private int viewWidth;
    private int screenWidth;
    private int screenHeight;


    private List data = new ArrayList<>(); //元数据
    private List> weatherDatas = new ArrayList<>();  //对元数据中天气分组后的集合
    private List dashDatas = new ArrayList<>(); //不同天气之间虚线的x坐标集合
    private List points = new ArrayList<>(); //折线拐点坐标集合
    private Map icons = new HashMap<>(); //天气图标集合
    private int maxTemperature;//元数据中的最高和最低温度
    private int minTemperature;

上面大部分参数都是名词自解释的。为了更好理解,附上一张参数的图示:
自定义View之仿小米MIUI天气24小时预报折线图_第2张图片
上图中,元数据data的值和连续相同天气分组集合weatherData的值如下所示,其中weatherData的实体是 Pair,它就是个含有2个对象元素的容器,的第一个参数(pair.first)为连续相同天气的数量,第二参数(pair.second)为天气值:
自定义View之仿小米MIUI天气24小时预报折线图_第3张图片
自定义View之仿小米MIUI天气24小时预报折线图_第4张图片

初始化

由于自定义view的一些绘制功能限制,请关闭硬件加速!!

创建MiuiWeatherView继承View,实现1~3个参数的构造,在3参数构造函数里初始化数据。本控件里可以让用户自定义的值有很多,但是有些值设置不合理的话容易出现不同元素叠加等不好的效果,因此这里我只公开了3个属性供用户设置,即lineInterval、minPointHeight、backgroundColor。
构造方法代码如下:

 public MiuiWeatherView(Context context) {
        this(context, null);
    }

    public MiuiWeatherView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public MiuiWeatherView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);

        scroller = new Scroller(context);
        viewConfiguration = ViewConfiguration.get(context);

        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.MiuiWeatherView);
        minPointHeight = (int) ta.getDimension(R.styleable.MiuiWeatherView_min_point_height, dp2pxF(context, 60));
        lineInterval = (int) ta.getDimension(R.styleable.MiuiWeatherView_line_interval, dp2pxF(context, 60));
        backgroundColor = ta.getColor(R.styleable.MiuiWeatherView_background_color, Color.WHITE);
        ta.recycle();

        setBackgroundColor(backgroundColor);

        initSize(context);

        initPaint(context);

        initIcons();

    }

代码中看到,初始化了Scroller、ViewConfiguration,这是用来给后面实现Touch事件使用的,然后通过TypedArray 获取了用户在xml里自定义的属性。之后分别调用了 initSize(context);initPaint(context);initIcons();

先看initSize(context)

/**
     * 初始化默认数据
     */
    private void initSize(Context c) {
        screenWidth = getResources().getDisplayMetrics().widthPixels;
        screenHeight = getResources().getDisplayMetrics().heightPixels;

        minViewHeight = 3 * minPointHeight;  //默认3倍
        pointRadius = dp2pxF(c, 2.5f);
        textSize = sp2pxF(c, 10);
        defaultPadding = (int) (0.5 * minPointHeight);  //默认0.5倍
        iconWidth = (1.0f / 3.0f) * lineInterval; //默认1/3倍
    }

很简单,不用过多解释。接下来看 initPaint(context) :

private void initPaint(Context c) {
        linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        linePaint.setStrokeWidth(dp2px(c, 1));

        textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        textPaint.setTextSize(textSize);
        textPaint.setColor(Color.BLACK);
        textPaint.setTextAlign(Paint.Align.CENTER);

        circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        circlePaint.setStrokeWidth(dp2pxF(c, 1));
    }

更简单,不过注意一下用于画文字的画笔加了这么一句:textPaint.setTextAlign(Paint.Align.CENTER); 这样即能实现文字的水平居中,到时候绘制文字时传入文字中心x的坐标即可。(而不需要向左偏移半个文本长度)
接下来看initIcons();

    /**
     * 初始化天气图标集合
     */
    private void initIcons() {
        icons.clear();
        String[] weathers = WeatherBean.getAllWeathers();
        for (int i = 0; i < weathers.length; i++) {
            Bitmap bmp = getWeatherIcon(weathers[i], iconWidth, iconWidth);
            icons.put(weathers[i], bmp);
        }
    }

    /**
     * 根据天气获取对应的图标,并且缩放到指定大小
     * @param weather
     * @param requestW
     * @param requestH
     * @return
     */
    private Bitmap getWeatherIcon(String weather, float requestW, float requestH) {
        int resId = getIconResId(weather);
        Bitmap bmp;
        int outWdith, outHeight;
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeResource(getResources(), resId,options);
        outWdith = options.outWidth;
        outHeight = options.outHeight;
        options.inSampleSize = 1;
        if (outWdith > requestW || outHeight > requestH) {
            int ratioW = Math.round(outWdith / requestW);
            int ratioH = Math.round(outHeight / requestH);
            options.inSampleSize = Math.max(ratioW, ratioH);
        }
        options.inJustDecodeBounds = false;
        bmp = BitmapFactory.decodeResource(getResources(), resId,options);
        return bmp;
    }

    private int getIconResId(String weather) {
        int resId;
        switch (weather) {
            case WeatherBean.SUN:
                resId = R.drawable.sun;
                break;
            case WeatherBean.CLOUDY:
                resId = R.drawable.cloudy;
                break;
            case WeatherBean.RAIN:
                resId = R.drawable.rain;
                break;
            case WeatherBean.SNOW:
                resId = R.drawable.snow;
                break;
            case WeatherBean.SUN_CLOUD:
                resId = R.drawable.sun_cloud;
                break;
            case WeatherBean.THUNDER:
            default:
                resId = R.drawable.thunder;
                break;
        }
        return resId;
    }

这一步是解析我们之后要显示的天气图标,并把它们放到集合icons里(不要到了绘制时再去解析图标,耗时)。由WeatherBean的定义可知,本控件有6种天气。在 getWeatherIcon里,我们从资源文件里解析出了图标,必要时通过改变
options.inSampleSize来进行图片压缩,这块知识相信大家初学的时候就知道了,若有遗忘的,可参考郭神对官方文档的译文:
Android高效加载大图、多图解决方案,有效避免程序OOM

构造方法之后,我们这时这个View还没有数据呢,所以接下来为该控件编写唯一外部公开的方法:setData(List data)

/**
     * 唯一公开方法,用于设置元数据
     *
     * @param data
     */
    public void setData(List data) {
        if (data == null || data.isEmpty()) {
            return;
        }
        this.data = data;
        weatherDatas.clear();
        points.clear();
        dashDatas.clear();

        initWeatherMap(); //初始化相邻的相同天气分组
        requestLayout();
        invalidate();
    }

该方法接收数据源,并clear了我们绘制所需用到的集合,之后调用了initWeatherMap(); 它的作用是对连续相同天气进行分组,配合文章开头定义参数时给出的例图表格来看代码,应该会比较好理解,代码如下:

/**
     * 根据元数据中连续相同的天气数做分组,
     * pair中的first值为连续相同天气的数量,second值为对应天气
     */
    private void initWeatherMap() {
        weatherDatas.clear();
        String lastWeather = "";
        int count = 0;
        for (int i = 0; i < data.size(); i++) {
            WeatherBean bean = data.get(i);
            if (i == 0) {
                lastWeather = bean.weather;
            }
            if (bean.weather != lastWeather) {
                Pair<Integer, String> pair = new Pair<>(count, lastWeather);
                weatherDatas.add(pair);
                count = 1;
            } else {
                count++;
            }
            lastWeather = bean.weather;

            if (i == data.size() - 1) {
                Pair<Integer, String> pair = new Pair<>(count, lastWeather);
                weatherDatas.add(pair);
            }
        }
    }

重写onMeasure

先贴代码:

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);

        if (heightMode == MeasureSpec.EXACTLY) {
            viewHeight = Math.max(heightSize, minViewHeight);
        } else {
            viewHeight = minViewHeight;
        }

        int totalWidth = 0;
        if (data.size() > 1) {
            totalWidth = 2 * defaultPadding + lineInterval * (data.size() - 1);
        }
        viewWidth = Math.max(screenWidth, totalWidth);  //默认控件最小宽度为屏幕宽度

        setMeasuredDimension(viewWidth, viewHeight);
        calculatePontGap();
        Log.d("ccy", "viewHeight = " + viewHeight + ";viewWidth = " + viewWidth);
    }

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

顺便给出了onSizeChanged方法。通过上面代码可以看出,本控件的高度是用户可控的,不过限制了最小高度。而宽度时根据数据数量来决定的,并且最小宽度为1个屏幕宽。在setMeasuredDimension 之后,大部分长度参数确定好了,接着我们调用了 calculatePontGap(); 来计算折线的单位高度差:

/**
     * 计算折线单位高度差
     */
    private void calculatePontGap() {
        int lastMaxTem = -100000;
        int lastMinTem = 100000;
        for (WeatherBean bean : data) {
            if (bean.temperature > lastMaxTem) {
                maxTemperature = bean.temperature;
                lastMaxTem = bean.temperature;
            }
            if (bean.temperature < lastMinTem) {
                minTemperature = bean.temperature;
                lastMinTem = bean.temperature;
            }
        }
        float gap = (maxTemperature - minTemperature) * 1.0f;
        gap = (gap == 0.0f ? 1.0f : gap);  //保证分母不为0
        pointGap = (viewHeight - minPointHeight - 2 * defaultPadding) / gap;
    }

这个方法中,找出了元数据里最高温度和最低温度,相减,然后将折线显示范围除以差值,即可得到单位温度的高度差,注意差值可能为0(即传入的所有数据温度都相同)。

重写onDraw

终于到了我们自定义view的核心方法了,上面这么多的数据的初始工作,都是为了能让该方法更流畅更简单。不多说,onDraw代码如下:

 @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if (data.isEmpty()) {
            return;
        }
        drawAxis(canvas);

        drawLinesAndPoints(canvas);

        drawTemperature(canvas);

        drawWeatherDash(canvas);

        drawWeatherIcon(canvas);

    }

五大步骤!随我一步一步走。

1、 drawAxis(canvas);

/**
     * 画时间轴
     *
     * @param canvas
     */
    private void drawAxis(Canvas canvas) {
        canvas.save();
        linePaint.setColor(DEFAULT_GRAY);
        linePaint.setStrokeWidth(dp2px(getContext(), 1));

        canvas.drawLine(defaultPadding,
                viewHeight - defaultPadding,
                viewWidth - defaultPadding,
                viewHeight - defaultPadding,
                linePaint);

        float centerY = viewHeight - defaultPadding + dp2pxF(getContext(), 15);
        float centerX;
        for (int i = 0; i < data.size(); i++) {
            String text = data.get(i).time;
            centerX = defaultPadding + i * lineInterval;
            Paint.FontMetrics m = textPaint.getFontMetrics();
            canvas.drawText(text, 0, text.length(), centerX, centerY - (m.ascent + m.descent) / 2, textPaint);
        }
        canvas.restore();
    }

首先,把画笔设为灰色(DEFAULT_GRAY),画了一条长长的线。然后遍历数据集data,获取里面的time值作为要绘制的文字,将他们以lineInterval为间距绘制出来,这里绘制时,由于之前textPaint已经调用了textPaint.setTextAlign(Paint.Align.CENTER); ,所以直接传入centerX作为x坐标,而y坐标传入的值为 centerY - (m.ascent + m.descent) / 2 ,这样能实现文字以centerY为垂直中心,其中m为Paint.FontMetrics对象,它根据当前画笔设置的文字大小封装了绘制文字时的各种参考线和基线,关于Paint.FontMetrics ,若有不了解的可自行百度。
好,看下效果图:
自定义View之仿小米MIUI天气24小时预报折线图_第5张图片
第一关,表示很轻松。


2、drawLinesAndPoints(canvas);

/**
     * 画折线和它拐点的园
     *
     * @param canvas
     */
    private void drawLinesAndPoints(Canvas canvas) {
        canvas.save();
        linePaint.setColor(DEFAULT_BULE);
        linePaint.setStrokeWidth(dp2pxF(getContext(), 1));
        linePaint.setStyle(Paint.Style.STROKE);

        Path linePath = new Path(); //用于绘制折线
        points.clear();
        int baseHeight = defaultPadding + minPointHeight;
        float centerX;
        float centerY;
        for (int i = 0; i < data.size(); i++) {
            int tem = data.get(i).temperature;
            tem = tem - minTemperature;
            centerY = (int) (viewHeight - (baseHeight + tem * pointGap));
            centerX = defaultPadding + i * lineInterval;
            points.add(new PointF(centerX, centerY));
            if (i == 0) {
                linePath.moveTo(centerX, centerY);
            } else {
                linePath.lineTo(centerX, centerY);
            }
        }
        canvas.drawPath(linePath, linePaint); //画出折线

        //接下来画折线拐点的园
        float x, y;
        for (int i = 0; i < points.size(); i++) {
            x = points.get(i).x;
            y = points.get(i).y;

            //先画一个颜色为背景颜色的实心园覆盖掉折线拐角
            circlePaint.setStyle(Paint.Style.FILL_AND_STROKE);
            circlePaint.setColor(backgroundColor);
            canvas.drawCircle(x, y,
                    pointRadius + dp2pxF(getContext(), 1),
                    circlePaint);
            //再画出正常的空心园
            circlePaint.setStyle(Paint.Style.STROKE);
            circlePaint.setColor(DEFAULT_BULE);
            canvas.drawCircle(x, y,
                    pointRadius,
                    circlePaint);
        }
        canvas.restore();
    }

代码中,首先是绘制折线,折线我们通过一个Path来完成,path每次lineTo的x坐标比较好算,就是每次向右移动一个间距lineInterval。y坐标呢,我们要从data里获取对应的温度,然后减去最低温度,乘上单位高度差,即可算出y坐标,比如我们这里最低温度为20,这时取出的温度若为22,那么他的高度就是 基础高+(22-20)*单位高度差。注意每次计算出了拐角的坐标后我们把它存入了points集合里,后面有用。
遍历结束后就可以把折线画出来了:canvas.drawPath(linePath, linePaint);
之后我们通过points取出拐点集合,在每个拐点先后画了一个半径稍大一点的实心圆和正常半径的空心圆。这步之后效果图如下:
自定义View之仿小米MIUI天气24小时预报折线图_第6张图片

先画一个颜色与背景色相同的实心圆的作用是覆盖掉拐角的线,如果你没画,效果图是这样的:
自定义View之仿小米MIUI天气24小时预报折线图_第7张图片

第二关,猥琐发育,别浪

3、drawTemperature(canvas);

/**
     * 画温度描述值
     *
     * @param canvas
     */
    private void drawTemperature(Canvas canvas) {
        canvas.save();

        textPaint.setTextSize(1.2f * textSize); //字体放大一丢丢
        float centerX;
        float centerY;
        String text;
        for (int i = 0; i < points.size(); i++) {
            text = data.get(i).temperatureStr;
            centerX = points.get(i).x;
            centerY = points.get(i).y - dp2pxF(getContext(), 15);
            Paint.FontMetrics metrics = textPaint.getFontMetrics();
            canvas.drawText(text,
                    centerX,
                    centerY - (metrics.ascent + metrics.descent/2,
                    textPaint);
        }
        textPaint.setTextSize(textSize);
        canvas.restore();
    }

这步很简单,有了上一步记录好的坐标点集合points,我们很容易确定出文字的绘制位置,效果图如下:
自定义View之仿小米MIUI天气24小时预报折线图_第8张图片

第三关,对面开始送了。

4、drawWeatherDash(canvas);

/**
     * 画不同天气之间的虚线
     *
     * @param canvas
     */
    private void drawWeatherDash(Canvas canvas) {
        canvas.save();
        linePaint.setColor(DEFAULT_GRAY);
        linePaint.setStrokeWidth(dp2pxF(getContext(), 0.5f));
        linePaint.setAlpha(0xcc);

        //设置画笔画出虚线
        float[] f = {dp2pxF(getContext(), 5), dp2pxF(getContext(), 1)};  //两个值分别为循环的实线长度、空白长度
        PathEffect pathEffect = new DashPathEffect(f, 0);
        linePaint.setPathEffect(pathEffect);

        dashDatas.clear();
        int interval = 0;
        float startX, startY, endX, endY;
        endY = viewHeight - defaultPadding;

        //0坐标点的虚线手动画上
        canvas.drawLine(defaultPadding,
                points.get(0).y + pointRadius + dp2pxF(getContext(), 2),
                defaultPadding,
                endY,
                linePaint);
        dashDatas.add((float) defaultPadding);

        for (int i = 0; i < weatherDatas.size(); i++) {
            interval += weatherDatas.get(i).first;
            if(interval > points.size()-1){
                interval = points.size()-1;
            }
            startX = endX = defaultPadding + interval * lineInterval;
            startY = points.get(interval).y + pointRadius + dp2pxF(getContext(), 2);
            dashDatas.add(startX);
            canvas.drawLine(startX, startY, endX, endY, linePaint);
        }

        //这里注意一下,当最后一组的连续天气数为1时,是不需要计入虚线集合的,否则会多画一个天气图标
        //若不理解,可尝试去掉下面这块代码并观察运行效果
        if(weatherDatas.get(weatherDatas.size()-1).first == 1
                && dashDatas.size() > 1){
            dashDatas.remove(dashDatas.get(dashDatas.size()-1));
        }

        linePaint.setPathEffect(null);
        linePaint.setAlpha(0xff);
        canvas.restore();
    }

让画笔画出虚线的方法是通过给画笔设置DashPathEffect,它的构造方法接收两个参数,第一个参数是一个最小长度为2的float数组,表示循环着画一定长度的线,再空一定长度的线;第二个参数是偏移量。比如上述代码中,效果就是循环的画5dp的线,再空1dp,折页就达到了虚线效果。
DashPathEffect属于PathEffect的一种,该类能影响画笔路径的效果,不同的PathEffect有不同的效果,若不了解可自行百度。
接下来就是根据我们早早就初始化好的weatherDatas和points这两个集合,计算出虚线绘制的位置,然后绘制之啦~不过也是有要注意的地方的,代码中已经做注释了,这里不费口舌了。
效果图如下:
自定义View之仿小米MIUI天气24小时预报折线图_第9张图片
第四关,稳住,我们能赢

5、drawWeatherIcon(canvas);

这一步也是绘制的最后一步,我们观察本控件的效果图,发现天气图标的位置是随着控件的滑动而动态计算的(当然目前我们的控件还不能滑动)。可见 ,一个天气图标,要居于左右虚线的中心,但随着控件的滑动,左虚线或右虚线可能会被移到屏幕之外,比如做虚线移到了屏幕外,那么图标就居于屏幕左边沿和右虚线的中心,反之亦然。另外还有几种情况,代码内都注释好了,配合开头的动图应该好理解的
代码如下:

/**
     * 画天气图标和它下方文字
     * 若相邻虚线都在屏幕内,图标的x位置即在两虚线的中间
     * 若有一条虚线在屏幕外,图标的x位置即在屏幕边沿到另一条虚线的中间
     * 若两条都在屏幕外,图标x位置紧贴某一条虚线或屏幕中间
     *
     * @param canvas
     */
    private void drawWeatherIcon(Canvas canvas) {
        canvas.save();
        textPaint.setTextSize(0.9f * textSize); //字体缩小一丢丢

        boolean leftUsedScreenLeft = false;
        boolean rightUsedScreenRight = false;

        int scrollX = getScrollX();  //范围控制在0 ~ viewWidth-screenWidth
        float left, right;
        float iconX, iconY;
        float textY;     //文字的x坐标跟图标是一样的,无需额外声明
        iconY = viewHeight - (defaultPadding + minPointHeight / 2.0f);
        textY = iconY + iconWidth / 2.0f + dp2pxF(getContext(), 10);
        Paint.FontMetrics metrics = textPaint.getFontMetrics();
        for (int i = 0; i < dashDatas.size() - 1; i++) {
            left = dashDatas.get(i);
            right = dashDatas.get(i + 1);

            //以下校正的情况为:两条虚线都在屏幕内或只有一条在屏幕内

            if (left < scrollX &&    //仅左虚线在屏幕外
                    right < scrollX + screenWidth) {
                left = scrollX;
                leftUsedScreenLeft = true;
            }
            if (right > scrollX + screenWidth &&  //仅右虚线在屏幕外
                    left > scrollX) {
                right = scrollX + screenWidth;
                rightUsedScreenRight = true;
            }

            if (right - left > iconWidth) {    //经过上述校正之后左右距离还大于图标宽度
                iconX = left + (right - left) / 2.0f;
            } else {                          //经过上述校正之后左右距离小于图标宽度,则贴着在屏幕内的虚线
                if (leftUsedScreenLeft) {
                    iconX = right - iconWidth / 2.0f;
                } else {
                    iconX = left + iconWidth / 2.0f;
                }
            }

            //以下校正的情况为:两条虚线都在屏幕之外

            if (right < scrollX) {  //两条都在屏幕左侧,图标紧贴右虚线
                iconX = right - iconWidth / 2.0f;
            } else if (left > scrollX + screenWidth) {   //两条都在屏幕右侧,图标紧贴左虚线
                iconX = left + iconWidth / 2.0f;
            } else if (left < scrollX && right > scrollX + screenWidth) {  //一条在屏幕左一条在屏幕右,图标居中
                iconX = scrollX + (screenWidth / 2.0f);
            }


            Bitmap icon = icons.get(weatherDatas.get(i).second);

            //经过上述校正之后可以得到图标和文字的绘制区域
            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.drawText(weatherDatas.get(i).second,
                    iconX,
                    textY - (metrics.ascent+metrics.descent)/2,
                    textPaint); //画图标下方文字

            leftUsedScreenLeft = rightUsedScreenRight = false; //重置标志位
        }

        textPaint.setTextSize(textSize);
        canvas.restore();
    }

效果图如下:
自定义View之仿小米MIUI天气24小时预报折线图_第10张图片
第5关,上高地了

实现触摸滑动、抛动(fling)事件

接下来我们要实现控件的滑动效果。想要实现,有个方法很简单。。。直接给它套上一层父布局HorizontalScrollView就能实现了滑动和抛动了~然后重写HorizontalScrollView的onScrollChanged方法,将它滑动的相关参数(scrollX)传递给子view(即本控件),然后本控件把参数拿来简单给第五步设置一下再invalidate一下就好了,好了,了。
不过我们是为了练习自己能力才写这个控件的,当然不用上面的方式。故本控件使用Scroller + VelocityTracker来实现滑动和抛动的效果。
Scroller是滑动的相关类,大家应该都学过,也可参考郭神的:Android Scroller完全解析,关于Scroller你所需知道的一切
VelocityTracker是用于计算手指在屏幕上滑动速度的,在控件监听手势事件的时候,通过velocityTracker.addMovement(event);方法把事件传给VelocityTracker,之后在合适的时候我们就可以计算并获取到滑动速度,我们可以设定当滑动速度大于一定值时,就认为是抛动,然后我们借助Scroll.fling()方法即可实现抛动效果。
贴上代码:

    private float lastX = 0;
    private float x = 0;

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (velocityTracker == null) {
            velocityTracker = VelocityTracker.obtain();
        }
        velocityTracker.addMovement(event);
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (!scroller.isFinished()) {  //fling还没结束
                    scroller.abortAnimation();
                }
                lastX = x = event.getX();
                return true;
            case MotionEvent.ACTION_MOVE:
                x = event.getX();
                int deltaX = (int) (lastX - x);
                if (getScrollX() + deltaX < 0) {    //越界恢复
                    scrollTo(0, 0);
                    return true;
                } else if (getScrollX() + deltaX > viewWidth - screenWidth) {
                    scrollTo(viewWidth - screenWidth, 0);
                    return true;
                }
                scrollBy(deltaX, 0);
                lastX = x;
                break;
            case MotionEvent.ACTION_UP:
                x = event.getX();
                velocityTracker.computeCurrentVelocity(1000);  //计算1秒内滑动过多少像素
                int xVelocity = (int) velocityTracker.getXVelocity();
                if (Math.abs(xVelocity) > viewConfiguration.getScaledMinimumFlingVelocity()) {  //滑动速度可被判定为抛动
                    scroller.fling(getScrollX(), 0, -xVelocity, 0, 0, viewWidth - screenWidth, 0, 0);
                    invalidate();
                }
                break;
        }
        return super.onTouchEvent(event);
    }

    @Override
    public void computeScroll() {
        super.computeScroll();
        if (scroller.computeScrollOffset()) {
            scrollTo(scroller.getCurrX(), scroller.getCurrY());
            invalidate();
        }
    }

从代码中可以看到:
关于滑动,在每次MOVE事件触发的时候,计算位移差int deltaX = (int) (lastX - x) ,然后通过scrollBy(deltaX, 0) 即可实现滑动。这里要注意的是scrollBy/scrollTo接受的值的正负关系,细心的朋友能发现deltaX其实是位移差的负值。

徐医生的《Android群英传》里有个比喻说的很形象(p95):把手机屏幕当成一个中空的盖板,盖板下面是一个巨大的画布,也就是我们想要显示的视图。当把这个盖板盖在画布上的某一处时,透过中间空的矩形,我们看见了手机屏幕上显示的视图,而画布上其他地方的视图,则被盖板盖住了无法看见。当调用scorllBy方法时,可以想象为外面的盖板在移动。

关于抛动,首先通过VelocityTracker.obtain() 获取一个VelocityTracker对象,然后通过 velocityTracker.addMovement(event); 将事件传给它,在手指抬起的时候,通过 velocityTracker.computeCurrentVelocity(1000); 计算1秒内手指划过了多少像素,这里时间单位用1秒是因为 scroller.fling()方法里要求的速度参数的时间单位也为1秒。之后,我们用viewConfiguration.getScaledMinimumFlingVelocity() 获取了系统认为的最小抛动速度,并将获取的速度与之比较,若大于最小速度,则调用scroller.fling(getScrollX(), 0, -xVelocity, 0, 0, viewWidth - screenWidth, 0, 0); 来触发抛动效果。该方法前2个参数为x、y方向的起点,之后2个参数为x、y方向的速度,之后2个参数为x方向最小和最大位移,最后2个参数为y方向最小最大位移。最后不能忘记调用 invalidate() 和重写 computeScroll()

通过viewConfiguration可以获取各种系统认定的标准值,常用的有比如通过viewConfiguration.getScaledTouchSlop() 可以获取系统认为的最小滑动距离等。



恭喜。本控件的分析已经结束了,有问题可评论留言,本控件源码地址:
https://github.com/CCY0122/miuiweatherline

排坑记

关于关闭硬件加速

自定义view往往要关闭硬件加速,不然一些api的效果无法显示。一般关闭硬件加速的方法是在manifest里加入这么一句 android:hardwareAccelerated="false"
这句放在 < application />节点下表示关闭整个项目的硬件加速,放在 < activity />下表示关闭该组件硬件加速。网上还有人说关闭view级别的硬件加速方法是这样的:setLayerType(LAYER_TYPE_SOFTWARE,null); ,我一开始用了这个方式,但是我发现当我传入的数据量较大时(data.size() >= 22) ,onDraw直接不执行了,在打印里可以看到这么一句,大概是说绘制的软件层内存不够用:
这里写图片描述
(MiuiWeatherView not displayed because it is too large to fit into a software layer(or drawing cache) ,needs 8553600 bytes, only 8294400 available)

因此,谨慎使用setLayerType(LAYER_TYPE_SOFTWARE,null); 来关闭硬件加速

关于图片压缩

我们知道解析bitmap时,当options.inJustDecodeBounds为true时只解析图片大小参数,此时可以通过改变inSampleSize来缩放图片分辨率。然后再置回false去解析图片,若没改变inSampleSize的值,图片大小理应不变。
但是,我们看下面这个代码的打印:

        int resId = R.drawable.sun;
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeResource(getResources(), resId,options);
        Log.d("ccy","inJustDecodeBounds = true, width = " + options.outWidth+"; height = " + options.outHeight);
        options.inJustDecodeBounds = false;
        Bitmap bmp = BitmapFactory.decodeResource(getResources(), resId,options);
        Log.d("ccy","inJustDecodeBounds = false, width = " + options.outWidth+"; height = " + options.outHeight);
        Log.d("ccy","inJustDecodeBounds = false, bmp width = " + bmp.getWidth()+"; bmp height = " + bmp.getHeight());

本人设备屏幕密度对应xxhdpi。当图片放在drawable-xxhdpi时,打印如下:
这里写图片描述
当放在drawable-xhdpi时打印如下:
这里写图片描述
当放在drawable时打印如下:
这里写图片描述

好了结论就是当inJustDecodeBounds为true时,解析出的图片大小即原图大小,当inJustDecodeBounds为false时,解析出的图片大小除了受inSampleSize影响以外,还受当前设备密度和图片在哪个文件夹有关。因此,我在绘制天气图片时,选择了canvas.drawBitmap这么一个重载:drawBitmap(Bitmap bmp,Rect src,Rect dst,Paint paint) 它会在必要时将图片进行缩放、旋转以达到让图片限制在dst这个Rect参数里。

关于不同后缀的drawable所对应的设备密度,大家随便拿本手头的安卓入门书上应该都有列出了表格,简单讲ldpi : mdpi : hdpi : xhdpi : xxhdpi = 3:4:6:8:12

关于优化

本控件完全可以脱离“天气”这个概念,显示文本都是自定义的,完全可以当成其他用途的折线图,而且本控件天气图标只有6种,而且我们图标是直接在view里面解析的,这并不好,大家可以创建自己的Bean,对代码稍作修改,将本控件改造成可以随意传文本值、随意传各种图标。
最后,BB了这么多,谢谢阅读
欢迎点赞点star点fork~~以润色本人简历,谢谢

你可能感兴趣的:(Android自定义View)