在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)需要注意,
- 我們將參數(shù)少的構(gòu)造方法委托給了參數(shù)多的構(gòu)造方法,以完成構(gòu)造初始化。這也是常規(guī)的做法。
- 我們?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):
- 前面文章我們說過,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è)量。
- 設(shè)置好MeasureSpec后,我們調(diào)用了 super.onMeasure(widthSpec, heightSpec);因?yàn)樵瓉?lái)父view傳給我們的MeasureSpec并不是這樣的。
- 調(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高度即可。
- 最后我們根據(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);
}
}
該段代碼的意義闡述如下:
- 首先我們完成了兩條分割線的繪制,這也是復(fù)寫onDraw方法的初衷,因?yàn)槿萜飨嚓P(guān)的可以由android框架幫我們完成繪制,但是背后的兩條分割線卻只能我們自己來(lái)完成繪制。這個(gè)繪制很簡(jiǎn)單,就是根據(jù)onMeasure測(cè)量出來(lái)的兩條分割線的Y坐標(biāo),完成繪制。
- 第一次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)沒有解決:
- 無(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ú)法做到。
- 即使從視覺上來(lái)看,也存在很大的問題,那就是當(dāng)ScrollPickerView滾動(dòng)停止時(shí),發(fā)現(xiàn)item視圖會(huì)落在任意的位置,比如可能落在分割線上,也可能落在分割線內(nèi)等等。
解決上述兩個(gè)問題,就需要復(fù)寫兩個(gè)方法,分別闡述如下:
- 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);
}
}
- 復(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ù)主要工作闡述如下:
- 獲取被選中item視圖偏離兩條分割線中間的偏移量offset,如果offset==0,表明剛好落在兩條分割線中間,則無(wú)需調(diào)整。
- 如果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視圖。
- 如果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)。