1. 程式人生 > >談談-Android-PickerView系列之源碼解析(二)

談談-Android-PickerView系列之源碼解析(二)

需求 動態 () comm tag 多個 來源 ntc 寬高

前言

  WheelView想必大家或多或少都有一定了解, 它是一款3D滾輪控件,效果類似IOS 上面的UIpickerview 。按照國際慣例,先放一張效果圖:

技術分享

  以上是Android-PickerView 的demo演示圖,它有時間選擇和選項選擇,並支持一二三級聯動,支持自定義樣式。
本篇文章的主要內容是講解WheelView的實現原理以及源代碼,大致分以下幾個步驟:

一、實現原理
二、自定義控件
三、onMeasure 測量
四、onDraw 繪制
五、onTouchEvent監聽

一、實現原理

  上面我們看到的GIF圖中,控件中間滾輪部分的布局,有多個WheelView, 一個WheelView 就是一個3D滾輪,我畫了一張圖方便大家更為直觀地理解:

技術分享

  從上圖中我們可以看到,每一項Item都是在圓弧上面, 假設我們設置的WheelView它的可見Item數目為11,那麽圓的半個周長就等於 10項Item的高度。我們看到的第一象限和第四象限,它是可見區域,即Item所顯示的位置。其中,每項Item的高度 ItemHeight 等於兩條分隔線的高度,具體如下圖所示:

技術分享
(為什麽要畫得那麽詳細,因為這些參數在繪制過程中需要用到)

因此,我們可得以下結論:

1.每項Item 的高度是由文字大小以及間距倍數控制的, itemHeight = lineSpacingMultiplier * maxTextHeight;
2.圓周長 C = 2 (itemHeight *(itemsVisible - 1))


2.根據圓周長公式 C= 2πR, 可推導出圓半徑R = C/2π ,圓直徑 L = C/π;

二、自定義控件

  1. 創建一個WheelView 類繼承自 View,覆蓋onDraw、onMeasure、onTouchEvent方法.
  2. 在構造方法中初始化數據;
  3. 在構造方法中初始化三個畫筆Paint,分別用於繪制選中項、未選中項、分隔線。
 private void initPaints() {
        paintOuterText = new Paint();
        paintOuterText.setColor(textColorOut);
        paintOuterText.setAntiAlias(true);
        paintOuterText.setTypeface(Typeface.MONOSPACE);
        paintOuterText.setTextSize(textSize);

        paintCenterText = new Paint();
        paintCenterText.setColor(textColorCenter);
        paintCenterText.setAntiAlias(true);
        paintCenterText.setTextScaleX(1.1F);
        paintCenterText.setTypeface(Typeface.MONOSPACE);
        paintCenterText.setTextSize(textSize);


        paintIndicator = new Paint();
        paintIndicator.setColor(dividerColor);
        paintIndicator.setAntiAlias(true);

        if (android.os.Build.VERSION.SDK_INT >= 11) {
            setLayerType(LAYER_TYPE_SOFTWARE, null);
        }
    }

三、onMeasure 測量

1.計算最大length的Text的寬高度

 private void measureTextWidthHeight() {
        Rect rect = new Rect();
        for (int i = 0; i < adapter.getItemsCount(); i++) {
            String s1 = getContentText(adapter.getItem(i));
            paintCenterText.getTextBounds(s1, 0, s1.length(), rect);
            int textWidth = rect.width();
            if (textWidth > maxTextWidth) {
                maxTextWidth = textWidth;
            }
            paintCenterText.getTextBounds("\u661F\u671F", 0, 2, rect); // "星期"的字符編碼,用它作為標準高度
            int textHeight = rect.height();
            if (textHeight > maxTextHeight) {
                maxTextHeight = textHeight;
            }
        }
        itemHeight = lineSpacingMultiplier * maxTextHeight;//item的高度
    }

2.計算圓的半徑和直徑,求出WheelView控件的寬高度


        //周長公式 C= 2πR
        //半圓的周長 = item高度乘以item數目-1
        halfCircumference = (int) (itemHeight * (itemsVisible - 1));
        //整個圓的周長除以PI得到直徑,這個直徑用作控件的總高度
        measuredHeight = (int) ((halfCircumference * 2) / Math.PI);
        //求出半徑
        radius = (int) (halfCircumference / Math.PI);
        //計算控件寬度,這裏支持weight
        measuredWidth = MeasureSpec.getSize(widthMeasureSpec);

3.計算兩條分隔線和Label文字的基線位置

        //計算兩條橫線 和 選中項Label的基線centerY 位置
        firstLineY = (measuredHeight - itemHeight) / 2.0F;
        secondLineY = (measuredHeight + itemHeight) / 2.0F;
        centerY = secondLineY - (itemHeight-maxTextHeight)/2.0f - CENTERCONTENTOFFSET;

對於centerY 為什麽要減去CENTERCONTENTOFFSET(偏移量),因為Canvas.drawText方法中的坐標參數Y,並不是文字的底部位置,而是基線位置,所以我們要微調一下位置,讓顯示居中:

技術分享

技術分享
註:圖片來源於http://blog.csdn.net/zly921112/article/details/50401976

4.初始化默認顯示的item的position,即選中項位置

if (initPosition == -1) {
            if (isLoop) {
                initPosition = (adapter.getItemsCount() + 1) / 2;
            } else {
                initPosition = 0;
            }
        }
        preCurrentIndex = initPosition;

四、onDraw 繪制

  經過以上幾個步驟之後,我們繪制控件所需要的各個屬性值也基本上計算好了,接下來就開始在onDraw方法中進行繪制

1.繪制兩條橫線

 //繪制中間兩條橫線
 canvas.drawLine(0.0F, firstLineY, measuredWidth, firstLineY, paintIndicator);
 canvas.drawLine(0.0F, secondLineY, measuredWidth, secondLineY, paintIndicator);

2.繪制Label文字

        //顯示單位Label,label不為空則進行繪制
        if (label != null&& !label.equals("")) {
            int drawRightContentStart = measuredWidth - getTextWidth(paintCenterText, label);
            //繪制文字,靠右並留出空隙
            canvas.drawText(label, drawRightContentStart - CENTERCONTENTOFFSET, centerY, paintCenterText);
        }

3.繪制item內容文字

  終於到最核心的地方了——繪制有3D滾輪效果的Item文字。繪制它的兩個關鍵因素:

  • item平移距離translateY。
  • 文字Y軸的縮放率 scaleY。

在繪制之前,我們需要溫習一下 弧和圓、弧度、以及三角函數等概念以及它們的計算公式,對於公式有不了解的地方可以自行google 百度一下,這裏就不多闡述了:

  • 弧度和角度的換算公式 α = n*π/180 (α為弧度,n為角度)
  • 弧度的計算公式 α = L / R ( 弧長/半徑 )
  • 正弦和余弦轉換公式 cosα = sin( π/2-α )

      由於我們在onDraw方法裏面,translateY和scaleY都是是通過弧度 α 計算得到的,因此需要從弧度α開始入手
      
      由計算公式 α = L / R 可知,我們若要得到某項Item的弧度,則需要知道弧長和半徑,由於之前我們已經通過計算獲取到了半徑值,所以現在需要計算弧長。
      
      弧長L = itemHeight * counter - itemHeightOffset; 即 Item的高度乘以該項Item所在Position位置,再減去已滑動距離的偏移量(itemHeightOffset < itemHeight ),計算出了弧長L,則可計算出每項Item對應的弧度α,計算出了α之後,根據三角函數可計算出平移距離translateY,如下圖 :

技術分享

  • radius (半徑)
  • h2=cosα * maxTextHeight/2
  • h1=sinα * radius

由上圖可知,item從位置F3E3移動到 A2B2的時,平移距離 translateY = radius - h2 -h1;

求出了translateY 之後,我們還需要求出壓縮率scaleY:

技術分享

由上圖可知 scaleY = cosn,由於代碼裏面參數是弧度制代表的數值,所以我們用弧度制表達 scaleY = cosα;

計算好了,我們開始擼代碼,由於篇幅問題,就只貼出了部分關鍵代碼,如下:

 /*省略部分...*/

 counter = 0;//position位置
        while (counter < itemsVisible) {
            canvas.save();
            // 弧長 L = itemHeight * counter - itemHeightOffset
            // 求弧度 α = L / r  (弧長/半徑)
            double radian = ((itemHeight * counter - itemHeightOffset)) / radius;
            // 弧度轉換成角度(把半圓以Y軸為軸心向右轉90度,使其處於第一象限及第四象限
            float angle = (float) (90D - (radian / Math.PI) * 180D);//item第一項,從90度開始,逐漸遞減到 -90度
            // 九十度以上的不繪制
            if (angle >= 90F || angle <= -90F) {
                canvas.restore();
            } else {
                String contentText = getContentText(visibles[counter]);
                reMeasureTextSize(contentText);
                //計算開始繪制的位置
                measuredCenterContentStart(contentText);
                measuredOutContentStart(contentText);
                float translateY = (float) (radius - Math.cos(radian) * radius - (Math.sin(radian) * maxTextHeight) / 2D);
                //根據Math.sin(radian)來更改canvas坐標系原點,然後縮放畫布,使得文字高度進行縮放,形成弧形3d視覺差
                canvas.translate(0.0F, translateY);
                canvas.scale(1.0F, (float) Math.sin(radian));

“等等,大兄弟,剛剛不是說‘scaleY = cosα ’嗎”,怎麽縮放文字的代碼是這樣的:

canvas.scale(1.0F, (float) Math.sin(radian));

  別急別急,這是由於為了使Item的顯示位置處於第一象限及第四象限,我們把半圓以原點為中心,順時針旋轉了90度。所以我們的角度 angle = (float) (90D - (radian / Math.PI) * 180D);(使角度的取值範圍為[-90°, 90°])

而根據:
1.弧度和角度轉換公式 α = angle*π/180
2.正弦和余弦轉換公式 cosα = sin( π/2-α )

我們就把cos函數 給轉化成sin函數了。

4.文字自適應大小

  由於item的文字長度是不固定的,所以會存在文字長度過長而導致繪制超過Wheelview的寬度,因此這裏需要做一下處理,當文字長度超過measuredWidth的時候,重設字體大小讓其能完全顯示:

/**
     * 根據文字的長度 重新設置文字的大小 讓其能完全顯示
     * @param contentText
     */
    private void reMeasureTextSize(String contentText) {
        Rect rect = new Rect();
        paintCenterText.getTextBounds(contentText, 0, contentText.length(), rect);
        int width = rect.width();
        int size = textSize;
        while (width > measuredWidth) {
            size--;
            //設置2條橫線中間的文字大小
            paintCenterText.setTextSize(size);
            paintCenterText.getTextBounds(contentText, 0, contentText.length(), rect);
            width = rect.width();
        }
        //設置2條橫線外面的文字大小
        paintOuterText.setTextSize(size);
    }

五、onTouchEvent監聽

一次靜態圖形繪制完成了,但是我們需要根據滑動距離讓item文字動態地變換,以達到需求,那麽如何根據滑動距離來控制UI變化呢?

  別著急,之前已經說了定義WheelView 的時候需要覆蓋onTouchEvent方法,必然是有它的道理的。我們通過onTouchEvent方法,然後:

1.分別處理MotionEvent.ACTION_DOWN、ACTION_MOVE、ACTION_UP 這三個事件,獲取到滑動距離並記錄下來。
2.根據獲取的滑動距離、計算停止滑動時item所需要的偏移量 ,邊界處理等工作。
3.最後調用invalidate()方法通知系統更新UI,相當於重新調用了onDraw()方法,重新繪制UI,實現3D滾輪效果。

由於篇幅問題,就只貼一下部分關鍵代碼了:

@Override
    public boolean onTouchEvent(MotionEvent event) {
        boolean eventConsumed = gestureDetector.onTouchEvent(event);
        switch (event.getAction()) {
            //按下
            case MotionEvent.ACTION_DOWN:
                startTime = System.currentTimeMillis();
                cancelFuture();
                previousY = event.getRawY();
                break;
            //滑動中
            case MotionEvent.ACTION_MOVE:
                float dy = previousY - event.getRawY();
                previousY = event.getRawY();
                totalScrollY = (int) (totalScrollY + dy);

                // 邊界處理。
                if (!isLoop) {
                    float top = -initPosition * itemHeight;
                    float bottom = (adapter.getItemsCount() - 1 - initPosition) * itemHeight;
                    if (totalScrollY - itemHeight * 0.3 < top) {
                        top = totalScrollY - dy;
                    } else if (totalScrollY + itemHeight * 0.3 > bottom) {
                        bottom = totalScrollY - dy;
                    }

                    if (totalScrollY < top) {
                        totalScrollY = (int) top;
                    } else if (totalScrollY > bottom) {
                        totalScrollY = (int) bottom;
                    }
                }
                break;
            //完成滑動,手指離開屏幕
            case MotionEvent.ACTION_UP:
            default:
           // 弧長 L = α*R
       // 反余弦公式:arccos(cosα)= α
       // 由於之前是有向右偏移90度,所以 實際弧度範圍為
       // α2 =π/2-α (α=[0,π] α2 = [-π/2,π/2])
       // 根據正弦余弦轉換公式 cosα = sin(π/2-α)
       // 因此 cosα = sin(π/2-α) = sinα2 = (radius - y) / radius
       // 所以弧長 L = arccos(cosα)*R = arccos((radius - y) / radius)*R
                if (!eventConsumed) {
                    float y = event.getY();
                    double L = Math.acos((radius - y) / radius) * radius;
                    int circlePosition = (int) ((L + itemHeight / 2) / itemHeight);

                    float extraOffset = (totalScrollY % itemHeight + itemHeight) % itemHeight;
                    mOffset = (int) ((circlePosition - itemsVisible / 2) * itemHeight - extraOffset);

                    if ((System.currentTimeMillis() - startTime) > 120) {
                        // 處理拖拽事件
                        smoothScroll(ACTION.DAGGLE);
                    } else {
                        // 處理條目點擊事件
                        smoothScroll(ACTION.CLICK);
                    }
                }
                break;
        }
        invalidate();

        return true;
    }

結尾語

  以上是WheelView的實現原理以及繪制過程的講解,但是實際使用中我們往往需要多個Wheelview 並設置聯動來實現我們的功能,所以Android-PickerView,這個項目就是對其進行了很好的封裝,提供了兩種選擇器,一種是時間選擇器(timePicker),一種是選擇選擇器(optionPicker)。
 

完整項目碼請到Github下載,這裏是地址鏈接:Android-PickerView。

文章轉載自:http://blog.csdn.net/qq_22393017/article/details/59488906

談談-Android-PickerView系列之源碼解析(二)