1. 程式人生 > >自定義View之仿小米MIUI天氣24小時預報折線圖

自定義View之仿小米MIUI天氣24小時預報折線圖

本篇文章已授權微信公眾號 hongyangAndroid(鴻洋)獨家釋出。

效果圖

img1
img2

本控制元件是仿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);

原理

原始碼地址

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<WeatherBean> data = new ArrayList<>(); //元資料
    private List<Pair<Integer, String>> weatherDatas = new ArrayList<>();  //對元資料中天氣分組後的集合
    private List<Float> dashDatas = new ArrayList<>(); //不同天氣之間虛線的x座標集合
    private List<PointF> points = new ArrayList<>(); //折線拐點座標集合
    private Map<String, Bitmap> icons = new HashMap<>(); //天氣圖示集合
    private int maxTemperature;//元資料中的最高和最低溫度
    private int minTemperature;

上面大部分引數都是名詞自解釋的。為了更好理解,附上一張引數的圖示:
img3
上圖中,元資料data的值和連續相同天氣分組集合weatherData的值如下所示,其中weatherData的實體是 Pair,它就是個含有2個物件元素的容器,的第一個引數(pair.first)為連續相同天氣的數量,第二引數(pair.second)為天氣值:
table1
這裡寫圖片描述

初始化

由於自定義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<WeatherBean> data)

/**
     * 唯一公開方法,用於設定元資料
     *
     * @param data
     */
    public void setData(List<WeatherBean> 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 ,若有不瞭解的可自行百度。
好,看下效果圖:
這裡寫圖片描述
第一關,表示很輕鬆。


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取出拐點集合,在每個拐點先後畫了一個半徑稍大一點的實心圓和正常半徑的空心圓。這步之後效果圖如下:
這裡寫圖片描述

先畫一個顏色與背景色相同的實心圓的作用是覆蓋掉拐角的線,如果你沒畫,效果圖是這樣的:
這裡寫圖片描述

第二關,猥瑣發育,別浪

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,我們很容易確定出文字的繪製位置,效果圖如下:
這裡寫圖片描述

第三關,對面開始送了。

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這兩個集合,計算出虛線繪製的位置,然後繪製之啦~不過也是有要注意的地方的,程式碼中已經做註釋了,這裡不費口舌了。
效果圖如下:
這裡寫圖片描述
第四關,穩住,我們能贏

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();
    }

效果圖如下:
這裡寫圖片描述
第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() 可以獲取系統認為的最小滑動距離等。

排坑記

關於關閉硬體加速

自定義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~~以潤色本人簡歷,謝謝