android自定義滾動(dòng)選擇器(二)

android自定義滾動(dòng)選擇器(一)這篇文章中,我們已經(jīng)闡述了滾動(dòng)選擇器的實(shí)現(xiàn)原理以及準(zhǔn)備事項(xiàng),本篇文章將會(huì)從代碼的角度一步步來(lái)實(shí)現(xiàn)該滾動(dòng)選擇器。

如果來(lái)不及閱讀文章,或者想直接獲取源碼,見git:android自定義滾動(dòng)選擇器

ScrollPickerView的實(shí)現(xiàn)

ScrollPickerView這個(gè)是我們的主視圖,說白了就是我們的滾動(dòng)選擇器,本小節(jié)先來(lái)闡述下其代碼實(shí)現(xiàn)。

首先,我們要將ScrollPickerView用于xml中,就必須實(shí)現(xiàn)包含有AttributeSet類型入?yún)⒌臉?gòu)造方法,這里我們直接實(shí)現(xiàn)匹配父類的三個(gè)構(gòu)造方法,如下所示:

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

其中有幾點(diǎn)需要注意,

  1. 我們將參數(shù)少的構(gòu)造方法委托給了參數(shù)多的構(gòu)造方法,以完成構(gòu)造初始化。這也是常規(guī)的做法。
  2. 我們?cè)趨?shù)最多的構(gòu)造方法中調(diào)用了initTask,這個(gè)initTask的目的是初始化一個(gè)執(zhí)行任務(wù),該任務(wù)就是在ScrollPickerView滾動(dòng)結(jié)束后調(diào)整item視圖的位置,使得被選中的item視圖剛好位于兩條分割線中。這里暫時(shí)不對(duì)其進(jìn)行分析,會(huì)在下面進(jìn)行闡述。

好了,構(gòu)造方法基本完成了,但是我們發(fā)現(xiàn)在構(gòu)造方法中并沒有進(jìn)行畫筆的初始化,這個(gè)畫筆是指用于繪制兩條分割線的畫筆,一般情況下我們都會(huì)在構(gòu)造方法中完成初始化,那么為什么現(xiàn)在不這么做?

理由是考慮到分割線的定制化,因?yàn)橥饨缈梢酝ㄟ^adapter來(lái)完成畫筆顏色的設(shè)置,而此時(shí)構(gòu)造方法已經(jīng)完成構(gòu)造,所以無(wú)法獲取到這些數(shù)據(jù)。

有朋友說那放在onMeasure或者onDraw中不就行了?放在這里確實(shí)也可以,但是存在一個(gè)性能和語(yǔ)義場(chǎng)景問題,首先,onMeasure的功能是完成view的測(cè)量,將畫筆的屬性設(shè)置放在這里面顯然不太合適。其次,onDraw方法是view中被非常頻繁調(diào)用的方法,如果畫筆在此設(shè)置顯然會(huì)影響性能,所以,也不能在此設(shè)置。那么還有更好的方法嗎?

有,那就是在onAttachedToWindow方法中進(jìn)行設(shè)置,這個(gè)方法會(huì)在view構(gòu)造完成后調(diào)用,而且只調(diào)用一次,如下所示:

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

其中g(shù)etLineColor方法,就是獲取外部設(shè)置的分割線顏色,正是通過上文中的IPickerViewOperation完成的,IPickerViewOperation接口的設(shè)計(jì)理念請(qǐng)參考上篇文章,getLineColor方法代碼如下所示:


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

那么接下來(lái)該干什么?接下來(lái)當(dāng)然就是完成ScrollPickerView的測(cè)繪,也就是復(fù)寫onMeasure方法。

因?yàn)镾crollPickerView本身是個(gè)容器,這個(gè)容器會(huì)包含若干個(gè)item視圖,所以如果我們想要保證容器大小合適,就必須自內(nèi)而外的發(fā)起measure,也就是根據(jù)item視圖來(lái)決定ScrollPickerView本身的寬高。

ScrollPickerView的onMeasure代碼實(shí)現(xiàn)如下:

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

這段代碼主要注意以下幾點(diǎn):

  1. 前面文章我們說過,ScrollPickerView的寬高要由item視圖來(lái)決定,而不能根據(jù)外界的設(shè)置決定,因?yàn)檫@樣容易影響滾動(dòng)選擇器的整體效果,所以這里我們首先設(shè)置了MeasureSpec,將widthSpec設(shè)置成UNSPECIFIED,表示其寬度是不受限制的,而將heightSpec設(shè)置成AT_MOST(可以理解為對(duì)應(yīng)于wrap_content)表示會(huì)根item視圖高度完成滾動(dòng)選擇器的高度測(cè)量。這里解釋下,上面所說的寬度不受限制,實(shí)際上并不是任意的,因?yàn)榇a后面會(huì)根據(jù)item的寬度完成測(cè)量。
  2. 設(shè)置好MeasureSpec后,我們調(diào)用了 super.onMeasure(widthSpec, heightSpec);因?yàn)樵瓉?lái)父view傳給我們的MeasureSpec并不是這樣的。
  3. 調(diào)用measureSize方法完成測(cè)繪,其核心思想就是獲取item視圖的高度和寬度,并完成兩條分割線的Y坐標(biāo)測(cè)繪,該方法的實(shí)現(xiàn)如下所示:
    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坐標(biāo)的測(cè)量方法需要結(jié)合被選中item視圖的偏移量來(lái)完成,即第一條線的Y坐標(biāo)就是item視圖的高度乘以被選中item視圖的高度,而第二條只需要在增加一個(gè)itemHeight高度即可。

  1. 最后我們根據(jù)測(cè)量到的item視圖高度和寬度,完成測(cè)量設(shè)置:
        setMeasuredDimension(mItemWidth, mItemHeight * getVisibleItemNumber());

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

測(cè)量完成后,接下來(lái)就是渲染繪制了,對(duì)應(yīng)的方法就是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. 首先我們完成了兩條分割線的繪制,這也是復(fù)寫onDraw方法的初衷,因?yàn)槿萜飨嚓P(guān)的可以由android框架幫我們完成繪制,但是背后的兩條分割線卻只能我們自己來(lái)完成繪制。這個(gè)繪制很簡(jiǎn)單,就是根據(jù)onMeasure測(cè)量出來(lái)的兩條分割線的Y坐標(biāo),完成繪制。
  2. 第一次onDraw的時(shí)候,我們進(jìn)行了一次修正,這個(gè)修正就是根據(jù)用戶設(shè)置的被選中item視圖的偏移量進(jìn)行的,其目的就是滾動(dòng)到用戶選中的item視圖位置。

到這里,實(shí)際上已經(jīng)能夠看到一定的效果了,ScrollPickerView本身已經(jīng)具備了滾動(dòng),而且兩條分割線也已經(jīng)出來(lái)了,但是這只是個(gè)輪廓,還有以下兩點(diǎn)沒有解決:

  1. 無(wú)法區(qū)分哪個(gè)item視圖是被選中的,有朋友說滾動(dòng)到兩條分割線中間的不就是嘛!確實(shí)是這樣的,但是這個(gè)是從視覺上來(lái)說的,實(shí)際上從代碼的角度來(lái)看滾動(dòng)到兩條分割線之間的item視圖和其他item視圖沒有任何區(qū)別,因?yàn)閺拇a的角度還無(wú)法標(biāo)識(shí)該item視圖是否在分割線中間,即當(dāng)前分割線和item視圖是獨(dú)立存在的。比如我要將被選中的item視圖字體變成紅色,就還無(wú)法做到。
  2. 即使從視覺上來(lái)看,也存在很大的問題,那就是當(dāng)ScrollPickerView滾動(dòng)停止時(shí),發(fā)現(xiàn)item視圖會(huì)落在任意的位置,比如可能落在分割線上,也可能落在分割線內(nèi)等等。

解決上述兩個(gè)問題,就需要復(fù)寫兩個(gè)方法,分別闡述如下:

  1. onScrolled方法。復(fù)寫該方法的目的就是為了解決上述第一個(gè)問題,如下所示:
    @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);
        }
    }

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

首先,我們遍歷了ScrollPickerView中可見的item視圖,然后判斷哪條item視圖位于兩條分割線之內(nèi)。這里判斷item視圖是否在兩條分割線之間的方法很簡(jiǎn)單,就是通過當(dāng)前item視圖在其父視圖中的Y坐標(biāo)是否在兩條分割線之內(nèi)進(jìn)行判斷的。

最后,我們調(diào)用了updateView方法,將選中的視圖及其被選中的狀態(tài)傳遞了出去,實(shí)際上是通過adapter(IPickerViewOperation)來(lái)進(jìn)行傳遞的,如下所示:

    private void updateView(View itemView, boolean isSelected) {
        IPickerViewOperation operation = (IPickerViewOperation) getAdapter();
        if (operation != null) {
            operation.updateView(itemView, isSelected);
        }
    }
  1. 復(fù)寫onTouchEvent方法,復(fù)寫onTouchEvent方法就是為了解決上述的第二個(gè)問題,即解決滾動(dòng)結(jié)束后被選中的item視圖必須要位于兩條分割線的正中間,因此我們要在用戶手指離開屏幕的時(shí)候進(jìn)行調(diào)整,這就需要監(jiān)聽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);
    }

這兩個(gè)方法的代碼很簡(jiǎn)單,主要來(lái)看processItemOffset這個(gè)方法,其調(diào)用了一個(gè)方法getScrollYDistance和啟動(dòng)了一個(gè)任務(wù)mSmoothScrollTask,而這個(gè)mSmoothScrollTask任務(wù)就是我們?cè)赟crollPickerView構(gòu)造方法中進(jìn)行初始化的任務(wù),下面先來(lái)看看getScrollYDistance的實(shí)現(xiàn):

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

這段代碼的本質(zhì)就是根據(jù)容器中第一條可見的item視圖來(lái)完成ScrollPickerView的滾動(dòng)Y距離,從代碼可知這里adapter只能使用LinearLayoutManager作為布局管理者。

最后再來(lái)看下mSmoothScrollTask任務(wù)所做的工作,其代碼如下所示:

        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;//離選中區(qū)域中心的偏移量
                    if (offset == 0) {
                        return;
                    }
                    if (offset >= mItemHeight / 2) {//滾動(dòng)區(qū)域超過了item高度的1/2,調(diào)整position的值
                        smoothScrollBy(0, mItemHeight - offset);
                    } else if (offset < mItemHeight / 2) {
                        smoothScrollBy(0, -offset);
                    }
                }
            }
        };

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

首先,從整體上來(lái)講,上面代碼就是為了完成被選中item視圖位置調(diào)整的功能,因?yàn)槲覀円WC使被選中的視圖剛好停在兩條分割線的中間。

在調(diào)整之前,我們進(jìn)行了if (mInitialY != newY) 的判斷,mInitialY就是在ScrollPickerView滾動(dòng)結(jié)束后通過getScrollYDistance獲取的值,而newY也是通過getScrollYDistance獲取的值,只不過是在mSmoothScrollTask剛開始執(zhí)行的時(shí)候獲取的,這個(gè)比較是為了處理在mSmoothScrollTask剛要執(zhí)行的時(shí)候用戶又突然滑動(dòng)的狀況,這種狀況下顯然沒有必要進(jìn)行調(diào)整,所以直接結(jié)束當(dāng)前任務(wù),然后再觸發(fā)一次mSmoothScrollTask任務(wù)即可。

如果mInitialY == newY,就表示在執(zhí)行調(diào)整任務(wù)的時(shí)候,用戶已經(jīng)停止了滑動(dòng),這個(gè)是合情合理的,該調(diào)整任務(wù)主要工作闡述如下:

  1. 獲取被選中item視圖偏離兩條分割線中間的偏移量offset,如果offset==0,表明剛好落在兩條分割線中間,則無(wú)需調(diào)整。
  2. 如果offset >= mItemHeight / 2,則表示此時(shí)被選中的item視圖(稱之為itemA)滾動(dòng)到了兩條分割線中間點(diǎn)的下方,其趨勢(shì)是向下滾動(dòng),所以我們就繼續(xù)使其向下滾動(dòng),滾動(dòng)距離為mItemHeight - offset,剛好使得itemA上面的item視圖滾動(dòng)到兩條分割線中間,作為被選中item視圖。
  3. 如果offset < mItemHeight / 2,則表示此時(shí)被選中的item視圖(稱之為itemA)滾動(dòng)到了兩條分割線的上半部分,其趨勢(shì)是無(wú)法再繼續(xù)向下滾動(dòng),而是有回彈的跡象,所以此時(shí)我們只需要回退offset個(gè)距離即可,這樣就等于將itemA的下一個(gè)item視圖滾動(dòng)到了兩條分割線中間。

至此ScrollPickerView的實(shí)現(xiàn)闡述完畢。下篇文章android自定義滾動(dòng)選擇器(三)會(huì)闡述ScrollPickerAdapter及其默認(rèn)的item視圖實(shí)現(xiàn)。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容