在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();
}
其中有幾點需要注意,
- 我們将參數少的構造方法委托給了參數多的構造方法,以完成構造初始化。這也是正常的做法。
- 我們在參數最多的構造方法中調用了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();
}
這段代碼主要注意以下幾點:
- 前面文章我們說過,ScrollPickerView的寬高要由item視圖來決定,而不能根據外界的設定決定,因為這樣容易影響滾動選擇器的整體效果,是以這裡我們首先設定了MeasureSpec,将widthSpec設定成UNSPECIFIED,表示其寬度是不受限制的,而将heightSpec設定成AT_MOST(可以了解為對應于wrap_content)表示會根item視圖高度完成滾動選擇器的高度測量。這裡解釋下,上面所說的寬度不受限制,實際上并不是任意的,因為代碼後面會根據item的寬度完成測量。
- 設定好MeasureSpec後,我們調用了 super.onMeasure(widthSpec, heightSpec);因為原來父view傳給我們的MeasureSpec并不是這樣的。
- 調用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高度即可。
- 最後我們根據測量到的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);
}
}
該段代碼的意義闡述如下:
- 首先我們完成了兩條分割線的繪制,這也是複寫onDraw方法的初衷,因為容器相關的可以由android架構幫我們完成繪制,但是背後的兩條分割線卻隻能我們自己來完成繪制。這個繪制很簡單,就是根據onMeasure測量出來的兩條分割線的Y坐标,完成繪制。
- 第一次onDraw的時候,我們進行了一次修正,這個修正就是根據使用者設定的被選中item視圖的偏移量進行的,其目的就是滾動到使用者選中的item視圖位置。
到這裡,實際上已經能夠看到一定的效果了,ScrollPickerView本身已經具備了滾動,而且兩條分割線也已經出來了,但是這隻是個輪廓,還有以下兩點沒有解決:
- 無法區分哪個item視圖是被選中的,有朋友說滾動到兩條分割線中間的不就是嘛!确實是這樣的,但是這個是從視覺上來說的,實際上從代碼的角度來看滾動到兩條分割線之間的item視圖和其他item視圖沒有任何差別,因為從代碼的角度還無法辨別該item視圖是否在分割線中間,即目前分割線和item視圖是獨立存在的。比如我要将被選中的item視圖字型變成紅色,就還無法做到。
- 即使從視覺上來看,也存在很大的問題,那就是當ScrollPickerView滾動停止時,發現item視圖會落在任意的位置,比如可能落在分割線上,也可能落在分割線内等等。
解決上述兩個問題,就需要複寫兩個方法,分别闡述如下:
- 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);
}
}
- 複寫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,就表示在執行調整任務的時候,使用者已經停止了滑動,這個是合情合理的,該調整任務主要工作闡述如下:
- 擷取被選中item視圖偏離兩條分割線中間的偏移量offset,如果offset==0,表明剛好落在兩條分割線中間,則無需調整。
- 如果offset >= mItemHeight / 2,則表示此時被選中的item視圖(稱之為itemA)滾動到了兩條分割線中間點的下方,其趨勢是向下滾動,是以我們就繼續使其向下滾動,滾動距離為mItemHeight - offset,剛好使得itemA上面的item視圖滾動到兩條分割線中間,作為被選中item視圖。
- 如果offset < mItemHeight / 2,則表示此時被選中的item視圖(稱之為itemA)滾動到了兩條分割線的上半部分,其趨勢是無法再繼續向下滾動,而是有回彈的迹象,是以此時我們隻需要回退offset個距離即可,這樣就等于将itemA的下一個item視圖滾動到了兩條分割線中間。
至此ScrollPickerView的實作闡述完畢。下篇文章android自定義滾動選擇器(三)會闡述ScrollPickerAdapter及其預設的item視圖實作。