天天看點

android自定義滾動選擇器(二)ScrollPickerView的實作

在android自定義滾動選擇器(一)這篇文章中,我們已經闡述了滾動選擇器的實作原理以及準備事項,本篇文章将會從代碼的角度一步步來實作該滾動選擇器。

如果來不及閱讀文章,或者想直接擷取源碼,見git:android自定義滾動選擇器

ScrollPickerView的實作

ScrollPickerView這個是我們的主視圖,說白了就是我們的滾動選擇器,本小節先來闡述下其代碼實作。

首先,我們要将ScrollPickerView用于xml中,就必須實作包含有AttributeSet類型入參的構造方法,這裡我們直接實作比對父類的三個構造方法,如下所示:

public ScrollPickerView(@NonNull Context context) {
        this(context, null);
    }

    public ScrollPickerView(@NonNull Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public ScrollPickerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        initTask();
    }
           

其中有幾點需要注意,

  1. 我們将參數少的構造方法委托給了參數多的構造方法,以完成構造初始化。這也是正常的做法。
  2. 我們在參數最多的構造方法中調用了initTask,這個initTask的目的是初始化一個執行任務,該任務就是在ScrollPickerView滾動結束後調整item視圖的位置,使得被選中的item視圖剛好位于兩條分割線中。這裡暫時不對其進行分析,會在下面進行闡述。

好了,構造方法基本完成了,但是我們發現在構造方法中并沒有進行畫筆的初始化,這個畫筆是指用于繪制兩條分割線的畫筆,一般情況下我們都會在構造方法中完成初始化,那麼為什麼現在不這麼做?

理由是考慮到分割線的定制化,因為外界可以通過adapter來完成畫筆顔色的設定,而此時構造方法已經完成構造,是以無法擷取到這些資料。

有朋友說那放在onMeasure或者onDraw中不就行了?放在這裡确實也可以,但是存在一個性能和語義場景問題,首先,onMeasure的功能是完成view的測量,将畫筆的屬性設定放在這裡面顯然不太合适。其次,onDraw方法是view中被非常頻繁調用的方法,如果畫筆在此設定顯然會影響性能,是以,也不能在此設定。那麼還有更好的方法嗎?

有,那就是在onAttachedToWindow方法中進行設定,這個方法會在view構造完成後調用,而且隻調用一次,如下所示:

@Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        initPaint();
    }
    private void initPaint() {
        if (mBgPaint == null) {
            mBgPaint = new Paint();
            mBgPaint.setColor(getLineColor());    mBgPaint.setStrokeWidth(ScreenUtil.dpToPx(1f));
        }
    }

           

其中getLineColor方法,就是擷取外部設定的分割線顔色,正是通過上文中的IPickerViewOperation完成的,IPickerViewOperation接口的設計理念請參考上篇文章,getLineColor方法代碼如下所示:

private int getLineColor() {
        IPickerViewOperation operation = (IPickerViewOperation) getAdapter();
        if (operation != null && operation.getLineColor() != 0) {
            return operation.getLineColor();
        }
        return getResources().getColor(R.color.colorPrimary);
    }
           

那麼接下來該幹什麼?接下來當然就是完成ScrollPickerView的測繪,也就是複寫onMeasure方法。

因為ScrollPickerView本身是個容器,這個容器會包含若幹個item視圖,是以如果我們想要保證容器大小合适,就必須自内而外的發起measure,也就是根據item視圖來決定ScrollPickerView本身的寬高。

ScrollPickerView的onMeasure代碼實作如下:

protected void onMeasure(int widthSpec, int heightSpec) {

        widthSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
        heightSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);

        super.onMeasure(widthSpec, heightSpec);

        measureSize();

        setMeasuredDimension(mItemWidth, mItemHeight * getVisibleItemNumber());

        initPaint();
    }
           

這段代碼主要注意以下幾點:

  1. 前面文章我們說過,ScrollPickerView的寬高要由item視圖來決定,而不能根據外界的設定決定,因為這樣容易影響滾動選擇器的整體效果,是以這裡我們首先設定了MeasureSpec,将widthSpec設定成UNSPECIFIED,表示其寬度是不受限制的,而将heightSpec設定成AT_MOST(可以了解為對應于wrap_content)表示會根item視圖高度完成滾動選擇器的高度測量。這裡解釋下,上面所說的寬度不受限制,實際上并不是任意的,因為代碼後面會根據item的寬度完成測量。
  2. 設定好MeasureSpec後,我們調用了 super.onMeasure(widthSpec, heightSpec);因為原來父view傳給我們的MeasureSpec并不是這樣的。
  3. 調用measureSize方法完成測繪,其核心思想就是擷取item視圖的高度和寬度,并完成兩條分割線的Y坐标測繪,該方法的實作如下所示:
private void measureSize() {
        if (getChildCount() > 0) {
            if (mItemHeight == 0) {
                mItemHeight = getChildAt(0).getMeasuredHeight();
            }
            if (mItemWidth == 0) {
                mItemWidth = getChildAt(0).getMeasuredWidth();
            }

            if (mFirstLineY == 0 || mSecondLineY == 0) {
                mFirstLineY = mItemHeight * getItemSelectedOffset();
                mSecondLineY = mItemHeight * (getItemSelectedOffset() + 1);
            }
        }
    }
           

這裡分割線Y坐标的測量方法需要結合被選中item視圖的偏移量來完成,即第一條線的Y坐标就是item視圖的高度乘以被選中item視圖的高度,而第二條隻需要在增加一個itemHeight高度即可。

  1. 最後我們根據測量到的item視圖高度和寬度,完成測量設定:
setMeasuredDimension(mItemWidth, mItemHeight * getVisibleItemNumber());
           

getVisibleItemNumber()表示item視圖的可見數目,同樣由IPickerViewOperation提供。

測量完成後,接下來就是渲染繪制了,對應的方法就是onDraw方法,該方法涉及的代碼如下所示:

@Override
    public void onDraw(Canvas c) {
        super.onDraw(c);
        doDraw(c);
        if (!mFirstAmend) {
            mFirstAmend = true;
            ((LinearLayoutManager) getLayoutManager()).scrollToPositionWithOffset(getItemSelectedOffset(), 0);
        }
    }

    public void doDraw(Canvas canvas) {
        if (mItemHeight > 0) {
            int screenX = getWidth();
            int startX = screenX / 2 - mItemWidth / 2 - ScreenUtil.dpToPx(5);
            int stopX = mItemWidth + startX + ScreenUtil.dpToPx(5);

            canvas.drawLine(startX, mFirstLineY, stopX, mFirstLineY, mBgPaint);
            canvas.drawLine(startX, mSecondLineY, stopX, mSecondLineY, mBgPaint);
        }
    }
           

該段代碼的意義闡述如下:

  1. 首先我們完成了兩條分割線的繪制,這也是複寫onDraw方法的初衷,因為容器相關的可以由android架構幫我們完成繪制,但是背後的兩條分割線卻隻能我們自己來完成繪制。這個繪制很簡單,就是根據onMeasure測量出來的兩條分割線的Y坐标,完成繪制。
  2. 第一次onDraw的時候,我們進行了一次修正,這個修正就是根據使用者設定的被選中item視圖的偏移量進行的,其目的就是滾動到使用者選中的item視圖位置。

到這裡,實際上已經能夠看到一定的效果了,ScrollPickerView本身已經具備了滾動,而且兩條分割線也已經出來了,但是這隻是個輪廓,還有以下兩點沒有解決:

  1. 無法區分哪個item視圖是被選中的,有朋友說滾動到兩條分割線中間的不就是嘛!确實是這樣的,但是這個是從視覺上來說的,實際上從代碼的角度來看滾動到兩條分割線之間的item視圖和其他item視圖沒有任何差別,因為從代碼的角度還無法辨別該item視圖是否在分割線中間,即目前分割線和item視圖是獨立存在的。比如我要将被選中的item視圖字型變成紅色,就還無法做到。
  2. 即使從視覺上來看,也存在很大的問題,那就是當ScrollPickerView滾動停止時,發現item視圖會落在任意的位置,比如可能落在分割線上,也可能落在分割線内等等。

解決上述兩個問題,就需要複寫兩個方法,分别闡述如下:

  1. onScrolled方法。複寫該方法的目的就是為了解決上述第一個問題,如下所示:
@Override
    public void onScrolled(int dx, int dy) {
        super.onScrolled(dx, dy);
        freshItemView();
    }

    private void freshItemView() {
        for (int i = 0; i < getChildCount(); i++) {
            float itemViewY = getChildAt(i).getTop() + mItemHeight / 2;
            updateView(getChildAt(i), mFirstLineY < itemViewY && itemViewY < mSecondLineY);
        }
    }
           

重點就是freshItemView,這就是我們要解決被選中item視圖和分割線之間關系的重點。

首先,我們周遊了ScrollPickerView中可見的item視圖,然後判斷哪條item視圖位于兩條分割線之内。這裡判斷item視圖是否在兩條分割線之間的方法很簡單,就是通過目前item視圖在其父視圖中的Y坐标是否在兩條分割線之内進行判斷的。

最後,我們調用了updateView方法,将選中的視圖及其被選中的狀态傳遞了出去,實際上是通過adapter(IPickerViewOperation)來進行傳遞的,如下所示:

private void updateView(View itemView, boolean isSelected) {
        IPickerViewOperation operation = (IPickerViewOperation) getAdapter();
        if (operation != null) {
            operation.updateView(itemView, isSelected);
        }
    }
           
  1. 複寫onTouchEvent方法,複寫onTouchEvent方法就是為了解決上述的第二個問題,即解決滾動結束後被選中的item視圖必須要位于兩條分割線的正中間,是以我們要在使用者手指離開螢幕的時候進行調整,這就需要監聽ACTION_UP事件,如下所示:
@Override
    public boolean onTouchEvent(MotionEvent e) {
        if (e.getAction() == MotionEvent.ACTION_UP) {
            processItemOffset();
        }
        return super.onTouchEvent(e);
    }

    private void processItemOffset() {
        mInitialY = getScrollYDistance();
        postDelayed(mSmoothScrollTask, 30);
    }
           

這兩個方法的代碼很簡單,主要來看processItemOffset這個方法,其調用了一個方法getScrollYDistance和啟動了一個任務mSmoothScrollTask,而這個mSmoothScrollTask任務就是我們在ScrollPickerView構造方法中進行初始化的任務,下面先來看看getScrollYDistance的實作:

private int getScrollYDistance() {
        LinearLayoutManager layoutManager = (LinearLayoutManager) this.getLayoutManager();
        if (layoutManager == null) {
            return 0;
        }
        int position = layoutManager.findFirstVisibleItemPosition();
        View firstVisibleChildView = layoutManager.findViewByPosition(position);
        if (firstVisibleChildView == null) {
            return 0;
        }
        int itemHeight = firstVisibleChildView.getHeight();
        return (position) * itemHeight - firstVisibleChildView.getTop();
    }
           

這段代碼的本質就是根據容器中第一條可見的item視圖來完成ScrollPickerView的滾動Y距離,從代碼可知這裡adapter隻能使用LinearLayoutManager作為布局管理者。

最後再來看下mSmoothScrollTask任務所做的工作,其代碼如下所示:

mSmoothScrollTask = new Runnable() {
            @Override
            public void run() {
                int newY = getScrollYDistance();
                if (mInitialY != newY) {
                    mInitialY = getScrollYDistance();
                    postDelayed(mSmoothScrollTask, 30);
                } else if (mItemHeight > 0) {
                    final int offset = mInitialY % mItemHeight;//離選中區域中心的偏移量
                    if (offset == 0) {
                        return;
                    }
                    if (offset >= mItemHeight / 2) {//滾動區域超過了item高度的1/2,調整position的值
                        smoothScrollBy(0, mItemHeight - offset);
                    } else if (offset < mItemHeight / 2) {
                        smoothScrollBy(0, -offset);
                    }
                }
            }
        };
           

這裡闡述下上面代碼的思想。

首先,從整體上來講,上面代碼就是為了完成被選中item視圖位置調整的功能,因為我們要保證使被選中的視圖剛好停在兩條分割線的中間。

在調整之前,我們進行了if (mInitialY != newY) 的判斷,mInitialY就是在ScrollPickerView滾動結束後通過getScrollYDistance擷取的值,而newY也是通過getScrollYDistance擷取的值,隻不過是在mSmoothScrollTask剛開始執行的時候擷取的,這個比較是為了處理在mSmoothScrollTask剛要執行的時候使用者又突然滑動的狀況,這種狀況下顯然沒有必要進行調整,是以直接結束目前任務,然後再觸發一次mSmoothScrollTask任務即可。

如果mInitialY == newY,就表示在執行調整任務的時候,使用者已經停止了滑動,這個是合情合理的,該調整任務主要工作闡述如下:

  1. 擷取被選中item視圖偏離兩條分割線中間的偏移量offset,如果offset==0,表明剛好落在兩條分割線中間,則無需調整。
  2. 如果offset >= mItemHeight / 2,則表示此時被選中的item視圖(稱之為itemA)滾動到了兩條分割線中間點的下方,其趨勢是向下滾動,是以我們就繼續使其向下滾動,滾動距離為mItemHeight - offset,剛好使得itemA上面的item視圖滾動到兩條分割線中間,作為被選中item視圖。
  3. 如果offset < mItemHeight / 2,則表示此時被選中的item視圖(稱之為itemA)滾動到了兩條分割線的上半部分,其趨勢是無法再繼續向下滾動,而是有回彈的迹象,是以此時我們隻需要回退offset個距離即可,這樣就等于将itemA的下一個item視圖滾動到了兩條分割線中間。

至此ScrollPickerView的實作闡述完畢。下篇文章android自定義滾動選擇器(三)會闡述ScrollPickerAdapter及其預設的item視圖實作。