專案需求討論-自定義滾輪

青蛙要fly發表於2019-02-28

大家好,這次又是到了實際的專案需求討論時間,我的一些文章下面,有時候有人評論,求原始碼,求Demo,我的主張是仔細看文章,自己理解了再寫一遍,會懂得更多。大部分人都習慣直接拿過來用,能用了都不會去看為什麼能實現需求。所以我還是寫以如何實現為主。(這B裝的我太累了。我TM就是懶啊。不想寫Demo,自覺性還是有待提高。)

這次是關於滾輪方面需求,美工又出難題了。叫開發做一個滾輪,實現的效果如下GIF圖所示:

需求:

  1. 滾輪進行滾動,並且要求是迴圈滾動。就是比如從A滑到了G,繼續滑動又到了A。
  2. 比如A開始滑動,滑到B,但是你其實只滑動了一點點,那放手後當然是重新彈回A處,只有當你滑動的距離超過每項的一半的時候,才能讓那一項滾到中間。
  3. 比如A項已經滾到了中間了,然後要再點選中間那一項,然後滾輪上面空白介面相應的介面會被更新,只能點選滾輪中間那項部分,其他的點選沒效果。

開始起航:

我們就一步步來,先做一個滾輪,我們知道,滾輪具有滾動效果,所以我們就直接讓我們自定義滾輪繼承ScrollView。從上面的GIF圖可知,我們的滾輪顯示在介面上的是有五項,也就是我們比如規定我們的每項的高度是50dp,那我們的自定義滾輪就是每項的高度乘以你要顯示在介面的個數(50dp X 5 = 250dp)。

我們先來繼承ScrollView:

public class WheelView extends ScrollView {
    public WheelView(Context context) {
        super(context);
        init(context);
    }

    public WheelView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(context);

    }

    public WheelView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        init(context);
    }
}複製程式碼

然後在init()方法中我們初始化滾輪具備的一些功能,比如我們上面的<需求2>

private LinearLayout views;
Runnable scrollerTask;
int initialY;

private void init(Context context) {
    this.context = context;

    this.setVerticalScrollBarEnabled(false);

    views = new LinearLayout(context);
    views.setOrientation(LinearLayout.VERTICAL);
    this.addView(views);

    scrollerTask = new Runnable() {

        public void run() {

            int newY = getScrollY();
            if (initialY - newY == 0) { // stopped
                final int remainder = initialY % itemHeight;
                final int divided = initialY / itemHeight;

                if (remainder == 0) {
                    selectedIndex = divided + offset;

                    onSeletedCallBack();
                } else {
                    if (remainder > itemHeight / 2) {
                        WheelView.this.post(new Runnable() {
                            @Override
                            public void run() {
                                WheelView.this.smoothScrollTo(0, initialY - remainder + itemHeight);
                                selectedIndex = divided + offset + 1;
                                onSeletedCallBack();
                            }
                            });
                    } else {
                        WheelView.this.post(new Runnable() {
                            @Override
                            public void run() {
                                WheelView.this.smoothScrollTo(0, initialY - remainder);
                                selectedIndex = divided + offset;
                                onSeletedCallBack();
                            }
                        });
                    }
                }
            } else {
                initialY = getScrollY();
                WheelView.this.postDelayed(scrollerTask, newCheck);
            }
        }
    };
}複製程式碼

我們來分析下我們的init()方法,首先我們都知道ScrollView中只能有一個子控制元件,但我們滾輪裡面有很多一項項的item,那怎麼弄呢。 先在ScrollView中放一個LinearLayout,然後把我們要顯示的滾輪中的每一項再加入到這個LinearLayout中即可。同時大家也知道ScrollView本身在右邊會有一個顯示的滾動條,我們還要把這個滾動條去除掉。程式碼如下:

this.setVerticalScrollBarEnabled(false);

views = new LinearLayout(context);
views.setOrientation(LinearLayout.VERTICAL);
this.addView(views);複製程式碼

好了,我們再來看,我們如何能實現<需求2>


如何一格一格的滾動:

我們先來知道一個東西,如何讓他每次滾動是滾一個Item呢,而不是說直接卡在一半,就是說我直接划動一部分距離,然後ScrollView中的內容就顯示成下面這個圖:


因為我們知道ScrollView的滾動不是特定一格一格滾動的,所以我們要用到了ScrollView中的smoothScrollTo方法了(可能有人會問,為啥不用ScrollTo,也可以,但是用smoothScrollTo滾動的時候是平緩的而不是立即滾動到某處)。我們比如滑動一部分。我們就讓ScrollView直接smoothScrollTo相應的item的Y值處。這樣,就相當於滑動是一格一格的。

假設我們現在這個滾輪是隻顯示3項,假設每個的高度都是100,然後我們比如往上滑,比如讓C居於中間,我們只要smoothScrollTo(0,100),比如再往上移動一格呢,就是smoothScrollTo(0,200),再往上移動一格就是smoothScrollTo(0,300),也就是相當於smoothScrollTo(0,(當前的Y值)+(偏移的格數 * 每格的高度)),如果是向下移動,裡面的偏移的格數就為負數,當前與當前的Y值減去相應的高度即可。


如何計算偏移格數:

所以我們已經解決了每次移動的時候一定是一格一格的移動,而不會說滑動了後,在二根紅線內顯示一半的Item。接下去我們要處理如何定位我滑動的時候來確定上述公式裡面的偏移格數。

我們先來獲取你滾動到哪裡了:使用getScrollY(),所以當我們滑動了,我們就能獲取到我們這次滾動到哪裡了,這裡我要分二塊來講:

  1. 慢慢的滑動 :
    慢慢滑動的時候,我們獲取到的移動距離就直接是getScrollY(),我們只需要監聽onTouch事件,然後在監聽MotionEvent.ACTION_UP事件,當監聽到手指抬起來了。我們就通過getScrollY()獲取到這時候的滾動的到哪裡了。

  2. 用力的滑動後放開:
    這時候在監聽MotionEvent.ACTION_UP事件的時候,你如果獲取了getScrollY的值,判定當前滑動到了這個位置是不準確的,為什麼,因為ScrollView還有因為慣性在滑動,所以這時候我們要不停的獲取getScrollY的值,比如第一次取跟第二次取相比較,如果不同,再取一次,每次都取了之後與前面一次取得值相比較,如果相同,才能說明ScrollView已經停下來了。這時候的距離才是真正的滾輪停止的位置。

所以我們在根據上面說的,我們重寫onTouch監聽MotionEvent.ACTION_UP的事件:

@Override
public boolean onTouchEvent(MotionEvent ev) {
    if (ev.getAction() == MotionEvent.ACTION_UP) {
        startScrollerTask();
    }
    return super.onTouchEvent(ev);
}

int newCheck = 50;
public void startScrollerTask() {
    initialY = getScrollY();
    this.postDelayed(scrollerTask, newCheck);
}複製程式碼

我們獲取了手指放開時候的當前ScrollView 所處的位置,然後延遲一點點時間後執行了上面我們在init方法中自定義的Runnable,因為等會這個Runnable裡面會再次獲取ScrollView 的滾動位置,要用來比較,所以要延遲一點點時間。

我們來看下我們的自定義的Runnable的內容:

scrollerTask = new Runnable() {

    public void run() {

        int newY = getScrollY();
        if (initialY - newY == 0) { // stopped
            final int remainder = initialY % itemHeight;
            final int divided = initialY / itemHeight;

            if (remainder == 0) {
                selectedIndex = divided + offset;

                onSeletedCallBack();
            } else {
                if (remainder > itemHeight / 2) {
                    WheelView.this.post(new Runnable() {
                        @Override
                        public void run() {
                            WheelView.this.smoothScrollTo(0, initialY - remainder + itemHeight);
                            selectedIndex = divided + offset + 1;
                            onSeletedCallBack();
                        }
                        });
                } else {
                    WheelView.this.post(new Runnable() {
                        @Override
                        public void run() {
                            WheelView.this.smoothScrollTo(0, initialY - remainder);
                            selectedIndex = divided + offset;
                            onSeletedCallBack();
                        }
                    });
                }
            }
        } else {
            initialY = getScrollY();
            WheelView.this.postDelayed(scrollerTask, newCheck);
        }
    }
};複製程式碼

我們發現,我們在Runnable中再次呼叫了int newY = getScrollY();,然後獲取了新的位置,然後跟剛才在onTouch中獲取到的進行比較,如果相同,說明ScrollView已經停止了。如果不同,說明ScrollView還在滾動中,我們要再次呼叫:

initialY = getScrollY();
WheelView.this.postDelayed(scrollerTask, newCheck);複製程式碼

然後再次重複執行Runnable,直到我們發現後面獲取到的getScrollY的值和延遲後獲取到的getScrollY的值相同,說明了我們的ScrollView已經停止滾動了。這時候獲取到的getScrollY的值就是當前這次滑動後的ScrollView 真正處於的位置了。

到了我們核心的部分了:通過獲取到的ScrollView的滾動位置來計算出當前處於是哪個Item,然後我們要來通過smoothScrollTo移動這個到指定的Item項即可。

我們還是分二種情況分析:

  1. 第一種情況:

    我們還是來舉例子,綠色的框是我們的手機螢幕,二根紅線就是我們中間項的分割線,比如我們原來螢幕上顯示的是A,B,C 三項,我輕輕的往上移動了80的距離,這時候我們獲取到的getScrollY是80,
    我們通過拿到的getScrollY的值與每項的Item的高度做除法及求餘演算法。
    final int remainder = initialY % itemHeight;
    final int divided = initialY / itemHeight;複製程式碼
    通過divided我們就知道了當前螢幕上的能顯示的第一個是哪個Item了。通過remainder我們就知道了螢幕的頂部處於這個Item的哪個位置。
    比如當前因為我們的getScrollY是80,所以我們divided = 80 / 100 = 0;所以當前螢幕上的顯示的第一個就是index為0的那項,也就是A;remainder = 80 % 100 = 80,所以我們知道顯示的第一項的大部分的都在螢幕外面了,只留下了(itemHeight - remainder ,即 100- 80 = 20)在螢幕裡面。

這時候我要問大家了,我放開手,這時候想要的效果應該是什麼,是不是A完全移出介面,然後B變成第一個,C變中間,D變最後一個,這時候理論上呼叫的程式碼應該是smoothScrollTo(0,1 * itemHeight);也就是smoothScrollTo(0,100)。那這個1是怎麼來的呢。

if (remainder > itemHeight / 2) {
    WheelView.this.post(new Runnable() {
        @Override
            public void run() {
            WheelView.this.smoothScrollTo(0, initialY - remainder + itemHeight);
            ....
            ....
            ....
        }
    });
}複製程式碼

是不是因為是我們求餘數得到的remainder大於了50(itemHeight/2),所以本來第一個明明是A,我們卻讓B變成了第一個,所以我們只需要把這個餘數變為itemHeight就行了。我們給他補上20,不就等於把A成功的移出螢幕了嗎? 所以不就是initialY - remainder + itemHeight

  1. 第二種情況:

如果我們只是滾動了一點點,比如我們這裡只往上滾動了20的距離,這時候getScrollY為20,我們這時候獲取到的

final int remainder = initialY % itemHeight;
final int divided = initialY / itemHeight;複製程式碼

remainder = 20,divided = 0,說明還是index為0的處於螢幕第一個,那這時候因為remainder 小於 itemHeight/2 ,所以我們的期望是讓A往下滾動,然後螢幕上顯示為A,B,C ,
所以我們期望的是smoothScrollTo(0,0),也就是:

WheelView.this.post(new Runnable() {
    @Override
    public void run() {
        WheelView.this.smoothScrollTo(0, initialY - remainder);
        ....
        ....
        ....
    }
});複製程式碼

沒錯。既然多了20,而且我們因為不需要滾動,直接把這個20減掉不就好了麼。所以直接就是initailY - remainder


中間的紅線部分:

大家看見我上面的圖中,有二根紅線。我不是故意把中間的一項給標記出來,用二根紅線給提示下,而是因為美工設計的時候說,預設中間的是第一項,而且是中間紅線包裹的地方才表示這一項處於選中狀態。因為本來預設的肯定是:

預設的ScrollView 顯示
預設的ScrollView 顯示

專案需求
專案需求

不過既然原理我們上面都懂了,我們還慌啥,其實很簡單,比如這個需求,有螢幕上有三個Item,預設是中間那個,我們只需要在A的前面多加一個空資料,在尾巴處也多加一個空資料,即:[空資料,A,B,C,D,E,空資料]。這樣ScrollView剛初始化好的時候,我們的A就處於中間位置了。有人會問為什麼最後一個還要一個空資料,因為不然你最後一項E就不能顯示到中間紅線部分,就無法處於被選中狀態。

那如果一個螢幕顯示五項,然後中間是選中的項,那就新增二個:
[空資料,空資料,A,B,C,D,E,空資料,空資料]
哈哈,是不是很簡單。

所以你們會看到我上面的init方法中的程式碼:
selectedIndex = divided + offset;類似的有出現過offset的這個值,也就是偏移值,因為我們當前預設的選中的項不是第一項,而是中間這項,比如我們本來的divided是0,也就是螢幕第一個是A,但是當前A要處於中間才是選中裝填,我們只要設定offset = 1就可以了。

往ScrollView裡面加具體的Item:

上面我們已經講了原理了。現在我們就要往ScrollView中的LinearLayout裡面加具體的Item,其實這個更簡單了。
我們在外界往我們的自定義ScrollView中傳入列表資料,我這裡用了普通的字串:
(程式碼裡面的資料頭和尾巴補上偏移值上面剛講過,大家應該還記得 )

public void setItems(List list) {
    if (null == items) {
        items = new ArrayList();
    }
    items.clear();
    items.addAll(list);

    // 前面和後面補全
    for (int i = 0; i < offset; i++) {
        items.add(0, "");
        items.add("");
    }

    initData();
}


int displayItemCount; // 每頁顯示的數量
private void initData() {
    displayItemCount = offset * 2 + 1;

    for (String item : items) {
        views.addView(createView(item));
    }
}


private TextView createView(String item) {

    TextView tv = new TextView(context);
    tv.setLayoutParams(new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,dip2px(74)));
    tv.setSingleLine(true);
    tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
    tv.setText(item);
    tv.setGravity(Gravity.CENTER);
    int padding = dip2px(15);
    tv.setPadding(padding, padding, padding, padding);
    if (0 == itemHeight) {
        itemHeight = dip2px(74);
        Log.d(TAG, "itemHeight: " + itemHeight);
        views.setLayoutParams(new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, itemHeight * displayItemCount));
        LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) this.getLayoutParams();
        this.setLayoutParams(new LinearLayout.LayoutParams(lp.width, itemHeight * displayItemCount));
    }
    return tv;
}複製程式碼

中間的二根紅線怎麼畫出來:

其實很簡單,只要對ScrollView設定背景即可,

然後背景的畫布上,在Y值為100處畫一根線,然後在Y值為200的地方畫一根線,也就是:
一根在Y值為:itemHeight offset;
一根在Y值為: itemHeight
(offset+1)即可;

@Override
public void setBackgroundDrawable(Drawable background) {
    if (viewWidth == 0) {
        viewWidth = ((Activity) context).getWindowManager().getDefaultDisplay().getWidth();
    }

    if (null == paint) {
        paint = new Paint();
        paint.setColor(Color.parseColor("#83cde6"));
        paint.setStrokeWidth(dip2px(1f));
    }

    background = new Drawable() {
        @Override
        public void draw(Canvas canvas) {

            canvas.drawLine(viewWidth * 1 / 6, itemHeight * offset, viewWidth * 5 / 6, obtainSelectedAreaBorder()[0], paint);
            canvas.drawLine(viewWidth * 1 / 6, itemHeight * (offset+1), viewWidth * 5 / 6, obtainSelectedAreaBorder()[1], paint);

        }

        @Override
        public void setAlpha(int alpha) {

        }

        @Override
        public void setColorFilter(ColorFilter cf) {

        }

        @Override
        public int getOpacity() {
            return PixelFormat.UNKNOWN;
        }
    };

    super.setBackgroundDrawable(background);
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    viewWidth = w;
    setBackgroundDrawable(null);
}複製程式碼

迴圈滾動:

其實我做的是一個假迴圈,因為我想到了迴圈的Banner廣告,其實就是在頭部加一個最後一頁的資料,然後在尾部加第一個的資料,滑到最後的時候,再滑動,然後會跳到第一個。我也是用了這個思路。

還記不記得我們前面為了偏移值,所以多加了空資料,我們就不弄空資料了,直接加真的資料。
[A,B,C,D,E, A,B,C,D,E, A,B,C,D,E]
預設顯示的是中間的A-E ,然後每次滑到不是中間的A-E,就預設移動回到中間的A-E就可以了。

然後把滾動的速度減慢:

@Override
public void fling(int velocityY) {
    super.fling(velocityY / 5);
}複製程式碼

結語:

當然我覺得這個假迴圈也不是最優解。。希望大家能提供思路。謝謝大家,不要噴我。如果不噴。晚些時候我整理下,放上GIF圖所示的Demo上來。哈哈。不要噴我。。。

相關文章